Merge branch 'stable-3.9' into stable-3.10

* stable-3.9:
  Revert "Ensure plugin modules are bound in the baseInjector"
  Set version to 3.9.4-SNAPSHOT
  Set version to 3.9.3
  Fix endless loop when using "is:watched" in project watches
  ReviewCommand: When available use project when identifying a change

Release-Notes: skip
Change-Id: I230b6113eb19a11b6f9abc68f9f8bdbeb7857762
diff --git a/.bazelrc b/.bazelrc
index a8f1210..9662078 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1,3 +1,7 @@
+# TODO(davido): Migrate all dependencies from WORKSPACE to MODULE.bazel
+# https://issues.gerritcodereview.com/issues/303819949
+common --noenable_bzlmod
+
 build --workspace_status_command="python3 ./tools/workspace_status.py"
 build --repository_cache=~/.gerritcodereview/bazel-cache/repository
 build --action_env=PATH
@@ -47,6 +51,29 @@
 build:remote11_bb --config=config_bb
 build:remote11_bb --config=build_java11_shared
 
+# Builds using remotejdk_21, executes using remotejdk_21 or local_jdk
+build:build_java21_shared --java_language_version=21
+build:build_java21_shared --java_runtime_version=remotejdk_21
+build:build_java21_shared --tool_java_language_version=21
+build:build_java21_shared --tool_java_runtime_version=remotejdk_21
+
+build:java21 --config=build_java21_shared
+
+# Builds and executes on RBE using remotejdk_21
+build:remote21 --config=config_gcp
+build:remote21 --config=build_java21_shared
+
+# Define remote21 configuration alias
+build:remote21_gcp --config=remote21
+
+# Builds and executes on BuildBuddy RBE using remotejdk_11
+build:remote21_bb --config=config_bb
+build:remote21_bb --config=build_java21_shared
+
+# Enable modern C++ features
+build --cxxopt=-std=c++17
+build --host_cxxopt=-std=c++17
+
 # Enable strict_action_env flag to. For more information on this feature see
 # https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
 # This will be the new default behavior at some point (and the flag was flipped
@@ -58,9 +85,8 @@
 
 test --build_tests_only
 test --test_output=errors
-# This option is the default for Bazel 7 that is used on master since
-# change Ie7cb3003d, so this additional config should be removed when
-# merging to master.
-test --incompatible_sandbox_hermetic_tmp
 
 import %workspace%/tools/remote-bazelrc
+
+# User-specific .bazelrc
+try-import %workspace%/user.bazelrc
diff --git a/.bazelversion b/.bazelversion
index 91e4a9f..66ce77b 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-6.3.2
+7.0.0
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 19a19dd..2e90cff 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -264,6 +264,7 @@
 `-2..+2`, as the user's membership of `Foo Leads` effectively grant
 them access to the entire reference space, thanks to the wildcard.
 
+[[exclusive]]
 Gerrit also supports exclusive reference-level access control.
 
 It is possible to configure Gerrit to grant an exclusive ref level
@@ -443,13 +444,13 @@
 
 
 [[category_create]]
-=== Create Reference
+=== Create (aka Create Reference)
 
-The create reference category controls whether it is possible to
-create new references, branches or tags.  This implies that the
-reference must not already exist, it's not a destructive permission
-in that you can't overwrite or remove any previously existing
-references (and also discard any commits in the process).
+The create category controls whether it is possible to create new
+references, branches or tags.  This implies that the reference must not
+already exist, it's not a destructive permission in that you can't
+overwrite or remove any previously existing references (and also
+discard any commits in the process).
 
 It's probably most common to either permit the creation of a single
 branch in many gits (by granting permission on a parent project), or
@@ -484,9 +485,9 @@
 `${username}` are still reachable by the users.
 
 [[category_delete]]
-=== Delete Reference
+=== Delete (aka Delete Reference)
 
-The delete reference category controls whether it is possible to delete
+The delete category controls whether it is possible to delete
 references, branches or tags. It doesn't allow any other update of
 references.
 
@@ -550,29 +551,46 @@
 [[category_owner]]
 === Owner
 
-The `Owner` category controls which groups can modify the project's
-configuration.  Users who are members of an owner group can:
+The `Owner` category on `refs/*` controls which groups own the project,
+i.e. the users who are members of an owner group are called the
+`project owners`.
 
-* Change the project description
-* Grant/revoke any access rights, including `Owner`
+Project owners can change the link:config-project-config.html[project
+configuration], including:
 
-To get SSH branch access project owners must grant an access right to a group
-they are a member of, just like for any other user.
+* Granting/revoking any access rights (including the `Owner` access
+  right)
+* Changing the project description
+* Changing the link to the parent project
+* Changing the project options
+* Changing link:config-labels.html[labels]
+* Changing link:config-submit-requirements.html[submit requirements]
 
-Ownership over a particular branch subspace may be delegated by
-entering a branch pattern.  To delegate control over all branches
-that begin with `qa/` to the QA group, add `Owner` category
-for reference `+refs/heads/qa/*+`.  Members of the QA group can
-further refine access, but only for references that begin with
-`refs/heads/qa/`. See <<project_owners,project owners>> to find
-out more about this role.
+[NOTE]
+Access rights that are assigned to the magic
+link:#project_owners[Project Owners] group are resolved to the users
+that are project owners by having the `Owner` permission assigned on
+`refs/*`.
 
+[NOTE]
+To get branch access via Git project owners must grant an access right
+to a group they are a member of, just like for any other user.
+
+[NOTE]
 For the `All-Projects` root project any `Owner` access right on
 'refs/*' is ignored since this permission would allow users to edit the
 global capabilities, which is the same as being able to administrate
 the Gerrit server (e.g. the user could assign the `Administrate Server`
 capability to the own account).
 
+Ownership over a particular branch subspace may be delegated by
+entering a branch pattern. E.g. to delegate control over all branches
+that begin with `qa/` to the QA group, add the `Owner` category
+for reference `+refs/heads/qa/*+`. Members of the QA group can
+further refine access, but only for references that begin with
+`refs/heads/qa/`. See <<project_owners,project owners>> to find
+out more about this role.
+
 
 [[category_push]]
 === Push
@@ -1618,6 +1636,7 @@
 
 Access to refs can be blocked, allowed or denied.
 
+[[block-rule]]
 ==== BLOCK
 
 For blocking access, all rules marked BLOCK are tested, and if one
@@ -1643,6 +1662,7 @@
 permissions when they are processed in the ALLOW/DENY processing, as
 described in the next subsection.
 
+[[allow-rule]]
 ==== ALLOW
 
 For allowing access, all ALLOW/DENY rules that might apply to a ref
@@ -1656,6 +1676,7 @@
 This ordering lets project owners apply permissions specific to their
 project, overwriting the site defaults specified in All-Projects.
 
+[[deny-rule]]
 ==== DENY
 
 DENY is processed together with ALLOW.
diff --git a/Documentation/cmd-hook-commit-msg.txt b/Documentation/cmd-hook-commit-msg.txt
index e547822..ccb6d58 100644
--- a/Documentation/cmd-hook-commit-msg.txt
+++ b/Documentation/cmd-hook-commit-msg.txt
@@ -56,6 +56,12 @@
 The `Change-Id` will not be added if `gerrit.createChangeId` is set
 to `false` in the git config.
 
+The `Change-Id` will not be added to temporary commits created by
+`git commit --fixup` or `git commit --squash`, as well as commits
+with a subject line that begins with a lowercase word followed by
+an exclamation mark (e.g., `nopush!`). To override this behavior,
+set `gerrit.createChangeId` to `always` in the git config.
+
 If `gerrit.reviewUrl` is set to the base URL of the Gerrit server that
 changes are uploaded to (e.g. `https://gerrit-review.googlesource.com/`)
 in the git config, then instead of adding a `Change-Id` trailer, a `Link`
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index fb5904b..b276cdf 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -116,7 +116,11 @@
 current user is a member of are visible.
 +
 If `VISIBLE_GROUP`, only users who are members of at least one group
-that is visible to the current user are visible.
+that is visible to the current user are visible. To make an account
+visible to all users (e.g. because it is a service account) when
+`VISIBLE_GROUP` is used, create a `Public Users` group that has the
+`Make group visible to all registered users` option enabled and add the
+account as a group member.
 +
 If `NONE`, no users other than the current user are visible.
 +
@@ -526,10 +530,10 @@
 
 [[auth.cookieSecure]]auth.cookieSecure::
 +
-Sets "secure" flag of the authentication cookie.  If true, cookies
+Sets "secure" flag of the authentication cookie.  If `true`, cookies
 will be transmitted only over HTTPS protocol.
 +
-By default, false.
+By default, `false`.
 
 [[auth.emailFormat]]auth.emailFormat::
 +
@@ -558,7 +562,7 @@
 `'$site_path'/static`, so users can actually complete one or
 more agreements.
 +
-By default this is false (no agreements are used).
+By default this is `false` (no agreements are used).
 +
 To enable the actual usage of contributor agreement the project
 specific config option in the `project.config` must be set:
@@ -567,14 +571,14 @@
 
 [[auth.trustContainerAuth]]auth.trustContainerAuth::
 +
-If true then it is the responsibility of the container hosting
+If `true` then it is the responsibility of the container hosting
 Gerrit to authenticate users. In this case Gerrit will blindly trust
 the container.
 +
 This parameter only affects git over http traffic. If set to false
 then Gerrit will do the authentication (using Basic authentication).
 +
-By default this is set to false.
+By default this is set to `false`.
 
 
 [[auth.gitBasicAuthPolicy]]auth.gitBasicAuthPolicy::
@@ -652,7 +656,7 @@
 +
 This parameter only affects git over http and git over SSH traffic.
 +
-By default this is set to false.
+By default this is set to `false`.
 
 [[auth.userNameCaseInsensitive]]auth.userNameCaseInsensitive::
 +
@@ -679,29 +683,29 @@
 note name would be identical and thus conflict. These duplicates thus
 have to be deleted manually by deleting the respective external ID.
 +
-For newly initialized sites this option defaults to true.
+For newly initialized sites this option defaults to `true`.
 +
-Default is false.
+Default is `false`.
 
 [[auth.userNameCaseInsensitiveMigrationMode]]auth.userNameCaseInsensitiveMigrationMode::
 +
-Setting migration mode to true allows to fallback to case sensitive
+Setting migration mode to `true` allows to fallback to case sensitive
 behaviour if the migrated external ID cannot be found. This allows to
 trigger the migration while Gerrit process is running.
 +
-Default is false.
+Default is `false`.
 
 [[auth.enableRunAs]]auth.enableRunAs::
 +
-If true HTTP REST APIs will accept the `X-Gerrit-RunAs` HTTP request
+If `true` HTTP REST APIs will accept the `X-Gerrit-RunAs` HTTP request
 header from any users granted the link:access-control.html#capability_runAs[Run As]
 capability. The header and capability permit the authenticated user
 to impersonate another account.
 +
-If false the feature is disabled and cannot be re-enabled without
+If `false` the feature is disabled and cannot be re-enabled without
 editing gerrit.config and restarting the server.
 +
-Default is true.
+Default is `true`.
 
 [[auth.allowRegisterNewEmail]]auth.allowRegisterNewEmail::
 +
@@ -711,13 +715,13 @@
 link:#auth.httpemailheader[auth.httpemailheader] must *not* be set to
 enable registration of new email addresses.
 +
-By default, true.
+By default, `true`.
 
 [[auth.autoUpdateAccountActiveStatus]]auth.autoUpdateAccountActiveStatus::
 +
 Whether to allow automatic synchronization of an account's inactive flag upon login.
 +
-If set to true, upon login, if the authentication back-end reports the account as active,
+If set to `true`, upon login, if the authentication back-end reports the account as active,
 the account's inactive flag in NoteDb will be updated to be active.
 +
 If the authentication back-end reports the account as inactive, the account's flag will be
@@ -725,12 +729,12 @@
 should ensure that their authentication back-end is supported. Currently, only
 strict 'LDAP' authentication is supported.
 +
-In addition, if this parameter is not set, or false, the corresponding scheduled
+In addition, if this parameter is not set, or `false`, the corresponding scheduled
 task to deactivate inactive Gerrit accounts will also be disabled. If this
-parameter is set to true, users should also consider configuring the
+parameter is set to `true`, users should also consider configuring the
 link:#accountDeactivation[accountDeactivation] section appropriately.
 +
-By default, false.
+By default, `false`.
 
 [[auth.skipFullRefEvaluationIfAllRefsAreVisible]]auth.skipFullRefEvaluationIfAllRefsAreVisible::
 +
@@ -740,7 +744,7 @@
 The full ref filtering would filter out refs for pending edits, private changes
 and auto merge commits.
 +
-By default, true.
+By default, `true`.
 
 [[cache]]
 === Section cache
@@ -777,7 +781,7 @@
 Whether to enable the computation of disk statistics of persistent caches.
 This computation is expensive and requires a long time on larger installations.
 +
-By default, false.
+By default, `false`.
 
 [[cache.h2CacheSize]]cache.h2CacheSize::
 +
@@ -800,13 +804,13 @@
 
 [[cache.h2AutoServer]]cache.h2AutoServer::
 +
-If set to true, enable H2 autoserver mode for the H2-backed persistent cache
+If set to `true`, enable H2 autoserver mode for the H2-backed persistent cache
 databases.
 +
 See link:http://www.h2database.com/html/features.html#auto_mixed_mode[here,role=external,window=_blank]
 for detail.
 +
-Default is false.
+Default is `false`.
 
 [[cache.openFiles]]cache.openFiles::
 +
@@ -1310,7 +1314,7 @@
 this setting will fallback to `cache.diff.intraline` if not set in the
 configuration.
 +
-Default is true, enabled.
+Default is `true`, enabled.
 
 [[cache.projects.loadOnStartup]]cache.projects.loadOnStartup::
 +
@@ -1320,12 +1324,12 @@
 size set under <<cache.name.memoryLimit,cache.projects.memoryLimit>>
 is not smaller than the number of repos.
 +
-Default is false, disabled.
+Default is `false`, disabled.
 
 [[cache.projects.loadThreads]]cache.projects.loadThreads::
 +
 Only relevant if <<cache.projects.loadOnStartup,cache.projects.loadOnStartup>>
-is true.
+is `true`.
 +
 The number of threads to allocate for loading the cache at startup. These
 threads will die out after the cache is loaded.
@@ -1348,6 +1352,33 @@
 
 Default is 00:00 if the project_list cache warmer is enabled.
 
+[[cachePruning]]
+=== Section cachePruning
+
+[[cachePruning.pruneOnStartup]]cachePruning.pruneOnStartup::
++
+Whether to asynchronously prune all cache when starting Gerrit.
++
+Defaults to `true`.
+
+[[cachePruning.startTime]]cachePruning.startTime::
++
+The link:#schedule-configuration-startTime[start time] for running
+cache pruning.
++
+Defaults to `01:00`.
+
+[[cachePruning.interval]]cachePruning.interval::
++
+The link:#schedule-configuration-interval[interval] for running
+cache pruning.
++
+Defaults to `1d`.
+
+link:#schedule-configuration-examples[Schedule examples] can be found
+in the link:#schedule-configuration[Schedule Configuration] section.
+
+
 [[capability]]
 === Section capability
 
@@ -1380,7 +1411,7 @@
 `administrateServer` capability assigned. This is useful to bootstrap
 the link:config-accounts.html[account data].
 +
-Default is true.
+Default is `true`.
 
 
 [[change]]
@@ -1388,9 +1419,9 @@
 
 [[change.allowBlame]]change.allowBlame::
 +
-Allow blame on side by side diff. If set to false, blame cannot be used.
+Allow blame on side by side diff. If set to `false`, blame cannot be used.
 +
-Default is true.
+Default is `true`.
 
 [[change.cacheAutomerge]]change.cacheAutomerge::
 +
@@ -1400,13 +1431,13 @@
 diff caches (`"git_modified_files"`, `modified_files`, `"git_file_diff"`,
 `"file_diff"`).
 +
-If true, automerge results are stored in the repository under
+If `true`, automerge results are stored in the repository under
 `refs/cache-automerge/*`; the results of diffing the change against its
-automerge base are stored in the diff caches. If false, no extra data is
+automerge base are stored in the diff caches. If `false`, no extra data is
 stored in the repository, only the diff caches. This can result in slight
 performance improvements by reducing the number of refs in the repo.
 +
-Default is true.
+Default is `true`.
 
 [[change.commentSizeLimit]]change.commentSizeLimit::
 +
@@ -1434,9 +1465,9 @@
 
 [[change.disablePrivateChanges]]change.disablePrivateChanges::
 +
-If set to true, users are not allowed to create private changes.
+If set to `true`, users are not allowed to create private changes.
 +
-The default is false.
+The default is `false`.
 
 [[change.maxComments]]change.maxComments::
 +
@@ -1527,12 +1558,12 @@
 This setting determines when Gerrit renders conflict changes section on change
 screen and also supports `conflicts` predicate. This computation is expensive,
 computing ConflictsPredicate has a runtime complexity of O(nˆ2) with n number
-of open changes on a branch. When set to false GUI will silently ignore the
+of open changes on a branch. When set to `false` GUI will silently ignore the
 error message and leave the conflict changes section on change screen empty.
 See also implications on rendering of conflict changes section in configuration
 section:link:#change.mergeabilityComputationBehavior[change.mergeabilityComputationBehavior].
 
-Default is true.
+Default is `true`.
 
 [[change.maxSubmittableAtOnce]]change.maxSubmittableAtOnce::
 +
@@ -1555,29 +1586,43 @@
 branch (see details in
 link:https://issues.gerritcodereview.com/issues/40009784[issue 40009784]).
 +
-By default true.
+By default `true`.
 
 [[change.enableRobotComments]]change.enableRobotComments::
 +
 Are robot comments enabled in the Gerrit UI? This setting allows phasing out
 robot comments.
 +
-By default true.
+By default `true`.
 
 [[change.propagateSubmitRequirementErrors]]change.propagateSubmitRequirementErrors::
 +
-If a SubmitRequirement evaluation for a given change results in an
-ERROR status, abort the REST response with an HTTP 500 error.
+If set, requests that access the submit requirements of a change fail with an
+HTTP 500 error if the change has a submit requirement with a non-parseable
+expression that would otherwise result in an
+link:config-submit-requirements#status-error[ERROR] status for the submit
+requirement.
 +
-The ERROR status can occur if a SubmitRequirement uses a
-plugin-provided predicate (and the plugin is not available), due to
-bugs, or due to bypassing the validation that normally happens when
-updating `refs/meta/config`.
+Submit requirement expressions can become non-parseable due to bypassing the
+validation that normally happens when updating the project configuration in
+the `refs/meta/config` branch, or due to bugs in Gerrit.
 +
-Enabling this flag  makes gerrit unusuable under such conditions, so
-it is generally not recommended. However, this makes the
-application-specific ERROR status into a generic HTTP error, and can
-thus be acted on by automated deployment and monitoring infrastructure.
+A special case are expressions that use plugin-provided predicates. If any
+plugin that provides a predicate fails to load (e.g. due to an error in the
+plugin) the predicate can no longer be resolved and expressions that are using
+it can no longer be parsed. This is an error that requires the attention of the
+team that operates Gerrit, but in order to get notified when this happens the
+operation team would need to setup custom monitoring that observes whether
+link:config-submit-requirements#status-error[ERROR] statuses are returned for
+submit requirements. Instead this config option can be used to make
+non-parseable submit requirement expressions cause HTTP 500 errors which
+triggers the automatic alerting for errors that Gerrit operation teams usually
+have in place. This allows the operation team to react quickly when this
+happens.
++
+The drawback of enabling this option is that it causes requests to fail rather
+than handling parsing errors gracefully, which can make Gerrit for impacted
+users unusable.
 
 [[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
 +
@@ -1590,18 +1635,18 @@
 
 [[change.sendNewPatchsetEmails]]change.sendNewPatchsetEmails::
 +
-When false, emails will not be sent to owners, reviewers, and cc for
+When `false`, emails will not be sent to owners, reviewers, and cc for
 creating a new patchset unless they are project watchers or have starred
 the change.
 +
-Default is true.
+Default is `true`.
 
 [[change.showAssigneeInChangesTable]]change.showAssigneeInChangesTable::
 +
-Show assignee field in changes table. If set to false, assignees will
+Show assignee field in changes table. If set to `false`, assignees will
 not be visible in changes table.
 +
-Default is false.
+Default is `false`.
 
 [[change.strictLabels]]change.strictLabels::
 +
@@ -1609,7 +1654,7 @@
 configuration option is provided for backwards compatibility and may
 be removed in future gerrit versions.
 +
-Default is false.
+Default is `false`.
 
 [[change.submitLabel]]change.submitLabel::
 +
@@ -1654,7 +1699,7 @@
 
 [[change.submitTopicTooltip]]change.submitTopicTooltip::
 +
-If `change.submitWholeTopic` is configured to true and a change has a
+If `change.submitWholeTopic` is configured to `true` and a change has a
 topic, this configuration determines the tooltip for the submit button
 instead of `change.submitTooltip`. The variable `${topicSize}` is available
 for the number of changes in the same topic to be submitted. The number of
@@ -1669,7 +1714,7 @@
 Determines if the submit button submits the whole topic instead of
 just the current change.
 +
-Default is false.
+Default is `false`.
 
 [[change.updateDelay]]change.updateDelay::
 +
@@ -1698,7 +1743,7 @@
 This setting takes effect when generating the automerge, which happens on upload.
 Changing the setting leaves existing changes unaffected.
 +
-Default is false.
+Default is `false`.
 
 [[change.maxFileSizeDiff]]change.maxFileSizeDiff::
 +
@@ -1707,20 +1752,20 @@
 +
 If not set or set to zero, no limits are applied on file sizes.
 
-[[change.skipCurrentRulesEvaluationOnClosedChanges]]
+[[change.skipCurrentRulesEvaluationOnClosedChanges]]change.skipCurrentRulesEvaluationOnClosedChanges::
 +
-If false, Gerrit will always take latest project configuration to
+If `false`, Gerrit will always take latest project configuration to
 compute submit labels. This means that, closed changes (either merged
 or abandoned) will be evaluated against the latest configuration which
 may produce different results. Especially for merged changes, they may
 look like they didn't meet the submit requirements.
 +
-When true, evaluation will be skipped and Gerrit will show the
+When `true`, evaluation will be skipped and Gerrit will show the
 exact status of submit labels when change was submitted. Post-review
 votes will only be allowed on labels that were configured when change
 was closed.
 +
-Default it false.
+Default is `false`.
 
 [[changeCleanup]]
 === Section changeCleanup
@@ -1851,6 +1896,10 @@
 will hyperlink terms such as 'bug 42' to an external bug tracker,
 supplying the argument record number '42' for display.
 
+Before matching is done the relevant contents are html-escaped. If 'match' needs
+to contain `&`, `<`, `>`, `"` or  `'`, replace them with `&amp;`, `&gt;`,
+`&lt;`, `&quot;` and `&apos;` respectively.
+
 commentlinks supports link:#reloadConfig[configuration reloads]. Though a
 link:cmd-flush-caches.html[flush-caches] of "projects" is needed for the
 commentlinks to be immediately available in the UI.
@@ -1927,7 +1976,7 @@
 section in a parent or the site-wide config that is disabled by
 specifying `enabled = true`.
 +
-By default, true.
+By default, `true`.
 +
 Note that the names and contents of disabled sections are visible even
 to anonymous users via the
@@ -1986,7 +2035,7 @@
 
 [[container.replica]]container.replica::
 +
-Used on Gerrit replica installations. If set to true the Gerrit JVM is
+Used on Gerrit replica installations. If set to `true` the Gerrit JVM is
 called with the '--replica' switch, enabling replica mode. If no value is
 set (or any other value), Gerrit defaults to primary mode enabling write
 operations.
@@ -2119,18 +2168,18 @@
 
 [[core.packedGitMmap]]core.packedGitMmap::
 +
-When true, JGit will use `mmap()` rather than `malloc()+read()`
+When `true`, JGit will use `mmap()` rather than `malloc()+read()`
 to load data from pack files.  The use of mmap can be problematic
 on some JVMs as the garbage collector must deduce that a memory
 mapped segment is no longer in use before a call to `munmap()`
 can be made by the JVM native code.
 +
 In server applications (such as Gerrit) that need to access many
-pack files, setting this to true risks artificially running out
+pack files, setting this to `true` risks artificially running out
 of virtual address space, as the garbage collector cannot reclaim
 unused mapped spaces fast enough.
 +
-Default on JGit is false. Although potentially slower, it yields
+Default on JGit is `false`. Although potentially slower, it yields
 much more predictable behavior.
 
 [[core.asyncLoggingBufferSize]]core.asyncLoggingBufferSize::
@@ -2152,7 +2201,7 @@
 blog,role=external,window=_blank], the recursive merge produces better results if the two commits
 that are merged have more than one common predecessor.
 +
-Default is true.
+Default is `true`.
 
 [[core.repositoryCacheCleanupDelay]]core.repositoryCacheCleanupDelay::
 +
@@ -2189,7 +2238,7 @@
 (lazily) if needed. This helps reduce the overhead of checking if
 the packed-refs file is outdated.
 +
-Default is true.
+Default is `true`.
 
 [[dashboard]]
 === Section dashboard
@@ -2354,7 +2403,7 @@
 collections are more expensive but may lead to significantly smaller
 repositories.
 +
-Valid values are "true" and "false," default is "false".
+Valid values are "`true`" and "`false`," default is "`false`".
 
 [[gc.startTime]]gc.startTime::
 +
@@ -2450,7 +2499,7 @@
 for editing GPG keys. If disabled, GPG keys can only be added by
 administrators with direct git access to All-Users.
 +
-Defaults to true.
+Defaults to `true`.
 
 [[gerrit.installCommitMsgHookCommand]]gerrit.installCommitMsgHookCommand::
 +
@@ -2539,14 +2588,14 @@
 Enable rendering of project list from the secondary index instead
 of purely relying on the in-memory cache.
 +
-By default false.
+By default `false`.
 +
 [NOTE]
-The in-memory cache (set to false) rendering provides an **unlimited list** as a result
+The in-memory cache (set to `false`) rendering provides an **unlimited list** as a result
 of the list project API, causing the full list of projects to be
 returned as a result of the link:rest-api-projects.html[/projects/] REST API
 or the link:cmd-ls-projects.html[gerrit ls-projects] SSH command.
-When the rendering from the secondary index (set to true),
+When the rendering from the secondary index (set to `true`),
 the **list is limited** by the global capability
 link:access-control.html#capability_queryLimit[queryLimit]
 which is defaulted to 500 entries.
@@ -2578,7 +2627,7 @@
 
 Record actual peer IP address in ref log entry for identified user.
 
-Defaults to false.
+Defaults to `false`.
 
 [[gerrit.secureStoreClass]]gerrit.secureStoreClass::
 +
@@ -2593,9 +2642,9 @@
 [[gerrit.canLoadInIFrame]]gerrit.canLoadInIFrame::
 +
 For security reasons Gerrit will always jump out of iframe.
-Setting this option to true will prevent this behavior.
+Setting this option to `true` will prevent this behavior.
 +
-By default false.
+By default `false`.
 
 [[gerrit.xframeOption]]gerrit.xframeOption::
 +
@@ -2609,7 +2658,7 @@
 1. ALLOW - The page can be displayed in a frame.
 2. SAMEORIGIN - The page can only be displayed in a frame on the same origin as the page itself.
 +
-If link:#gerrit.canLoadInIFrame is set to false this option is ignored and the
+If link:#gerrit.canLoadInIFrame is set to `false` this option is ignored and the
 `X-Frame-Options` header is always set to `DENY`.
 Setting this option to `ALLOW` will cause the `X-Frame-Options` header to be omitted
 the the page can be displayed in a frame.
@@ -2651,17 +2700,17 @@
 Allow Gerrit to start even if the underlying schema version has been bumped to
 the next Gerrit version.
 +
-Set to true if Gerrit is installed in
+Set to `true` if Gerrit is installed in
 [high-availability configuration](https://gerrit.googlesource.com/plugins/high-availability/+/refs/heads/master/README.md)
 during the rolling upgrade to the next version.
 +
-By default false.
+By default `false`.
 +
 The rolling upgrade process, at high level, assumes that Gerrit is installed
 on two or more nodes sharing the repositories over NFS. The upgrade is composed
 of the following steps:
 +
-1. Set gerrit.experimentalRollingUpgrade to true on all Gerrit masters
+1. Set gerrit.experimentalRollingUpgrade to `true` on all Gerrit masters
 2. Set the first master unhealthy
 3. Shutdown the first master and [upgrade](install.html#init) to the next version
 4. Startup the first master, wait for the online reindex to complete (where applicable)
@@ -2679,7 +2728,7 @@
 ServerId of the repositories imported from other Gerrit servers. Changes coming
 associated with the imported serverIds are indexed and displayed in the UI
 but they are not searchable by `changeNumber` therefore the
-`index.cacheQueryResultsByChangeNum` must also be set to false.
+`index.cacheQueryResultsByChangeNum` must also be set to `false`.
 Imported changes are still discoverable in any other ways, for example:
 
   project:someproject branch:main changeId:I78a7add1fe2597cad788c833d8f771f09b54cf33
@@ -2851,7 +2900,7 @@
 [[groups.auditLog.ignoreRecordsFromUnidentifiedUsers]]groups.auditLog.ignoreRecordsFromUnidentifiedUsers::
 +
 Controls whether AuditLogReader should ignore commits created by unidentified users.
-If true, then AuditLogReader ignores commits in the refs/groups/* made by unidentified users (i.e.
+If `true`, then AuditLogReader ignores commits in the refs/groups/* made by unidentified users (i.e.
 when the author of a commit can't be parsed as account id).
 +
 The current version of Gerrit writes identified users as authors for new refs/groups/* commits.
@@ -2859,9 +2908,9 @@
 <server@googlesource.com>") for such commits. Such string can't be converted to account id but
 usually the commit shouldn't be ignored.
 +
-By default, false.
+By default, `false`.
 +
-Setting it to true may lead to some unexpected results in audit log and must be set carefully.
+Setting it to `true` may lead to some unexpected results in audit log and must be set carefully.
 
 [[groups.includeExternalUsersInRegisteredUsersGroup]]groups.includeExternalUsersInRegisteredUsersGroup::
 +
@@ -2873,14 +2922,14 @@
 or a custom authentication backend). By default, Gerrit core always requires
 users to register and doesn't use external users.
 +
-By default, true.
+By default, `true`.
 
 [[groups.newGroupsVisibleToAll]]groups.newGroupsVisibleToAll::
 +
 Controls whether newly created groups should be by default visible to
 all registered users.
 +
-By default, false.
+By default, `false`.
 
 [[groups.uuid.name]]groups.<uuid>.name::
 +
@@ -2995,7 +3044,7 @@
 
 [[http.addUserAsRequestAttribute]]http.addUserAsRequestAttribute::
 +
-If true, 'User' attribute will be added to the request attributes so it
+If `true`, 'User' attribute will be added to the request attributes so it
 can be accessed outside the request scope (will be set to username or id
 if username not configured).
 +
@@ -3010,15 +3059,15 @@
 Pattern to print user in Tomcat AccessLog.
 
 +
-Default value is true.
+Default value is `true`.
 
 [[http.addUserAsResponseHeader]]http.addUserAsResponseHeader::
 +
-If true, the header 'User' will be added to the list of response headers so it
+If `true`, the header 'User' will be added to the list of response headers so it
 can be accessed from a reverse proxy for logging purposes.
 
 +
-Default value is false.
+Default value is `false`.
 
 [[httpd]]
 === Section httpd
@@ -3147,12 +3196,12 @@
 
 [[httpd.reuseAddress]]httpd.reuseAddress::
 +
-If true, permits the daemon to bind to the port even if the port
-is already in use.  If false, the daemon ensures the port is not
+If `true`, permits the daemon to bind to the port even if the port
+is already in use.  If `false`, the daemon ensures the port is not
 in use before starting.  Busy sites may need to set this to true
 to permit fast restarts.
 +
-By default, true.
+By default, `true`.
 
 [[httpd.gracefulStopTimeout]]httpd.gracefulStopTimeout::
 +
@@ -3171,11 +3220,11 @@
 
 [[httpd.inheritChannel]]httpd.inheritChannel::
 +
-If true, permits the daemon to inherit its server socket channel
-from fd0/1(stdin/stdout). When set to true, the server can be socket
+If `true`, permits the daemon to inherit its server socket channel
+from fd0/1(stdin/stdout). When set to `true`, the server can be socket
 activated via systemd or xinetd.
 +
-By default, false.
+By default, `false`.
 
 [[httpd.requestHeaderSize]]httpd.requestHeaderSize::
 +
@@ -3246,8 +3295,8 @@
 `log4j.appender` with the name `httpd_log` can be configured to overwrite
 programmatic configuration.
 +
-By default, true if httpd.listenUrl uses http:// or https://,
-and false if httpd.listenUrl uses proxy-http:// or proxy-https://.
+By default, `true` if httpd.listenUrl uses http:// or https://,
+and `false` if httpd.listenUrl uses proxy-http:// or proxy-https://.
 
 [[httpd.acceptorThreads]]httpd.acceptorThreads::
 +
@@ -3399,7 +3448,7 @@
 +
 Enable (or disable) registration of Jetty MBeans for Java JMX.
 +
-By default, false.
+By default, `false`.
 
 [[index]]
 === Section index
@@ -3443,7 +3492,7 @@
 It needs to be turned off when having Changes imported from other servers
 because of the potential conflicts of change numbers.
 +
-Defaults to true.
+Defaults to `true`.
 
 [[index.onlineUpgrade]]index.onlineUpgrade::
 +
@@ -3452,10 +3501,10 @@
 Gerrit version upgrades (avoiding the need for an offline reindex step
 using Reindex), but can add additional server load during the upgrade.
 +
-If set to false, there is no way to upgrade the index schema to take
+If set to `false`, there is no way to upgrade the index schema to take
 advantage of new search features without restarting the server.
 +
-Defaults to true.
+Defaults to `true`.
 
 [[index.excludeProjectFromChangeReindex]]index.excludeProjectFromChangeReindex::
 +
@@ -3466,6 +3515,21 @@
 Excluded projects can later be reindexed by for example using the
 link:cmd-index-changes-in-project.html[index changes in project command].
 
+[[index.reuseExistingDocuments]]index.reuseExistingDocuments::
++
+Whether to reuse index documents that already exist during reindexing.
++
+Currently, only supported by the changes index.
++
+This feature is useful, if the Gerrit server has to be restarted
+during an ongoing index online upgrade, since this would cause
+a complete reindexing otherwise that might take an extensive time.
++
+Each existing document in the index will be checked for staleness
+and reindexed if found to be stale.
++
+Defaults to false.
+
 [[index.paginationType]]index.paginationType::
 +
 The pagination type to use when index queries are repeated to
@@ -3583,11 +3647,11 @@
 [[index.autoReindexIfStale]]index.autoReindexIfStale::
 +
 Whether to automatically check if a document became stale in the index
-immediately after indexing it. If false, there is a race condition during two
+immediately after indexing it. If `false`, there is a race condition during two
 simultaneous writes that may cause one of the writes to not be reflected in the
 index. The check to avoid this does consume some resources.
 +
-Defaults to false.
+Defaults to `false`.
 
 [[index.indexChangesAsync]]index.indexChangesAsync::
 +
@@ -3597,7 +3661,7 @@
 to the write request latency) and disadvantage that the indexing result might not be
 immediately available after the write request.
 +
-Defaults to false.
+Defaults to `false`.
 
 [[index.scheduledIndexer]]
 ==== Subsection index.scheduledIndexer
@@ -3739,7 +3803,7 @@
 link:https://lucene.apache.org/core/5_5_0/core/org/apache/lucene/index/ConcurrentMergeScheduler.html#enableAutoIOThrottle()[
 Lucene documentation,role=external,window=_blank] for further details.
 +
-Defaults to true (throttling enabled).
+Defaults to `true` (throttling enabled).
 
 During offline reindexing, setting ramBufferSize greater than the size
 of index (size of specific index folder under <site_dir>/index) and
@@ -3801,7 +3865,7 @@
 specific content, e.g.: `recheck`. Jenkins Gerrit Trigger plugin and Zuul CI
 depend on this feature to trigger change verification.
 +
-By default, true.
+By default, `true`.
 
 [[event.stream-events.enableRefUpdatedEvents]]event.stream-events.enableRefUpdatedEvents::
 +
@@ -3811,7 +3875,7 @@
 Please consider switching to `batch-ref-updated` event which provides better control on grouping and
 preserving order of the ref updates.
 +
-By default, true.
+By default, `true`.
 
 [[event.stream-events.enableBatchRefUpdatedEvents]]event.stream-events.enableBatchRefUpdatedEvents::
 +
@@ -3819,9 +3883,9 @@
 refs updated during a single batch ref update operation.
 Single ref updates are also streamed as a `batch-ref-updated` events with a single ref specified.
 This allows event listeners to react on all ref updated events and disable individual `ref-updated`
-events by setting <<event.stream-events.enableRefUpdatedEvents, event.stream-events.enableRefUpdatedEvents>> to false.
+events by setting <<event.stream-events.enableRefUpdatedEvents, event.stream-events.enableRefUpdatedEvents>> to `false`.
 +
-By default, false.
+By default, `false`.
 
 [[event.stream-events.enableDraftCommentEvents]]event.stream-events.enableDraftCommentEvents::
 +
@@ -3832,7 +3896,7 @@
 The extra amount of events depends on the usage pattern of the installation. It is worth evaluating
 the amount of extra events produced before enabling this flag by counting the calls to the draft APIs.
 +
-By default, false.
+By default, `false`.
 
 [[experiments]]
 === Section experiments
@@ -3905,7 +3969,7 @@
 temporarily inaccessible by users even with LDAP membership and grants
 referenced in the ACLs.
 +
-By default, true.
+By default, `true`.
 
 [[ldap.server]]ldap.server::
 +
@@ -3924,31 +3988,31 @@
 
 [[ldap.startTls]]ldap.startTls::
 +
-If true, Gerrit will perform StartTLS extended operation.
+If `true`, Gerrit will perform StartTLS extended operation.
 +
-By default, false, StartTLS will not be enabled.
+By default, `false`, StartTLS will not be enabled.
 
 [[ldap.supportAnonymous]]ldap.supportAnonymous::
 +
-If false, Gerrit will provide credentials only at connection open, this is
+If `false`, Gerrit will provide credentials only at connection open, this is
 required for some `LDAP` implementations that do not allow anonymous bind
 for StartTLS or for reauthentication.
 +
-By default, true.
+By default, `true`.
 
 [[ldap.sslVerify]]ldap.sslVerify::
 +
-If false and ldap.server is an `ldaps://` style URL or `ldap.startTls`
-is true, Gerrit will not verify the server certificate when it connects
+If `false` and ldap.server is an `ldaps://` style URL or `ldap.startTls`
+is `true`, Gerrit will not verify the server certificate when it connects
 to perform a query.
 +
-By default, true, requiring the certificate to be verified.
+By default, `true`, requiring the certificate to be verified.
 
 [[ldap.groupsVisibleToAll]]ldap.groupsVisibleToAll::
 +
-If true, LDAP groups are visible to all registered users.
+If `true`, LDAP groups are visible to all registered users.
 +
-By default, false, LDAP groups are visible only to administrators and
+By default, `false`, LDAP groups are visible only to administrators and
 group members.
 
 [[ldap.username]]ldap.username::
@@ -4185,7 +4249,7 @@
 +
 Converts the local username, that is used to login into the Gerrit
 Web UI, to lower case before doing the LDAP authentication. By setting
-this parameter to true, a case insensitive login to the Gerrit Web UI
+this parameter to `true`, a case insensitive login to the Gerrit Web UI
 can be achieved.
 +
 If set, it must be ensured that the local usernames for all existing
@@ -4198,7 +4262,7 @@
 case can't be undone. For newly created accounts the local username
 will be directly stored in lower case.
 +
-By default, unset/false.
+By default, unset/`false`.
 
 [[ldap.authentication]]ldap.authentication::
 +
@@ -4217,7 +4281,7 @@
             required
             useTicketCache=true
             doNotPrompt=true
-            renewTGT=true;
+            renewTGT=`true`;
 };
 ----
 
@@ -4236,7 +4300,7 @@
 +
 _(Optional)_ Enable the LDAP connection pooling or not.
 +
-If it is true, the LDAP service provider maintains a pool of (possibly)
+If it is `true`, the LDAP service provider maintains a pool of (possibly)
 previously used connections and assigns them to a Context instance as
 needed. When a Context instance is done with a connection (closed or
 garbage collected), the connection is returned to the pool for future use.
@@ -4245,7 +4309,7 @@
 LDAP connection management (Pool),role=external,window=_blank] and link:http://docs.oracle.com/javase/tutorial/jndi/ldap/config.html[
 LDAP connection management (Configuration),role=external,window=_blank]
 +
-By default, false.
+By default, `false`.
 
 [[ldap.connectTimeout]]ldap.connectTimeout::
 +
@@ -4291,7 +4355,7 @@
 
 [[log.jsonLogging]]log.jsonLogging::
 +
-If set to true, enables error, ssh and http logging in JSON format (file names:
+If set to `true`, enables error, ssh and http logging in JSON format (file names:
 `logs/error_log.json`, `logs/sshd_log.json` and `logs/httpd_log.json`).
 +
 The option only applies to Gerrit built-in loggers. It is ignored when a log4j
@@ -4299,11 +4363,11 @@
 link:#container.javaOptions[container.javaOptions], for example
 `-Dlog4j.configuration=file://etc/log4j.properties`.
 +
-Defaults to false.
+Defaults to `false`.
 
 [[log.textLogging]]log.textLogging::
 +
-If set to true, enables error logging in regular plain text format. Can only be disabled
+If set to `true`, enables error logging in regular plain text format. Can only be disabled
 if `jsonLogging` is enabled.
 +
 The option only applies to Gerrit built-in loggers. It is ignored when a log4j
@@ -4311,20 +4375,47 @@
 link:#container.javaOptions[container.javaOptions], for example
 `-Dlog4j.configuration=file://etc/log4j.properties`.
 +
-Defaults to true.
+Defaults to `true`.
 
 [[log.compress]]log.compress::
 +
-If set to true, log files are compressed at server startup and then daily at 11pm
+If set to `true`, log files are compressed at server startup and then daily at 11pm
 (in the server's local time zone).
 +
-Defaults to true.
+Defaults to `true`.
 
 [[log.rotate]]log.rotate::
 +
-If set to true, log files are rotated daily at midnight (GMT).
+If set to `true`, log files are rotated daily at midnight (GMT).
 +
-Defaults to true.
+Defaults to `true`.
+
+[[log.daysToKeep]]log.timeToKeep::
++
+Time that logs should be kept until they are being deleted. Values should use common
+suffixes to express their setting:
++
+* d, day, days
+* w, week, weeks (`1 week` is treated as `7 days`)
+* mon, month, months (`1 month` is treated as `30 days`)
+* y, year, years (`1 year` is treated as `365 days`)
++
+The minimum granularity is days. Using a smaller time unit will result in deletion of
+all old logs, as if `0d` would have been configured.
++
+Actively used logs will never be deleted. Thus, this feature only works in combination
+with enabled link:#log.rotate[log.rotate]. Log deletion happens at server startup and
+then daily at 11pm (in the server's local time zone).
++
+Depending on the filesystem the following file times will be used, in order of priority:
++
+* Time of file creation
+* Time when the file was last modified
+* Date added to the filename as part of log file rotation. Time will be set to `00:00:00Z`.
++
+If none of the above is available, the log file won't be deleted.
++
+Defaults to `-1`, i.e. being disabled.
 
 [[metrics]]
 === Section metrics
@@ -4371,13 +4462,13 @@
 
 [[mimetype.name.safe]]mimetype.<name>.safe::
 +
-If set to true, files with the MIME type `<name>` will be sent as
+If set to `true`, files with the MIME type `<name>` will be sent as
 direct downloads to the user's browser, rather than being wrapped up
 inside of zipped archives.  The type name may be a complete type
 name, e.g. `image/gif`, a generic media type, e.g. `+image/*+`,
 or the wildcard `+*/*+` to match all types.
 +
-By default, false for all MIME types.
+By default, `false` for all MIME types.
 
 Common examples:
 ----
@@ -4442,16 +4533,16 @@
 
 [[oauth.allowEditFullName]]oauth.allowEditFullName::
 +
-If true, the full name can be edited in the contact information.
+If `true`, the full name can be edited in the contact information.
 +
-Default is false.
+Default is `false`.
 
 [[oauth.allowRegisterNewEmail]]oauth.allowRegisterNewEmail::
 +
-If true, additional email addresses can be registered in the contact
+If `true`, additional email addresses can be registered in the contact
 information.
 +
-Default is false.
+Default is `false`.
 
 [[operator-alias]]
 === Section operator alias
@@ -4497,7 +4588,7 @@
 
 [[pack.deltacompression]]pack.deltacompression::
 +
-If true, delta compression between objects is enabled.  This may
+If `true`, delta compression between objects is enabled.  This may
 result in a smaller overall transfer for the client, but requires
 more server memory and CPU time.
 +
@@ -4530,8 +4621,8 @@
 [[plugins.allowRemoteAdmin]]plugins.allowRemoteAdmin::
 +
 Enable remote installation, enable and disable of plugins over HTTP
-and SSH.  If set to true Administrators can install new plugins
-remotely, or disable existing plugins.  Defaults to false.
+and SSH.  If set to `true` Administrators can install new plugins
+remotely, or disable existing plugins.  Defaults to `false`.
 
 [[plugins.mandatory]]plugins.mandatory::
 +
@@ -4597,34 +4688,34 @@
 
 [[receive.checkMagicRefs]]receive.checkMagicRefs::
 +
-If true, Gerrit will verify the destination repository has
+If `true`, Gerrit will verify the destination repository has
 no references under the magic 'refs/for' branch namespace. Names under
 these locations confuse clients when trying to upload code reviews so
 Gerrit requires them to be empty.
 +
-If false Gerrit skips the sanity check and assumes administrators
+If `false` Gerrit skips the sanity check and assumes administrators
 have ensured the repository does not contain any magic references.
-Setting to false to skip the check can decrease latency during push.
+Setting to `false` to skip the check can decrease latency during push.
 +
-Default is true.
+Default is `true`.
 
 [[receive.allowProjectOwnersToChangeParent]]receive.allowProjectOwnersToChangeParent::
 +
-If true, Gerrit will allow project owners to change the parent of a project.
+If `true`, Gerrit will allow project owners to change the parent of a project.
 +
 By default only Gerrit administrators are allowed to change the parent
 of a project. By allowing project owners to change parents, it may
 allow the owner to circumvent certain enforced rules (like important
 BLOCK rules).
 +
-Default is false.
+Default is `false`.
 +
 This value supports configuration reloads:
 link:cmd-reload-config.html[reload-config]
 
 [[receive.checkReferencedObjectsAreReachable]]receive.checkReferencedObjectsAreReachable::
 +
-If set to true, Gerrit will validate that all referenced objects that
+If set to `true`, Gerrit will validate that all referenced objects that
 are not included in the received pack are reachable by the user.
 +
 Carrying out this check on gits with many refs and commits can be a
@@ -4634,11 +4725,11 @@
 Only disable this check if you trust the clients not to forge SHA-1
 references to access commits intended to be hidden from the user.
 +
-Default is true.
+Default is `true`.
 
 [[receive.enableInMemoryRefCache]]receive.enableInMemoryRefCache::
 +
-If true, Gerrit will cache all refs advertised during push in memory and
+If `true`, Gerrit will cache all refs advertised during push in memory and
 base later receive operations on that cache.
 +
 Turning this cache off is considered experimental.
@@ -4647,17 +4738,17 @@
 offer an inverse lookup of object ID to ref name. When RefTable is used,
 this cache can be turned off (experimental) to get speed improvements.
 +
-Default is true.
+Default is `true`.
 
 [[receive.enableSignedPush]]receive.enableSignedPush::
 +
-If true, server-side signed push validation is enabled.
+If `true`, server-side signed push validation is enabled.
 +
 When a client pushes with `git push --signed`, this ensures that the
 push certificate is valid and signed with a valid public key stored in
 the `refs/meta/gpg-keys` branch of `All-Users`.
 +
-Defaults to false.
+Defaults to `false`.
 
 [[receive.maxBatchChanges]]receive.maxBatchChanges::
 +
@@ -4708,7 +4799,7 @@
 value is inherited from the parent project. When `true`, the value is
 inherited, otherwise it is not inherited.
 +
-Default is false, the value is not inherited.
+Default is `false`, the value is not inherited.
 
 [[receive.maxTrustDepth]]receive.maxTrustDepth::
 +
@@ -4907,18 +4998,18 @@
 Whether Gerrit should automatically retry operations on failure with tracing
 enabled. The automatically generated traces can help with debugging.
 +
-By default this is set to false.
+By default this is set to `false`.
 
 [[rules]]
 === Section rules
 
 [[rules.enable]]rules.enable::
 +
-If true, Gerrit will load and execute 'rules.pl' files in each
-project's refs/meta/config branch, if present. When set to false,
+If `true`, Gerrit will load and execute 'rules.pl' files in each
+project's refs/meta/config branch, if present. When set to `false`,
 only the default internal rules will be used.
 +
-Default is true, to execute project specific rules.
+Default is `true`, to execute project specific rules.
 
 [[rules.reductionLimit]]rules.reductionLimit::
 +
@@ -4951,7 +5042,7 @@
 source files may need a larger rules.compileReductionLimit.  Consider
 using link:pgm-rulec.html[rulec] to precompile larger rule files.
 +
-A size of 0 bytes disables rules, same as rules.enable = false.
+A size of 0 bytes disables rules, same as rules.enable = `false`.
 +
 Common unit suffixes of 'k', 'm', or 'g' are supported.
 +
@@ -5045,7 +5136,7 @@
 email is delivered to the inbox. In this case, Gerrit will process the email
 immediately and will not have a fetch delay.
 +
-Defaults to false.
+Defaults to `false`.
 
 [[receiveemail.filter.mode]]receiveemail.filter.mode::
 +
@@ -5079,18 +5170,18 @@
 
 [[sendemail.enable]]sendemail.enable::
 +
-If false Gerrit will not send email messages, for any reason,
+If `false` Gerrit will not send email messages, for any reason,
 and all other properties of section sendemail are ignored.
 +
-By default, true, allowing notifications to be sent.
+By default, `true`, allowing notifications to be sent.
 
 [[sendemail.html]]sendemail.html::
 +
-If false, Gerrit will only send plain-text emails.
-If true, Gerrit will send multi-part emails with an HTML and
+If `false`, Gerrit will only send plain-text emails.
+If `true`, Gerrit will send multi-part emails with an HTML and
 plain text part.
 +
-By default, true, allowing HTML in the emails Gerrit sends.
+By default, `true`, allowing HTML in the emails Gerrit sends.
 
 [[sendemail.connectTimeout]]sendemail.connectTimeout::
 +
@@ -5182,11 +5273,11 @@
 
 [[sendemail.sslVerify]]sendemail.sslVerify::
 +
-If false and sendemail.smtpEncryption is 'ssl' or 'tls', Gerrit
+If `false` and sendemail.smtpEncryption is 'ssl' or 'tls', Gerrit
 will not verify the server certificate when it connects to send
 an email message.
 +
-By default, true, requiring the certificate to be verified.
+By default, `true`, requiring the certificate to be verified.
 
 [[sendemail.smtpUser]]sendemail.smtpUser::
 +
@@ -5223,12 +5314,12 @@
 
 [[sendemail.includeDiff]]sendemail.includeDiff::
 +
-If true, new change emails and merged change emails from Gerrit
+If `true`, new change emails and merged change emails from Gerrit
 will include the complete unified diff of the change.
 Variable maxmimumDiffSize places an upper limit on how large the
 email can get when this option is enabled.
 +
-By default, false.
+By default, `false`.
 
 [[sendemail.maximumDiffSize]]sendemail.maximumDiffSize::
 +
@@ -5278,12 +5369,12 @@
 
 [[sendemail.addInstanceNameInSubject]]sendemail.addInstanceNameInSubject::
 +
-When set to true, Gerrit will add its short name to the email subject, allowing recipients to quickly identify
+When set to `true`, Gerrit will add its short name to the email subject, allowing recipients to quickly identify
 what Gerrit instance the email came from.
 +
 The short name can be customized via the gerrit.instanceName option.
 +
-Defaults to false.
+Defaults to `false`.
 
 
 [[site]]
@@ -5303,9 +5394,9 @@
 
 [[site.refreshHeaderFooter]]site.refreshHeaderFooter::
 +
-If true the server checks the site header, footer and CSS files for
-updated versions. If false, a server restart is required to change
-any of these resources. Default is true, allowing automatic reloads.
+If `true` the server checks the site header, footer and CSS files for
+updated versions. If `false`, a server restart is required to change
+any of these resources. Default is `true`, allowing automatic reloads.
 
 [[ssh-alias]]
 === Section ssh-alias
@@ -5391,7 +5482,7 @@
 
 [[sshd.tcpKeepAlive]]sshd.tcpKeepAlive::
 +
-If true, enables TCP keepalive messages to the other side, so
+If `true`, enables TCP keepalive messages to the other side, so
 the daemon can terminate connections if the peer disappears.
 +
 Only effective when `sshd.backend` is set to `MINA`.
@@ -5728,7 +5819,7 @@
 If link:access-control.html#service_users[service users] should be skipped when
 suggesting reviewers.
 +
-By default true.
+By default `true`.
 
 [[tracing]]
 === Section tracing
@@ -5748,7 +5839,7 @@
 heap increase wasn't nearly as dramatic and the impact is most likely
 dependent on which plugin is used.
 +
-By default, false.
+By default, `false`.
 
 [[tracing.traceid]]
 ==== Subsection tracing.<trace-id>
@@ -6040,7 +6131,7 @@
 
 Note that the task will only be scheduled if the
 link:#autoUpdateAccountActiveStatus[auth.autoUpdateAccountActiveStatus]
-is set to true.
+is set to `true`.
 
 link:#schedule-configuration-examples[Schedule examples] can be found
 in the link:#schedule-configuration[Schedule Configuration] section.
@@ -6066,7 +6157,7 @@
 +
 This allows to enable the superproject subscription mechanism.
 +
-By default this is true.
+By default this is `true`.
 
 [[submodule.maxCombinedCommitMessageSize]]submodule.maxCombinedCommitMessageSize::
 +
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index ce63295..29d1b85 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -24,7 +24,7 @@
 informed about this by a message in the git output. In addition,
 outdated votes are also listed in the email notification that is sent
 for the new patch set (unless this is disabled by a custom email
-template). Note, that the uploader only get this email notification if
+template). Note, that the uploader only gets this email notification if
 they have configured `Every Comment` for `Email notifications` in their
 user preferences. With any other email preference the email sender, the
 uploader in this case, is not included in the email recipients.
@@ -251,7 +251,7 @@
 change is allowed for submission. Label functions are **deprecated** and updates
 that set `function` to a blocking value {`MaxWithBlock`, `MaxNoBlock`,
 `AnyWithBlock`} will be rejected. Existing label function definitions can only
-be updated to {`NoBlock`, `NoOp`, `PatchSetLock`}. New label defintions should
+be updated to {`NoBlock`, `NoOp`, `PatchSetLock`}. New label definitions should
 also explicitly set the `function` attribute to a non-blocking value since the
 default is `MaxWithBlock`.
 
@@ -269,6 +269,7 @@
 
 Valid values are:
 
+[[MaxWithBlock]]
 * `MaxWithBlock` (default)
 +
 The lowest possible negative value, if present, blocks a submit, while
@@ -276,22 +277,26 @@
 must be at least one positive value, or else submit will never be
 enabled. To permit blocking submits, ensure a negative value is defined.
 
+[[AnyWithBlock]]
 * `AnyWithBlock`
 +
 The label is not mandatory but the lowest possible negative value,
 if present, blocks a submit. To permit blocking submits, ensure that a
 negative value is defined.
 
+[[MaxNoBlock]]
 * `MaxNoBlock`
 +
 The highest possible positive value is required to enable submit, but
 the lowest possible negative value will not block the change.
 
+[[NoBlock]]
 * `NoBlock`/`NoOp`
 +
 The label is purely informational and values are not considered when
 determining whether a change is submittable.
 
+[[PatchSetLock]]
 * `PatchSetLock`
 +
 The `PatchSetLock` function provides a locking mechanism for patch
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 187cd0f..81f9d9f 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -1,95 +1,496 @@
-= Gerrit Code Review - Project Configuration File Format
+= Gerrit Code Review - Project Configuration
 
-This page explains the storage format of Gerrit's project configuration
-and access control models.
+Every project has a configuration that defines access rights and controls
+project-specific behavior.
 
-The web UI access control panel is a front end for human-readable
-configuration files under the +refs/meta/config+ namespace in the
-affected project.  Direct manipulation of these files is mainly
-relevant in an automation scenario of the access controls.
-
+The project configuration is stored inside the git repository inside the
+`refs/meta/config` branch.
 
 [[refs-meta-config]]
-== The +refs/meta/config+ namespace
+== The `refs/meta/config` branch
 
-The namespace contains three different files that play different
-roles in the permission model.  With read permission to that reference,
-it is possible to fetch the +refs/meta/config+ reference to a local
-repository.  A nice side effect is that you can also upload changes
-to project permissions and review them just like with regular code
-changes. The preview changes option is also provided on the UI. Please note
-that you will have to configure push rights for the +refs/meta/config+ name
-space if you'd like to use the possibility to automate permission updates.
+The `refs/meta/config` branch contains configuration files. It is disconnected
+from the normal branches under `refs/heads/` that contain the source code.
 
-== Property inheritance
+The files inside the `refs/meta/config` branch are versioned just like any
+other file in the repository. This means from the git history of the
+`refs/meta/config` branch it can be seen how the configuration changed
+over time and which configuration was active when.
 
-If a property is set to INHERIT, then the value from the parent project is
-used. If the property is not set in any parent project, the default value is
-FALSE.
+[[configuration-files]]
+== Configuration files
+
+The project configuration is stored in the following files (these files are
+stored inside the `refs/meta/config` branch, see
+link:#refs-meta-config[above]):
+
+* link:#file-project_config[project.config]:
+  Contains the link to the parent project, the project description, access
+  rights, label definitions, submit requirements and options to control
+  project-specific behavior.
+* link:#file-groups[groups]:
+  Resolves group names that are mentioned in `project.config` to group UUIDs.
+* [DEPRECATED] link:#file-rules_pl[rules.pl]:
+  Contains Prolog rules that control when a change becomes ready to submit.
+  Note, Prolog rules are deprecated and have been replaced by
+  link:config-submit-requirements.html[submit requirements].
+
+In addition, there can be configuration files from Gerrit plugins, usually they
+are named `<PLUGIN-NAME>.config`.
+
+[[update]]
+== Updating the project configuration
+
+There are several possibilities to update the project configuration:
+
+[[update-through-web-ui]]
+* Through the Gerrit web UI:
++
+[[update-through-general-screen]]
+--
+* On the project's `General` screen:
++
+The `General` screen can be found under:
+`BROWSE` > `Repositories` > `<project-name>` > `General`
++
+In the `Configurations` section this screen allows to edit the project
+description and change repository options.
++
+Note, this screen only supports changing the most important repository options,
+but it doesn't expose all options that exist.
++
+All changes are directly applied (without code review).
+--
++
+[[update-through-access-screen]]
+--
+* On the project's `Access` screen:
++
+The `Access` screen can be found under:
+`BROWSE` > `Repositories` > `<project-name>` > `Access`
++
+This screen allows to edit the project's access rights and the parent project.
++
+Updating access rights via the `Access` screen updates the
+link:#file-groups[groups] file automatically.
++
+Modifications can either be applied directly (`SAVE` button) or saved for
+review (`SAVE FOR REVIEW` button).
++
+Saving access modification for review (`SAVE FOR REVIEW` button) creates an
+open change for the `refs/meta/config` branch where the modifications can be
+reviewed by a project owner. The modifications become effective only after a
+project owner submits the change.
+--
++
+[[update-via-online-editing]]
+--
+** Via online editing:
++
+The project's `Commands` screen, that can be found under `BROWSE` >
+`Repositories` > `<project-name>` > `Commands`, offers an `EDIT REPO CONFIG`
+command that allows to edit the `project.config` file directly in the Gerrit
+web UI.
++
+The `EDIT REPO CONFIG` command creates a new work-in-progress change with a
+change edit that contains the `project.config` file. Clicking on the command
+opens an online editor for the `project.config` file, allowing the user to make
+modifications to it.
++
+Modifications need to be saved and published ("saving" saves the modifications
+in the change edit, "publishing" publishes the change edit to make it visible
+to other users). While the change edit is not published yet, it's possible to
+add further files to it (e.g. the link:#file-groups[groups] file if it needs to
+be modified, in contrast to making access rights changes through the `Access`
+screen the `groups` file is not automatically updated). For further details how
+to work with change edits see the link:user-inline-edit.html[inline edit user
+guide]. After publishing the change edit, the change should be set to ready by
+clicking on `START REVIEW` > `SEND AND START REVIEW`. At this time you also
+want to add a project owner as a reviewer so that they can review and approve
+the change.
+--
+
+[[update-by-git-push]]
+* By pushing updates via Git:
++
+Since the configuration files are stored in a git branch, it's possible to
+update them via normal git operations:
++
+--
+1. Clone the repository if you don't have it available yet. The clone command
+   can be found in the `Download` section of project's `General` screen
+   (`BROWSE` > `Repositories` > `<project-name>` > `General`).
+2. Fetch and checkout the `refs/meta/config branch`, e.g. by `git fetch origin
+   refs/meta/config && git checkout FETCH_HEAD`.
+3. Edit the `project.config` file or other configuration files and commit the
+   changes, e.g. by `git commit --all`. Note, since the `project.config` file
+   uses the format of a git config file you can also edit it via the
+   `git config` command (e.g. to set a project description do: `git config -f
+   project.config project.description "My project description"`).
+4. Push the newly created commit, either to update the `refs/meta/config`
+   branch directly without code-review (e.g. `git push origin
+   HEAD:refs/meta/config`), or for review (e.g. `git push origin
+   HEAD:refs/for/refs/meta/config`).
+--
++
+[NOTE]
+Updates to access right may require changes in the link:#file-groups[groups]
+file. In contrast to making access rights changes through the `Access` screen
+the `groups` file is not automatically updated.
+
+[[update-through-rest-api]]
+* Through the Gerrit REST API:
++
+Gerrit offers several REST endpoints to modify the project configuration:
++
+** link:rest-api-projects.html#set-config[Set Config] REST endpoint:
+   Allows to edit the project description and change repository options.
+** link:rest-api-projects.html#set-project-description[Set Project Description]
+   REST endpoint:
+   Allows to set the project description.
+** link:rest-api-projects.html#delete-project-description[Delete Project
+   Description] REST endpoint:
+   Allows to unset the project description.
+** link:rest-api-projects.html#set-access[Add, Update and Delete Access Rights
+   for Project] REST endpoint:
+   Allows to edit the access rights of the project.
+** link:rest-api-projects.html#create-access-change[Create Access Rights Change
+   for review] REST endpoint:
+   Allows to create a change with access right modifications that can be
+   reviewed and submitted by a project owner.
+** link:rest-api-projects.html#create-label[Create Label] REST endpoint:
+   Allows to create a new label definition.
+** link:rest-api-projects.html#set-label[Set Label] REST endpoint:
+   Allows to update an existing label definition.
+** link:rest-api-projects.html#delete-label[Delete Label] REST endpoint:
+   Allows to delete an existing label definition.
+** link:rest-api-projects.html#create-submit-requirement[Create Submit
+   Requirement] REST endpoint:
+   Allows to create a new submit requirement.
+** link:rest-api-projects.html#update-submit-requirement[Update Submit
+   Requirement] REST endpoint:
+   Allows to update an existing submit requirement.
+** link:rest-api-projects.html#delete-submit-requirement[Delete Submit
+   Requirement] REST endpoint:
+   Allows to delete an existing submit requirement.
+
+[[required-permissions]]
+== Required permissions
+
+Depending on how the project configuration is changed different access rights
+are required:
+
+* Direct updates through the web UI (link:#update-through-general-screen[
+  updates through the `General` screen] and link:#update-through-access-screen[
+  direct updates of access rights through the `Access` screen via the `SAVE`
+  button]) and direct updates through the REST API (via the
+  link:rest-api-projects.html#set-config[Set Config] REST endpoint or the
+  link:rest-api-projects.html#set-access[Add, Update and Delete Access Rights
+  for Project] REST endpoint) require the user to be a project owner (have the
+  link:access-control.html#category_owner[Owner] access right assigned on
+  `refs/*` or have the link:access-control.html#capability_administrateServer[
+  Administrate Server] global capability assigned on the `All-Projects` root
+  project).
+* link:#update-by-git-push[Direct updates through `git push`] require the
+  user to have the link:access-control.html#category_push[Push] access right
+  assigned on `refs/meta/config` and be a project owner (have the
+  link:access-control.html#category_owner[Owner] access right assigned on
+  `refs/*` or have the link:access-control.html#capability_administrateServer[
+  Administrate Server] global capability assigned on the `All-Projects` root
+  project).
+* Creating changes for updates through the web UI
+  (link:#update-through-access-screen[proposing updates of access rights
+  through the `Access` screen via the `SAVE FOR REVIEW` button] and
+  link:#update-via-online-editing[proposing updates via the `EDIT REPO CONFIG`
+  command]), creating changes for updates through the REST API (via the
+  link:rest-api-projects.html#create-access-change[Create Access Rights Change
+  for review] REST endpoint) and link:#update-by-git-push[pushing changes for
+  review] require the user to be able to see the `refs/meta/config` branch
+  (have the link:access-control.html#category_read[Read] access right assigned
+  on `refs/meta/config`) and be allowed to create changes for it (have the
+  link:access-control.html#category_push[Push] access right assigned on
+  `refs/for/refs/meta/config` or be a project owner or be an administrator).
+
+[[comments]]
+=== Comments in project configuration files
+
+In principle it's possible to have comments in the project configuration files
+(lines starting with '#'), however if any Gerrit API is used that lead to
+modifications in the configuration files the comments may be dropped. This is
+because when Gerrit parses the configuration files and writes them back with
+updates, comments are not preserved.
+
+[TIP]
+When updating the project configuration use the commit message to record the
+reason for the settings so that this information is preserved in the git
+history. For example, this allows using `git blame` to inspect why
+configuration values have been set.
+
+[[inheritance]]
+== Inheritance
+
+Projects in Gerrit are organized hierarchically in a tree with the
+`All-Projects` project as the root project. The parent project is defined in
+the link:#access.inheritFrom[access section] of the `project.config` file.
+
+Projects inherit access rights and options from their parent project, but not
+all options are inheritable. See the description of the options in the
+link:#file-project_config[project.config] file to learn whether they are
+inherited or not.
+
+Options with boolean values support a special `INHERIT` value to make them
+inherit the value that is set in the parent project.
+
+Some settings can be enforced for child projects (or if set on the
+`All-Projects` root project for all projects), e.g. access right restrictions
+via link:access-control#block[BLOCK rules] or
+link:config-submit-requirements.html#submit_requirement_can_override_in_child_projects[
+non-overridable] submit requirements.
+
+[NOTE]
+The parent project for an existing project can be changed via the
+link:update-through-access-screen[Access] screen (by default this is only
+allowed for administrators).
+
+[NOTE]
+Project owners can be allowed to change the parent of projects that they own
+(see link:config-gerrit.html#receive.allowProjectOwnersToChangeParent[
+receive.allowProjectOwnersToChangeParent] setting which is `false` by default).
+In this case project owners may escape the settings that are enforced by their
+parent project by choosing a different parent project.
 
 [[file-project_config]]
-== The file +project.config+
+== The file `project.config`
 
-The +project.config+ file contains the link between groups and their
-permitted actions on reference patterns in this project and any projects
-that inherit its permissions.
+The `project.config` file contains the link to the parent project, the project
+description, access rights, link:config-labels.html[label definitions],
+link:config-submit-requirements.html[submit requirements] and options to
+control project-specific behavior.
 
-The format in this file corresponds to the Git config file format, so
-if you want to automate your permissions it is a good idea to use the
-+git config+ command when writing to the file. This way you know you
-don't accidentally break the format of the file.
+The format in this file corresponds to the Git config file format.
 
-Here follows a +git config+ command example:
+[TIP]
+--
+Since the format of the `project.config` file adheres to the Git config file
+format, utilizing the `git config` command when modifying the file is
+recommended. This way you can avoid breaking the format of the file
+accidentally.
+
+Here is an example of a `git config` command that updates the project
+description:
 
 ----
-$ git config -f project.config project.description "Rights inherited by all other projects"
+$ git config -f project.config project.description "Foo Bar"
 ----
+--
 
-Below you will find an example of the +project.config+ file format:
+This is an example of a `project.config` file:
 
 ----
 [project]
-       description = Rights inherited by all other projects
+  description = Collection of scripts for setting up foo bar.
 [access "refs/*"]
-       read = group Administrators
+  read = group Registered Users
 [access "refs/heads/*"]
-        label-Your-Label-Here = -1..+1 group Administrators
-[capability]
-       administrateServer = group Administrators
+  label-Code-Review = -2..+2 group Maintainers
+  label-Code-Review = -1..+1 group Registered Users
+  label-Your-Label = -1..+1 group Maintainers
 [receive]
-       requireContributorAgreement = false
-[label "Your-Label-Here"]
-        function = MaxWithBlock
-        value = -1 Your -1 Description
-        value =  0 Your No score Description
-        value = +1 Your +1 Description
+  requireChangeId = true
+[label "Your-Label"]
+  function = NoOp
+  value = -1 Your -1 Description
+  value =  0 Your No score Description
+  value = +1 Your +1 Description
+[submit-requirement "Your-Label"]
+  description = At least one maximum vote for label 'Your-Label' is required
+  applicableIf = -branch:refs/meta/config
+  submittableIf = label:Your-Label=MAX AND -label:Your-Label=MIN
 ----
 
-As you can see, there are several sections.
+The sections that can appear in the `project.config` file are explained below.
 
-The link:#project-section[+project+ section] appears once per project.
+[[access-section]]
+=== Access section
 
-The link:#access-subsection[+access+ section] appears once per reference pattern,
-such as `+refs/*+` or `+refs/heads/*+`. Only one access section per pattern is
-allowed.
+[[access.inheritFrom]]access.inheritFrom::
++
+Name of the parent project from which access rights are inherited.
++
+If not set, access rights are inherited from the `All-Projects` root project.
 
-The link:#receive-section[+receive+ section] appears once per project.
+[[access-subsection]]
+==== Access subsection
 
-The link:#submit-section[+submit+ section] appears once per project.
+`access` subsections define access rules for a ref or a ref namespace. The ref
+or ref namespace is specified as the subsection name and can be a concrete ref
+(e.g. `refs/heads/master`), a ref pattern (last path segment is '\*', e.g.
+`refs/heads/*`) or a regular expression (must start with '^', e.g.
+`^refs/heads/rel-.*`).
 
-The link:#capability-section[+capability+] section only appears once, and only
-in the +All-Projects+ repository.  It controls core features that are configured
-on a global level.
+[NOTE]
+For ref patterns '\*' can only appear as the last path segment. If a '*' is
+required in any other place the ref namespace must be specified as a regular
+expression (must start with '^', '\*' must follow what's being matched, e.g.
+".*" to match any string).
 
-The link:#label-section[+label+] section can appear multiple times. You can
-also redefine the text and behavior of the built in label types `Code-Review`
-and `Verified`.
+[NOTE]
+Only one access subsection per ref and per ref namespace is allowed.
 
-Optionally a +commentlink+ section can be added to define project-specific
-comment links. The +commentlink+ section has the same format as the
-link:config-gerrit.html#commentlink[+commentlink+ section in gerrit.config]
+The `access` subsections contain access rules that apply to the ref or ref
+namespace of the `access` subsections. The format of the access rules is: +
+`<accessCategoryId> = (block|deny)? <range>? group <group-name>`
+
+* `<accessCategoryId>`: ID of the link:access-control.html#access_categories[
+  access category] for which the access rule should be defined. The ID of the
+  access category is the name of the access category in lowerCamelCase (e.g.
+  `createTag`), except for label permissions where it is `label-<label-Name>`
+  (e.g. `label-Code-Review`).
+* `(block|deny)?`: `block` defines a link:access-control.html#block-rule[BLOCK]
+  rule, `deny` defines a link:access-control.html#deny-rule[DENY] rule, if
+  neither `block` or `deny` is specified an link:access-control.html#allow-rule[
+  ALLOW] rule is defined.
+* `<range>?`: Only set for label permission. The voting range in the format
+  `<min-vote>..<max-vote>` (e.g. `-1..+1`).
+* `group <group-name>`: The (local) name of the group to which the access rule
+  should apply (e.g. `group Foo Bar`). The (local) group name must exist in the
+  link:#file-groups[groups] file, so that Gerrit can resolve it to the group
+  UUID.
+
+To make access rules link:access-control.html#exclusive[exclusive] they need to
+be included into the value of the `exclusiveGroupPermissions` key: +
+`exclusiveGroupPermissions = <space-separated-list-of-access-category-ids>`
+
+.Example access subsections
+----
+  [access "refs/heads/*"]
+    create = group Administrators
+    delete = group Administrators
+    deleteChanges = group Administrators
+    label-Code-Review = -2..+2 group Maintainers
+    label-Code-Review = -1..+1 group Registered Users
+    label-Verified = -1..+1 group Registered Users
+    push = block Registered Users
+    submit = group Maintainers
+    exclusiveGroupPermissions = deleteChanges submit
+  [access "^refs/tags/rel-.*"]
+    createTag = group Maintainers
+    createSignedTag = group Maintainers
+----
+
+[[branchOrder-section]]
+=== branchOrder section
+
+Defines a branch ordering which is used for backporting of changes.
+Backporting will be offered for a change (in the Gerrit UI) for all
+more stable branches where the change can merge cleanly.
+
+[[branchOrder.branch]]branchOrder.branch::
+
+A branch name, typically multiple values will be defined. The order of branch
+names in this section defines the branch order. The topmost is considered to be
+the least stable branch (typically the master branch) and the last one the
+most stable (typically the last maintained release branch).
+
+.Example:
+----
+[branchOrder]
+  branch = master
+  branch = stable-2.9
+  branch = stable-2.8
+  branch = stable-2.7
+----
+
+The `branchOrder` section is inheritable. This is useful when multiple or all
+projects follow the same branch rules. A `branchOrder` section in a child
+project completely overrides any `branchOrder` section from a parent i.e. there
+is no merging of `branchOrder` sections. A present but empty `branchOrder`
+section removes all inherited branch order.
+
+Branches not listed in this section will not be included in the mergeability
+check. If the `branchOrder` section is not defined then the mergeability of a
+change into other branches will not be computed.
+
+[[capability-section]]
+=== Capability section
+
+The `capability` section only appears once, and only in the `project.config`
+file of the `All-Projects` root project. It controls Gerrit administration
+capabilities that are configured on a global level.
+
+.Example:
+----
+[capability]
+  administrateServer = group Administrators
+----
+
+[NOTE]
+The (local) group names for which capabilities are assigned must exist in the
+link:#file-groups[groups] file, so that Gerrit can resolve them to their group
+UUID.
+
+Please refer to the
+link:access-control.html#global_capabilities[Global Capabilities]
+documentation for a full list of available capabilities.
+
+[[change-section]]
+=== Change section
+
+The change section includes configuration for project-specific change settings:
+
+[[change.privateByDefault]]change.privateByDefault::
++
+Controls whether all new changes in the project are set as private by default.
++
+Note that a new change will be public if the `is_private` field in
+link:rest-api-changes.html#change-input[ChangeInput] is set to `false` explicitly
+when calling the link:rest-api-changes.html#create-change[CreateChange] REST API
+or the `remove-private` link:user-upload.html#private[PushOption] is used during
+the Git push.
++
+Default is `INHERIT`, which means that this property is inherited from
+the parent project.
+
+[[change.workInProgressByDefault]]change.workInProgressByDefault::
++
+Controls whether all new changes in the project are set as WIP by default.
++
+Note that a new change will be ready if the `workInProgress` field in
+link:rest-api-changes.html#change-input[ChangeInput] is set to `false` explicitly
+when calling the link:rest-api-changes.html#create-change[CreateChange] REST API
+or the `ready` link:user-upload.html#wip[PushOption] is used during
+the Git push.
++
+Default is `INHERIT`, which means that this property is inherited from
+the parent project.
+
+[[commentlink-section]]
+=== Commentlink section
+
+Optionally a `commentlink` section can be added to define project-specific
+comment links. The `commentlink` section has the same format as the
+link:config-gerrit.html#commentlink[commentlink] section in `gerrit.config`
 which is used to define global comment links.
 
+[[label-section]]
+=== Label section
+
+Please refer to link:config-labels.html#label_custom[Custom Labels] documentation.
+
+[[mimetype-section]]
+=== MIME Types section
+
+The +mimetype+ section may be configured to force the web code
+reviewer to return certain MIME types by file path. MIME types
+may be used to activate syntax highlighting.
+
+----
+[mimetype "text/x-c"]
+  path = *.pkt
+[mimetype "text/x-java"]
+  path = api/current.txt
+----
+
 [[project-section]]
 === Project section
 
@@ -281,36 +682,31 @@
 where a commit is already merged into a branch and you want to create
 a new open change for that commit on another branch.
 
-[[change-section]]
-=== Change section
+[[reviewer-section]]
+=== reviewer section
 
-The change section includes configuration for project-specific change settings:
+Defines config options to adjust a project's reviewer workflow such as enabling
+reviewers and CCs by email.
 
-[[change.privateByDefault]]change.privateByDefault::
+[[reviewer.enableByEmail]]reviewer.enableByEmail::
 +
-Controls whether all new changes in the project are set as private by default.
+A boolean indicating if reviewers and CCs that do not currently have a Gerrit
+account can be added to a change by providing their email address.
 +
-Note that a new change will be public if the `is_private` field in
-link:rest-api-changes.html#change-input[ChangeInput] is set to `false` explicitly
-when calling the link:rest-api-changes.html#create-change[CreateChange] REST API
-or the `remove-private` link:user-upload.html#private[PushOption] is used during
-the Git push.
+This setting only takes effect for changes that are readable by anonymous users.
 +
 Default is `INHERIT`, which means that this property is inherited from
-the parent project.
+the parent project. If the property is not set in any parent project, the
+default value is `FALSE`.
 
-[[change.workInProgressByDefault]]change.workInProgressByDefault::
+[[reviewer.skipAddingAuthorAndCommitterAsReviewers]]reviewer.skipAddingAuthorAndCommitterAsReviewers::
 +
-Controls whether all new changes in the project are set as WIP by default.
-+
-Note that a new change will be ready if the `workInProgress` field in
-link:rest-api-changes.html#change-input[ChangeInput] is set to `false` explicitly
-when calling the link:rest-api-changes.html#create-change[CreateChange] REST API
-or the `ready` link:user-upload.html#wip[PushOption] is used during
-the Git push.
+Whether to skip adding the Git commit author and committer as reviewers for
+a new change.
 +
 Default is `INHERIT`, which means that this property is inherited from
-the parent project.
+the parent project. If the property is not set in any parent project, the
+default value is `FALSE`.
 
 [[submit-section]]
 === Submit section
@@ -457,9 +853,9 @@
 unless merge commits are being submitted. For some people this is an advantage
 since they find the linear history easier to read.
 +
-NOTE: Rebasing merge commits is not supported. If a change with a merge commit
-is submitted the link:#merge_if_necessary[merge if necessary] submit action is
-applied.
+NOTE: Rebasing merge commits is done by rebasing their first parent commit,
+i.e. the first parent is updated to the new base while the second parent stays
+intact.
 
 [[rebase_always]]
 * 'rebase always':
@@ -476,9 +872,9 @@
 unless merge commits are being submitted. For some people this is an advantage
 since they find the linear history easier to read.
 +
-NOTE: Rebasing merge commits is not supported. If a change with a merge commit
-is submitted the link:#merge_if_necessary[merge if necessary] submit action is
-applied.
+NOTE: Rebasing merge commits is done by rebasing their first parent commit,
+i.e. the first parent is updated to the new base while the second parent stays
+intact.
 +
 When rebasing the patchset, Gerrit automatically appends onto the end of the
 commit message a short summary of the change's approvals, and a URL link back
@@ -634,132 +1030,32 @@
 is set to 'true' the merge would fail in such a case. An empty commit is still allowed as the
 initial commit on a branch.
 
-
-[[access-section]]
-=== Access section
-
-[[access.inheritFrom]]access.inheritFrom::
-+
-Name of the parent project from which access rights are inherited.
-+
-If not set, access rights are inherited from the `All-Projects` root project.
-
-[[access-subsection]]
-==== Access subsection
-
-+access+ subsections for references connect access rights to groups. Each group
-listed must exist in the link:#file-groups[+groups+ file].
-
-Please refer to the
-link:access-control.html#access_categories[Access Categories]
-documentation for a full list of available access rights.
-
-
-[[mimetype-section]]
-=== MIME Types section
-
-The +mimetype+ section may be configured to force the web code
-reviewer to return certain MIME types by file path. MIME types
-may be used to activate syntax highlighting.
-
-----
-[mimetype "text/x-c"]
-  path = *.pkt
-[mimetype "text/x-java"]
-  path = api/current.txt
-----
-
-
-[[capability-section]]
-=== Capability section
-
-The +capability+ section only appears once, and only in the +All-Projects+
-repository.  It controls Gerrit administration capabilities that are configured
-on a global level.
-
-Please refer to the
-link:access-control.html#global_capabilities[Global Capabilities]
-documentation for a full list of available capabilities.
-
-[[label-section]]
-=== Label section
-
-Please refer to link:config-labels.html#label_custom[Custom Labels] documentation.
-
 [[submit-requirement-section]]
 === Submit Requirement section
 
 Please refer to link:config-submit-requirements.html[Configuring Submit
 Requirements] documentation.
 
-[[branchOrder-section]]
-=== branchOrder section
-
-Defines a branch ordering which is used for backporting of changes.
-Backporting will be offered for a change (in the Gerrit UI) for all
-more stable branches where the change can merge cleanly.
-
-[[branchOrder.branch]]branchOrder.branch::
-+
-A branch name, typically multiple values will be defined. The order of branch
-names in this section defines the branch order. The topmost is considered to be
-the least stable branch (typically the master branch) and the last one the
-most stable (typically the last maintained release branch).
-+
-Example:
-+
-----
-[branchOrder]
-  branch = master
-  branch = stable-2.9
-  branch = stable-2.8
-  branch = stable-2.7
-----
-+
-The `branchOrder` section is inheritable. This is useful when multiple or all
-projects follow the same branch rules. A `branchOrder` section in a child
-project completely overrides any `branchOrder` section from a parent i.e. there
-is no merging of `branchOrder` sections. A present but empty `branchOrder`
-section removes all inherited branch order.
-+
-Branches not listed in this section will not be included in the mergeability
-check. If the `branchOrder` section is not defined then the mergeability of a
-change into other branches will not be done.
-
-[[reviewer-section]]
-=== reviewer section
-
-Defines config options to adjust a project's reviewer workflow such as enabling
-reviewers and CCs by email.
-
-[[reviewer.enableByEmail]]reviewer.enableByEmail::
-+
-A boolean indicating if reviewers and CCs that do not currently have a Gerrit
-account can be added to a change by providing their email address.
-+
-This setting only takes effect for changes that are readable by anonymous users.
-+
-Default is `INHERIT`, which means that this property is inherited from
-the parent project. If the property is not set in any parent project, the
-default value is `FALSE`.
-
-[[reviewer.skipAddingAuthorAndCommitterAsReviewers]]reviewer.skipAddingAuthorAndCommitterAsReviewers::
-+
-Whether to skip adding the Git commit author and committer as reviewers for
-a new change.
-+
-Default is `INHERIT`, which means that this property is inherited from
-the parent project. If the property is not set in any parent project, the
-default value is `FALSE`.
-
 [[file-groups]]
-== The file +groups+
+== The file `groups`
 
-Each group in this list is linked with its UUID so that renaming of
-groups is possible without having to rewrite every +groups+ file
-in every repository where it's used.
+The `groups` file resolves group names that are mentioned in
+link:#access-subsection[access subsections] of the link:#file-project_config[
+project.config] file to group UUIDs.
 
-This is what the default groups file for +All-Projects.git+ looks like:
+In the access subsections access rights are assigned to group names. These
+group names are local to the project configuration and do not need to match
+with the actual group names. To enable Gerrit to resolve the local group names
+they must be mapped to group UUIDs in the `groups` file.
+
+The access sections use local group names, rather than requiring the actual
+group names, to allow renaming groups in Gerrit without having to rewrite every
+`project.config` file using the group.
+
+The content of the `groups` file is a simple table of group UUID to group name,
+separated by a tab.
+
+This is how the default `groups` file for `All-Projects` project looks like:
 
 ----
 # UUID                                         Group Name
@@ -771,29 +1067,37 @@
 global:Registered-Users                        Registered Users
 ----
 
-This file can't be written to by the +git config+ command.
+Since the `groups` file has a custom format it can't be edited using the
+`git config` command.
 
-In order to reference a group in +project.config+, it must be listed in
-the +groups+ file.  When editing permissions through the web UI this
-file is maintained automatically, but when pushing updates to
-+refs/meta/config+ this must be dealt with by hand.  Gerrit will refuse
-+project.config+ files that refer to groups not listed in +groups+.
+Whenever access rights in the `project.config` file are assigned to new groups
+mapping entries for the new groups must be added to the `groups` file. The
+modifications to the `groups` file must be included in the same commit that
+updates the `project.config` file. Pushing updates to `project.config` files
+that refer to groups not listed in the `groups` file are rejected by Gerrit.
 
-The UUID of a group can be found on the General tab of the group's page
-in the web UI or via the +-v+ option to
-link:cmd-ls-groups.html[the +ls-groups+ SSH command].
+When link:#update-through-access-screen[editing access rights through the web
+UI] the `groups` file is automatically updated by Gerrit.
 
+The UUID of a group can be found on the group screen (`BROWSE` > `Groups` >
+`<group-name>` ). Alternatively the group can be looked up via the
+link:rest-api-groups.html#get-group[Get Group] REST endpoint (note that the
+`group-id` in the URL can be the group name). The group UUID is contained as
+`id` field in the return link:rest-api-groups.html#group-info[GroupInfo] JSON.
 
 [[file-rules_pl]]
-== The file +rules.pl+
+== The file `rules.pl`
 
-The +rules.pl+ files allows you to replace or amend the default Prolog
-rules that control e.g. what conditions need to be fulfilled for a
-change to be submittable.  This file content should be
-interpretable by the 'Prolog Cafe' interpreter.
+The `rules.pl` file allows to replace or amend the default Prolog rules that
+control what conditions need to be fulfilled for a change to be submittable.
+This file should be interpretable by the 'Prolog Cafe' interpreter.
 
-You can read more about the +rules.pl+ file and the prolog rules on
-link:prolog-cookbook.html[the Prolog cookbook page].
+You can read more about prolog rules on the link:prolog-cookbook.html[Prolog
+cookbook] page.
+
+[NOTE]
+Prolog rules are deprecated and have been replaced by
+link:config-submit-requirements.html[submit requirements].
 
 GERRIT
 ------
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index 1bcda63..5ab1add 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -1,32 +1,375 @@
 = Gerrit Code Review - Submit Requirements
 
-As part of the code review process, project owners need to configure rules that
-govern when changes become submittable. For example, an admin might want to
-prevent changes from being submittable until at least a “+2” vote on the
-“Code-Review” label is granted on a change. Admins can define submit
-requirements to enforce submittability rules for changes.
+Submit requirements are rules that define when a change can be submitted. This
+page describes how to configure them.
 
 [[configuring_submit_requirements]]
 == Configuring Submit Requirements
 
-Site administrators and project owners can define submit requirements in the
-link:config-project-config.html[project config]. A submit requirement has the
-following fields:
+Submit requirements are defined as link:#submit-requirement-subsection[
+submit-requirement] subsections in the
+link:config-project-config.html#file-project_config[project.config] file. The
+subsection name defines the name of the submit requirement.
 
+[NOTE]
+There are multiple options how to update `project.config` files, please refer
+to the link:config-project-config.html#update[project config documentation].
 
-[[submit_requirement_name]]
-=== submit-requirement.Name
+[TIP]
+When modifying submit requirements it's recommended to
+link:#test-submit-requirements[test] them before updating them in the project
+configuration.
 
-A name that uniquely identifies the submit requirement. Submit requirements
-can be overridden in child projects if they are defined with the same name in
-the child project. See the link:#inheritance[inheritance] section for more
-details.
+[WARNING]
+--
+When adding submit requirements think about whether they should apply to the
+link:config-project-config.html#refs-meta-config[refs/meta/config] branch
+(see the link:#submit_requirement_applicable_if[applicableIf] description on
+how to exempt the `refs/meta/config` branch from a submit requirement). Since
+submit requirements are stored as part of the project configuration in the
+`refs/meta/config` branch, changing them through code review requires to pass
+the submit requirements that apply to the `refs/meta/config` branch. Hence by
+misconfiguring submit requirements for the `refs/meta/config` branch you can
+make further updates to submit requirements through code review impossible.
+If this happens the submit requirements can be restored by a direct push to the
+`refs/meta/config` branch.
+
+[[restore-submit-requirements]]
+If direct pushes are disabled or not allowed project owners can directly update
+the submit requirements via the
+link:rest-api-projects.html#update-submit-requirement[Update Submit Requirement]
+REST endpoint.
+
+.Example:
+----
+  curl -X PUT --header "Content-Type: application/json" -d '{"name": "Foo-Review", "description": "At least one maximum vote for the Foo-Review label is required", "submittability_expression": "label:Foo-Review=MAX AND -label:Foo-Review=MIN", "applicability_expression": "-branch:refs/meta/config", "canOverrideInChildProjects": true}' "https://<HOST>/a/projects/My%2FProject/submit_requirements/Foo-Review"
+----
+
+Tip: Googlers should use `gob-curl` instead of `curl` so that authentication is
+handled automatically.
+--
+
+[[test-submit-requirements]]
+=== Testing Submit Requirements
+
+When modifying submit requirements it's recommended to test them before
+updating them in the project configuration.
+
+To test a submit requirement on a selected change
+link:rest-api-changes.html#change-id[project\~branch~changeId] use the
+link:rest-api-changes.html#check-submit-requirement[Check Submit Requirement]
+REST endpoint.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/check.submit_requirement HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+    {
+      "name": "Code-Review",
+      "submittability_expression": "label:Code-Review=+2"
+    }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Code-Review",
+    "status": "SATISFIED",
+    "submittability_expression_result": {
+      "expression": "label:Code-Review=+2",
+      "fulfilled": true,
+      "passingAtoms": [
+        "label:Code-Review=+2"
+      ]
+    },
+    "is_legacy": false
+  }
+----
+
+Alternatively you can make a change that updates a submit requirement in the
+`project.config` file, upload it for review to the `refs/meta/config` branch
+and then load it from that change which is in review to test it against a
+change.
+
+Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/check.submit_requirement?sr-name=Code-Review&refs-config-change-id=myProject~refs/meta/config~Ibc1409aef8bf0a16a76f9fa9a928bd505228fa1d HTTP/1.0
+----
+
+In the above example `myProject\~master~I8473b95934b5732ac55d26311a706c9c2bde9940`
+is the change against which the submit requirement is tested, `Code Review` is
+the name of the submit requirement that is tested and
+`myProject\~refs/meta/config~Ibc1409aef8bf0a16a76f9fa9a928bd505228fa1d` is the
+change from which the `Code Review` submit requirement is loaded. Change
+`myProject~refs/meta/config~Ibc1409aef8bf0a16a76f9fa9a928bd505228fa1d` must be
+a change that touches the `project.config` file in the `refs/meta/config`
+branch and the `project.config` file must contain a submit requirement with the
+name `Code-Review`.
+
+[[dashboard]]
+=== Show Submit Requirements on Dashboards
+
+Gerrit offers dashboards that provide an overview over a set of changes (e.g.
+user dashboards shows changes that are relevant to the user, change list
+dashboards show changes that match a change query). To understand the state of
+the changes knowing the status of their submit requirements is important, but
+submit requirements are manifold and dashboards have only limited screen space
+available, so showing all submit requirements in dashboards is hardly possible.
+This is why administrators must decide which are the most important submit
+requirements that should be shown on dashboards. They can configure these
+submit requirements in `gerrit.config` by setting the
+link:config-gerrit.html#dashboard[dashboard.submitRequirementColumns] option.
+
+[NOTE]
+In order to save screen space submit requirement names on dashboards are
+abbreviated, e.g. a submit requirement called `Foo-Bar` is shown as `FB`.
+
+[[inheritance]]
+== Inheritance
+
+Submit requirements are inherited from parent projects. Child projects may
+override an inherited submit requirement by defining a submit requirement with
+the same name, but only if overriding the submit requirement is allowed (see
+link:#submit_requirement_can_override_in_child_projects[
+canOverrideInChildProjects] field). Overriding an inherited submit requirement
+always overrides the complete submit requirement definition, overriding single
+fields only is not possible.
+
+[NOTE]
+To remove an inherited submit requirement in a child project, set both the
+link:#submit_requirement_applicable_if[applicableIf] expression and the
+link:#submit_requirement_submittable_if[submittableIf] expression to
+`is:false`.
+
+[NOTE]
+If overriding a submit requirement is disallowed in a parent project, submit
+requirements with the same name in child projects, that would otherwise
+override the inherited submit requirement, are ignored.
+
+[[labels]]
+== Labels and Submit Requirements
+
+link:config-labels.html[Labels] define voting categories for reviewers to score
+changes. Often a label is accompanied by a submit requirement to check the votes
+on the label, e.g. with a link:#submit_requirement_submittable_if[submittableIf]
+expression that checks that:
+
+* the label was approved: `label:My-Label=MAX`
+* the label has no veto: `-label:My-Label=MIN`
+* the label was not self-approved: `label:My-Label=MAX,user=non_uploader`
+* the label was approved by multiple users: `label:My-Label,count>1`
+
+Submit requirements that check votes for a single label often have the same
+name as the label, e.g.:
+
+----
+[label "Code-Review"]
+  function = NoBlock
+  value = -2 This shall not be submitted
+  value = -1 I would prefer this is not merged as is
+  value = 0 No score
+  value = +1 Looks good to me, but someone else must approve
+  value = +2 Looks good to me, approved
+  defaultValue = 0
+[submit-requirement "Code-Review"]
+  description = At least one maximum vote for label 'Code-Review' is required
+  submittableIf = label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN
+  canOverrideInChildProjects = true
+----
+
+[[trigger-votes]]
+=== Trigger Votes
+
+Trigger votes are votes on labels that are not associated with any submit
+requirement expressions, i.e. the submittability of changes doesn't depend on
+these votes.
+
+Voting on labels that have no impact on the submittability of changes usually
+serves the purpose to trigger processes, e.g. a vote on a `Presubmit-Ready`
+label can be a signal to run presubmit integration tests. Hence these votes are
+called `trigger votes`.
+
+Trigger votes are displayed in a separate section in the change page.
+
+[[deprecated]]
+== Deprecated ways to control when changes can be submitted
+
+Using submit requirements is the recommended way to control when changes can be
+submitted. However, historically there are other ways for this, which are still
+working, although they are deprecated:
+
+[[label-functions]]
+* Label functions:
++
+link:config-labels.html#label_custom[Label definitions] can contain a
+link:config-labels.html#label_function[function] that impacts the
+submittability of changes (link:config-labels.html#MaxWithBlock[MaxWithBlock],
+link:config-labels.html#AnyWithBlock[AnyWithBlock],
+link:config-labels.html#MaxNoBlock[MaxNoBlock]). These functions are deprecated
+and setting them is no longer allowed, however if they are (already) set for
+existing label definitions they are still respected. For new labels the
+function should be set to link:config-labels.html#NoBlock[NoBlock] and then
+submit requirements should be used to control when changes can be submitted
+(using `submittableIf = label:My-Label=MAX AND -label:My-Label=MIN` is
+equivalent to `MaxWithBlock`, using `submittableIf = -label:My-Label=MIN` is
+equivalent to `AnyWithBlock`, using `submittableIf = label:My-Label=MAX` is
+equivalent to using `MaxNoBlock`).
+
+[[ignoreSelfApproval]]
+* `ignoreSelfApproval` flag on labels:
++
+Labels can be configured to link:config-labels.html#label_ignoreSelfApproval[
+ignore self approvals]. This flag only works in combination with the deprecated
+label functions (see link:#label-functions[above]) and hence it is deprecated
+as well. Instead use a `submittableIf` expression with the
+link:#operator_label[label] operator and the `user=non_uploader` argument. See
+the link:#code-review-example[Code Review] submit requirement example.
+
+[[prolog-rules]]
+* Prolog rules:
++
+Projects can define link:prolog-cookbook.html[prolog submit rules] that control
+when changes can be submitted. It's still possible to have Prolog submit rules,
+but they are deprecated and support for them will be dropped in future Gerrit
+releases. Hence it's recommended to use submit requirements instead.
+
+When checking whether changes can be submitted Gerrit takes results of label
+functions and Prolog submit rules into account, in addition to the submit
+requirements.
+
+[[plugin-submit-rules]]
+== Plugin provided submit rules
+
+Plugins can contribute submit rules by implementing the `SubmitRule` extension
+point (see link:dev-plugins.html#pre-submit-evaluator[Pre-submit Validation
+Plugins]).
+
+When checking whether changes can be submitted Gerrit takes results of
+plugin-provided submit rules into account, in addition to the submit
+requirements.
+
+[[evaluation]]
+== Submit Requirement Evaluation
+
+Submit requirements are evaluated whenever a change is updated. To decide
+whether changes can be submitted, the results of link:#label-functions[label
+functions], link:#prolog-rules[Prolog submit rules] and
+link:#plugin-submit-rules[plugin-provided submit rules] are taken into account,
+in addition to the submit requirements. For this the results of label
+functions, Prolog submit rules and plugin-provided submit rules are converted
+to submit requirement results.
+
+Submit requirement results are returned in the REST API when retrieving changes
+with the link:rest-api-changes.html#submit-requirements[SUBMIT_REQUIREMENTS]
+option (e.g. via the link:rest-api-changes.html#get-change-detail[Get Change
+Detail] REST endpoint or the link:rest-api-changes.html#list-changes[Query
+Changes] REST endpoint). If requested, submit requirements are included as
+link:rest-api-changes.html#submit-requirement-result-info[
+SubmitRequirementResultInfo] entities into
+link:rest-api-changes.html#change-info[ChangeInfo] (field
+`submit_requirements`).
+
+The `status` field of submit requirement results can be one of:
+
+[[status-not-applicable]]
+* `NOT_APPLICABLE`
++
+The link:#submit_requirement_applicable_if[applicableIf] expression evaluates
+to false for the change.
+
+[[status-unsatisfied]]
+* `UNSATISFIED`
++
+The submit requirement is applicable
+(link:#submit_requirement_applicable_if[applicableIf] evaluates to true), but
+the evaluation of the link:#submit_requirement_submittable_if[submittableIf] and
+link:#submit_requirement_override_if[overrideIf] expressions return false for
+the change.
+
+[[status-satisfied]]
+* `SATISFIED`
++
+The submit requirement is applicable
+(link:#submit_requirement_applicable_if[applicableIf] evaluates to true), the
+link:#submit_requirement_submittable_if[submittableIf] expression evaluates to
+true, and the link:#submit_requirement_override_if[overrideIf] evaluates to
+false for the change.
+
+[[status-overridden]]
+* `OVERRIDDEN`
++
+The submit requirement is applicable
+(link:#submit_requirement_applicable_if[applicableIf] evaluates to true) and the
+link:#submit_requirement_override_if[overrideIf] expression evaluates to true.
++
+Note that in this case, the submit requirement is overridden regardless of
+whether the link:#submit_requirement_submittable_if[submittableIf] expression
+evaluates to true or not.
+
+[[status-forced]]
+* `FORCED`
++
+The change was merged directly bypassing code review by supplying the
+link:user-upload.html#auto_merge[submit] push option while doing a git push.
+
+[[status-error]]
+* `ERROR`
++
+The evaluation of any of the
+link:#submit_requirement_applicable_if[applicableIf],
+link:#submit_requirement_submittable_if[submittableIf] or
+link:#submit_requirement_override_if[overrideIf] expressions resulted in an
+error, i.e. because the expression is not parseable.
+
+[NOTE]
+Gerrit can be configured to return a `500 internal server error` response
+instead of setting the status to `ERROR` (see the
+link:config-gerrit.html#change.propagateSubmitRequirementErrors[
+change.propagateSubmitRequirementErrors] option that can be set in
+`gerrit.config`).
+
+[[submit-requirement-subsection]]
+== submit-requirement subsection
+
+Each `submit-requirement` subsection defines a submit requirement.
+
+The name of the `submit-requirement` subsection defines the name that uniquely
+identifies the submit requirement. It is shown to the user in the web UI when
+the submit requirement is applicable.
+
+[NOTE]
+By using the same name as an inherited submit requirement, the inherited submit
+requirement can be overridden, if overriding is allowed (see
+link:#submit_requirement_can_override_in_child_projects[
+canOverrideInChildProjects] field). Details about overriding submit
+requirements are explained in the link:#inheritance[inheritance] section.
+
+Submit requirements must at least define a
+link:#submit_requirement_submittable_if[submittableIf] expression that defines
+when a change can be submitted.
+
+.Example:
+----
+[submit-requirement "Verified"]
+  description = CI result status for build and tests is passing
+  applicableIf = -branch:refs/meta/config
+  submittableIf = label:Verified=MAX AND -label:Verified=MIN
+  canOverrideInChildProjects = true
+----
+
+The fields that can be set for submit requirements are explained below.
 
 [[submit_requirement_description]]
 === submit-requirement.Name.description
 
 A detailed description of what the submit requirement is supposed to do. This
-field is optional. The description is visible to the users in the change page
+field is optional. The description is visible to the user in the change page
 upon hovering on the submit requirement to help them understand what the
 requirement is about and how it can be fulfilled.
 
@@ -34,18 +377,21 @@
 === submit-requirement.Name.applicableIf
 
 A link:#query_expression_syntax[query expression] that determines if the submit
-requirement is applicable for a change. For example, administrators can exclude
-submit requirements for certain branch patterns. See the
-link:#exempt-branch-example[exempt branch] example.
+requirement is applicable for a change. If a submit requirement is not
+applicable it is hidden in the web UI. For example, this allows to
+link:#exempt-branch-example[exempt a branch] from the submit requirement.
 
+[TIP]
+--
 Often submit requirements should only apply to branches that contain source
-code. In this case this parameter can be used to exclude the
+code. In this case the `applicableIf` condition can be used to exclude the
 link:config-project-config.html#refs-meta-config[refs/meta/config] branch from
-a submit requirement:
+the submit requirement:
 
 ----
   applicableIf = -branch:refs/meta/config
 ----
+--
 
 This field is optional, and if not specified, the submit requirement is
 considered applicable for all changes in the project.
@@ -54,7 +400,7 @@
 === submit-requirement.Name.submittableIf
 
 A link:#query_expression_syntax[query expression] that determines when the
-change becomes submittable. This field is mandatory.
+change can be submitted. This field is mandatory.
 
 
 [[submit_requirement_override_if]]
@@ -64,9 +410,10 @@
 submit requirement is overridden. When this expression is evaluated to true,
 the submit requirement state becomes `OVERRIDDEN` and the submit requirement
 is no longer blocking the change submission.
+
 This expression can be used to enable bypassing the requirement in some
-circumstances, for example if the change owner is a power user or to allow
-change submission in case of emergencies. +
+circumstances, for example if the uploader is a trusted bot user or to allow
+change submission in case of emergencies.
 
 This field is optional.
 
@@ -74,73 +421,10 @@
 === submit-requirement.Name.canOverrideInChildProjects
 
 A boolean (true, false) that determines if child projects can override the
-submit requirement. +
+submit requirement.
 
 The default value is `false`.
 
-[[evaluation_results]]
-== Evaluation Results
-
-When submit requirements are configured, their results are returned for all
-changes requested by the REST API with the
-link:rest-api-changes.html#submit-requirement-result-info[SubmitRequirementResultInfo]
-entity. +
-
-Submit requirement results are produced from the evaluation of the submit
-requirements in the project config (
-See link:#configuring_submit_requirements[Configuring Submit Requirements])
-as well as the conversion of the results of the legacy submit rules to submit
-requirement results. Legacy submit rules are label functions
-(see link:config-labels.html[config labels]), custom and
-link:prolog-cookbook.html[prolog] submit rules.
-
-The `status` field can be one of:
-
-* `NOT_APPLICABLE`
-+
-The link:#submit_requirement_applicable_if[applicableIf] expression evaluates
-to false for the change.
-
-* `UNSATISFIED`
-+
-The submit requirement is applicable
-(link:#submit_requirement_applicable_if[applicableIf] evaluates to true), but
-the evaluation of the link:#submit_requirement_submittable_if[submittableIf] and
-link:#submit_requirement_override_if[overrideIf] expressions return false for
-the change.
-
-* `SATISFIED`
-+
-The submit requirement is applicable
-(link:#submit_requirement_applicable_if[applicableIf] evaluates to true), the
-link:#submit_requirement_submittable_if[submittableIf] expression evaluates to
-true, and the link:#submit_requirement_override_if[overrideIf] evaluates to
-false for the change.
-
-* `OVERRIDDEN`
-+
-The submit requirement is applicable
-(link:#submit_requirement_applicable_if[applicableIf] evaluates to true) and the
-link:#submit_requirement_override_if[overrideIf] expression evaluates to true.
-+
-Note that in this case, the change is overridden whether the
-link:#submit_requirement_submittable_if[submittableIf] expression evaluates to
-true or not.
-
-* `FORCED`
-+
-The change was merged directly bypassing code review by supplying the
-link:user-upload.html#auto_merge[submit] push option while doing a git push.
-
-* `ERROR`
-+
-The evaluation of any of the
-link:#submit_requirement_applicable_if[applicableIf],
-link:#submit_requirement_submittable_if[submittableIf] or
-link:#submit_requirement_override_if[overrideIf] expressions resulted in an
-error.
-
-
 [[query_expression_syntax]]
 == Query Expression Syntax
 
@@ -247,13 +531,17 @@
 pattern.
 
 [[operator_label]]
-label:labelName=+1,user=non_contributor::
+label:LabelExpression::
 +
-Submit requirements support an additional `user=non_contributor` argument for
-labels that returns true if the change has a label vote matching the specified
-value and the vote is applied from a gerrit account that's not the uploader,
-author or committer of the latest patchset. See the documentation for the labels
-operator in the link:user-search.html[user search] page.
+The `label` operator allows to match changes that have votes matching the given
+`LabelExpression`. The `LabelExpression` can be anything that's supported for
+the link:user-search.html#labels[label] query operator.
++
+If used in submit requirement expressions, this operator supports an additional
+`user=non_contributor` argument. This argument works similar to the
+link:user-search.html#non_uploader["user=non_uploader"] argument and returns
+true if the change has a matching label vote that is applied by a user that's
+not the uploader, author or committer of the latest patchset.
 
 [[unsupported_operators]]
 === Unsupported Operators
@@ -265,39 +553,6 @@
 +
 Cannot be used since it will result in recursive evaluation of expressions.
 
-[[inheritance]]
-== Inheritance
-
-Child projects can override a submit requirement defined in any of their parent
-projects. Overriding a submit requirement overrides all of its properties and
-values. The overriding project needs to define all mandatory fields.
-
-Submit requirements are looked up from the current project up the inheritance
-hierarchy to “All-Projects”. The first project in the hierarchy chain that sets
-link:#submit_requirement_can_override_in_child_projects[canOverrideInChildProjects]
-to false prevents all descendant projects from overriding it.
-
-If a project disallows a submit requirement from being overridden in child
-projects, all definitions of this submit requirement in descendant projects are
-ignored.
-
-To remove a submit requirement in a child project, administrators can redefine
-the requirement with the same name in the child project and set the
-link:#submit_requirement_applicable_if[applicableIf] expression to `is:false`.
-Since the link:#submit_requirement_submittable_if[submittableIf] field is
-mandatory, administrators need to provide it in the child project but can set it
-to anything, for example `is:false` but it will have no effect anyway.
-
-
-[[trigger-votes]]
-== Trigger Votes
-
-Trigger votes are label votes that are not associated with any submit
-requirement expressions. Trigger votes are displayed in a separate section in
-the change page. For more about configuring labels, see the
-link:config-labels.html[config labels] documentation.
-
-
 [[examples]]
 == Examples
 
@@ -359,46 +614,6 @@
   submittableIf = hasfooter:\"Bug\"
 ----
 
-[[test-submit-requirements]]
-== Testing Submit Requirements
-
-The link:rest-api-changes.html#check-submit-requirement[Check Submit Requirement]
-change endpoint can be used to test submit requirements on any change. Users
-are encouraged to test submit requirements before adding them to the project
-to ensure they work as intended.
-
-.Request
-----
-  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/check.submit_requirement HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-
-    {
-      "name": "Code-Review",
-      "submittability_expression": "label:Code-Review=+2"
-    }
-----
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  {
-    "name": "Code-Review",
-    "status": "SATISFIED",
-    "submittability_expression_result": {
-      "expression": "label:Code-Review=+2",
-      "fulfilled": true,
-      "passingAtoms": [
-        "label:Code-Review=+2"
-      ]
-    },
-    "is_legacy": false
-  }
-----
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 53d9e6b..4c224b5 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -393,6 +393,34 @@
   bazelisk test //plugins/replication/...
 ----
 
+[[known-issues]]
+=== Known Issues
+
+[[byte-buddy-not-initialized-or-unavailable]]
+==== The Byte Buddy agent is not initialized or unavailable
+
+If running tests that make use of mocks fail with the exception below, set the
+`sandbox_tmpfs_path` flag for running tests in `.bazelrc` as described in this
+link:https://github.com/mockito/mockito/issues/1879#issuecomment-922459131[
+issue], e.g. add this line: `test --sandbox_tmpfs_path=/tmp`
+
+.Exception:
+----
+...
+Caused by: org.mockito.exceptions.base.MockitoInitializationException:
+Could not initialize inline Byte Buddy mock maker.
+
+It appears as if your JDK does not supply a working agent attachment mechanism.
+...
+Caused by: java.lang.IllegalStateException: The Byte Buddy agent is not initialized or unavailable
+at net.bytebuddy.agent.ByteBuddyAgent.getInstrumentation(ByteBuddyAgent.java:230)
+at net.bytebuddy.agent.ByteBuddyAgent.install(ByteBuddyAgent.java:617)
+at net.bytebuddy.agent.ByteBuddyAgent.install(ByteBuddyAgent.java:568)
+at net.bytebuddy.agent.ByteBuddyAgent.install(ByteBuddyAgent.java:545)
+at org.mockito.internal.creation.bytebuddy.InlineDelegateByteBuddyMockMaker.<clinit>(InlineDelegateByteBuddyMockMaker.java:115)
+... 47 more
+----
+
 [[debugging-tests]]
 == Debugging Unit Tests
 In some cases it may be necessary to debug a test while running it in bazel. For example, when we
@@ -664,26 +692,16 @@
     --disk-size=200
 ```
 
-Due to outdated Git version in official RBE docker images, a custom RBE docker
-image must be used. To build custom docker imager, change to the directory
-`tools/platforms` and build and publish custom RBE docker image.
+Note, that we are using Ubuntu2204 docker image from bazel project:
 
-To build the custom RBE docker image, run:
 
 ```
-docker build -t gcr.io/api-project-164060093628/rbe-ubuntu18-04 .
+docker pull gcr.io/bazel-public/ubuntu2204-java17@sha256:ffe37746a34537d8e73cef5a20ccd3a4e3ec7af3e7410cba87387ba97c0e520f
 ```
 
-To publish the custom RBE docker image, run:
-
-```
-docker push gcr.io/api-project-164060093628/rbe-ubuntu18-04
-[...]
-latest: digest: sha256:de5186d4313630a6111f9a2449b72563d0bc59ec9fb60956f063b69a38a76834 size: 1584
-```
-
-Re-build rbe_autoconfig project conduct a new release and switch to using it
-in `WORKSPACE` file.
+Re-build rbe_autoconfig project, conduct a new release and switch to using it
+in `WORKSPACE` file. For more details see this
+link:https://github.com/davido/rbe_autoconfig[repository,role=external,window=_blank]
 
 Note, to authenticate to the gcr.io registry, the following command must be
 used:
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt
index 1229faf..07e3a11 100644
--- a/Documentation/dev-community.txt
+++ b/Documentation/dev-community.txt
@@ -45,7 +45,7 @@
 * link:dev-readme.html[Developer Setup]
 * link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui[TypeScript Frontend Developer Setup]
 * link:dev-crafting-changes.html[Crafting Changes]
-* link:dev-starter-projects.html[Starter Projects]
+* link:dev-contribution-opportunities.html[Contribution Opportunities (Help Wanted)]
 
 [[plugin-development]]
 == Plugin Development
diff --git a/Documentation/dev-contribution-opportunities.txt b/Documentation/dev-contribution-opportunities.txt
new file mode 100644
index 0000000..23f916e
--- /dev/null
+++ b/Documentation/dev-contribution-opportunities.txt
@@ -0,0 +1,45 @@
+:linkattrs:
+= Gerrit Code Review - Contribution Opportunities
+
+If you are eager to contribute to Gerrit, but you don't know where to
+start here are some opportunities to contribute.
+
+[[help-wanted]]
+== Help Wanted
+
+The link:https://issues.gerritcodereview.com/hotlists/5395287[HelpWanted,role=external,window=_blank]
+hotlist in the issue tracker lists issues that need help from the
+community and where any contribution is very welcome.
+
+If you are interested in any of the projects and you want to try
+implementing it, just assign the corresponding issue to yourself and
+reach out on the
+link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list,role=external,window=_blank]
+if you have questions or need help.
+
+[[creating-help-wanted-issue]]
+=== Creating Help Wanted issues
+
+Issues on the link:https://issues.gerritcodereview.com/hotlists/5395287[HelpWanted,role=external,window=_blank]
+hotlist should be phrased as user stories and be well-scoped so that they can
+be easily picked up by new contributors:
+
+* The issue title should name the feature and be prefixed with a t-shirt size
+  in square brackets to indicate the expected effort.
+* The issue description should use Markdown and have the following sections:
+** *User Story*: Explain the use case that is being addressed and what's the
+   value of the feature.
+** *What*: Describe what should be done.
+** *Background*: Any useful background information, including ideas how the
+   feature can be done.
+** *Pointers*: Useful links to documentation and code
+
+Note, only link:dev-roles.html#maintainer[maintainers] and
+link:dev-roles.html#contributor[trusted contributors] can add issues to the `HelpWanted` hotlist.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 42edc1f..bcc96b4 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2248,6 +2248,52 @@
 e.g. a plugin can provide a list of servers on which the change was
 deployed.
 
+Plugins can filter the branches and tags that are inlcuded by implementing
+`com.google.gerrit.server.change.FilterIncludedIn`.
+
+[source, java]
+----
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.change.FilterIncludedIn;
+import java.util.function.Predicate;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+public class MyFilter implements FilterIncludedIn {
+  @Override
+  public Predicate<String> getBranchFilter(Project.NameKey project, RevCommit commit) {
+    if (project.get() != "myproject") {
+      return branch -> true;
+    }
+    return branch -> !branch.startsWith("feature/");
+  }
+
+  @Override
+  public Predicate<String> getTagFilter(Project.NameKey project, RevCommit commit) {
+    if (project.get() != "myproject") {
+      return tag -> true;
+    }
+    return tag -> tag.startsWith("v");
+  }
+}
+----
+
+And register your class:
+
+[source, java]
+----
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.change.FilterIncludedIn;
+import com.google.inject.AbstractModule;
+
+public class MyPluginModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), FilterIncludedIn.class).to(MyFilter.class);
+  }
+}
+----
+
 [[change-report-formatting]]
 == Change Report Formatting
 
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index 8b7ba4b..c2a1f86 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -208,19 +208,14 @@
 
 To report a security vulnerability file a
 link:https://issues.gerritcodereview.com/issues/new?component=1371046[
-security issue,role=external,window=_blank] in the Gerrit issue tracker. The visibility of issues that are
-created with the `Security Issue` template is automatically restricted to
-Gerrit maintainers and a few long-term contributors. This means as a reporter
-you may not be able to see the issue once it is created. Security issues are
-created on the `ESC` component so that they will be discussed at the next
-meeting of the link:#steering-committee[Engineering Steering Committee] which
-takes place biweekly.
+security issue,role=external,window=_blank] in the Gerrit issue tracker. Issues
+in the `Gerrit Code Review > Security` component are restricted to Gerrit
+maintainers and a few long-term contributors. The reporter becomes a
+collaborator on the issue and hence can see it as well. Security issues are
+triaged by the link:#steering-committee[Engineering Steering Committee].
 
-If an existing issue is found to be a security vulnerability it should be
-turned into a security issue by:
-
-. Setting the component to `ESC`
-. Adding the labels `Security` and `NonPublic`
+If an existing issue is found to be a security vulnerability it should be moved
+to `Gerrit Code Review > Security` component (component ID: 1371046).
 
 In case of doubt, or if an issue cannot wait until the next ESC meeting,
 contact the link:#steering-committee[Engineering Steering Committee] directly
@@ -370,6 +365,12 @@
 link:https://gerrit-review.googlesource.com/q/label:%2522Library-Compliance%253Dneed%2522+-ownerin:google-gerrit-team+status:open+project:gerrit+-age:4week+-is:wip+-is:private+label:Code-Review%252B2[change query]
 to find changes that require a `Library-Compliance` approval.
 
+To get the attention of a Googler for dependency updates file separate issues
+(use type "Task") for each dependency update on the
+link:https://issues.gerritcodereview.com/issues/new?component=1371020&template=1834212["Hosting > googlesource" component].
+Then it will show up in Google's triage queue and the current person who is on duty
+should look into this.
+
 Gerrit's library dependencies should only be upgraded if the new version contains
 something we need in Gerrit. This includes new features, API changes as well as bug
 or security fixes.
diff --git a/Documentation/dev-starter-projects.txt b/Documentation/dev-starter-projects.txt
deleted file mode 100644
index 92de84d..0000000
--- a/Documentation/dev-starter-projects.txt
+++ /dev/null
@@ -1,15 +0,0 @@
-:linkattrs:
-= Gerrit Code Review - Starter Projects
-
-We have created a
-link:https://issues.gerritcodereview.com/hotlists/5052926[StarterProject,role=external,window=_blank]
-hotlist in the issue tracker and try to assign easy hack projects to it. If in
-doubt, do not hesitate to ask on the developer
-link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list,role=external,window=_blank].
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/images/generated-suggested-edit-added.png b/Documentation/images/generated-suggested-edit-added.png
new file mode 100644
index 0000000..37300c3
--- /dev/null
+++ b/Documentation/images/generated-suggested-edit-added.png
Binary files differ
diff --git a/Documentation/images/generated-suggested-edit-preview.png b/Documentation/images/generated-suggested-edit-preview.png
new file mode 100644
index 0000000..9ca82f1
--- /dev/null
+++ b/Documentation/images/generated-suggested-edit-preview.png
Binary files differ
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 97b58af..15f27db 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -44,7 +44,7 @@
 To see the access rights of your project
 
 - go to the Gerrit Web UI
-- click on the `Projects` > `List` menu entry
+- click on the `BROWSE` > `Repositories` menu entry
 - find your project in the project list and click on it
 - click on the `Access` menu entry
 
@@ -150,7 +150,7 @@
 group backends.
 
 The Gerrit internal groups can be seen in the Gerrit Web UI by clicking
-on the `Groups` > `List` menu entry. By clicking on a group you can
+on the `BROWSE` > `Groups` menu entry. By clicking on a group you can
 edit the group members (`Members` tab) and the group options
 (`General` tab).
 
@@ -168,8 +168,9 @@
 `Make group visible to all registered users.`, which defines whether
 non-members can see who is member of the group.
 
-New internal Gerrit groups can be created under `Groups` >
-`Create New Group`. This menu is only available if you have the global
+New internal Gerrit groups can be created under `BROWSE` > `Groups`
+and then clicking on the `CREATE NEW` button in the upper right corner.
+The `CREATE NEW` button is only available if you have the global
 capability link:access-control.html#capability_createGroup[Create Group]
 assigned.
 
@@ -238,7 +239,7 @@
 To see the options of your project:
 
 . Go to the Gerrit Web UI.
-. Click on the `Projects` > `List` menu entry.
+. Click on the `BROWSE` > `Repositories` menu entry.
 . Find your project in the project list and click it.
 . Click the `General` menu entry.
 
@@ -431,7 +432,7 @@
 == Branch Administration
 
 As project owner you can administrate the branches of your project in
-the Gerrit Web UI under `Projects` > `List` > <your project> >
+the Gerrit Web UI under `BROWSE` > `Repositories` > <your project> >
 `Branches`. In the Web UI link:project-configuration.html#branch-creation[
 branch creation] is allowed if you have
 link:access-control.html#category_create[Create Reference] access right and
@@ -450,7 +451,7 @@
 
 With Gerrit individual users control their own email subscriptions. By
 editing the link:user-notify.html#user[watched projects] in the Web UI
-under `Settings` > `Watched Projects` users can decide which events to
+under `Settings` > `Notifications` users can decide which events to
 be informed about by email. The change notifications can be filtered by
 link:user-search.html[change search expressions].
 
@@ -476,8 +477,8 @@
 link:user-dashboards.html#project-dashboards[project level]. This way
 you can define a view of the changes that are relevant for your
 project and share this dashboard with all users. The project dashboards
-can be seen in the Web UI under `Projects` > `List` > <your project> >
-`Dashboards`.
+can be seen in the Web UI under `BROWSE` > `Repositories` > <your project>
+> `Dashboards`.
 
 [[issue-tracker-integration]]
 == Issue Tracker Integration
@@ -509,7 +510,7 @@
 to Gerrit changes to the issues in the issue tracker system or to
 automatically close an issue if the corresponding change is merged.
 If installed, project owners may enable/disable the issue tracker
-integration from the Gerrit Web UI under `Projects` > `Lists` >
+integration from the Gerrit Web UI under `BROWSE` > `Repositories` >
 <your project> > `General`.
 
 [[comment-links]]
@@ -554,7 +555,7 @@
 With the link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers[
 reviewers,role=external,window=_blank] plugin it is possible to configure default reviewers who
 will be automatically added to each change. The default reviewers can
-be configured in the Gerrit Web UI under `Projects` > `List` >
+be configured in the Gerrit Web UI under `BROWSE` > `Repositories` >
 <your project> > `General` in the `reviewers Plugin` section.
 
 The link:https://gerrit-review.googlesource.com/admin/repos/plugins/reviewers-by-blame[
@@ -565,7 +566,7 @@
 touched by the change, since these users should be familiar with the
 code and can most likely review the change. How many reviewers the
 plugin will add to a change at most can be configured in the Gerrit
-Web UI under `Projects` > `List` > <your project> > `General` in the
+Web UI under `BROWSE` > `Repositories` > <your project> > `General` in the
 `reviewers-by-blame Plugin` section.
 
 [[download-commands]]
@@ -650,9 +651,10 @@
 [[project-creation]]
 === Project Creation
 
-New projects can be created in the Gerrit Web UI under `Projects` >
-`Create Project`. The `Create Project` menu entry is only available if
-you have the link:access-control.html#capability_createProject[
+New projects can be created in the Gerrit Web UI under `BROWSE` >
+`Repositories` and then clicking on the `CREATE NEW` button in the
+upper right corner. The `CREATE NEW` button is only available if you
+have the link:access-control.html#capability_createProject[
 Create Project] global capability assigned.
 
 Projects can also be created via REST or SSH as described in the
@@ -736,7 +738,7 @@
 
 If the link:https://gerrit-review.googlesource.com/admin/repos/plugins/delete-project[
 delete-project,role=external,window=_blank] plugin is installed, projects can be deleted from the
-Gerrit Web UI under `Projects` > `List` > <project> > `General` by
+Gerrit Web UI under `BROWSE` > `Repositories` > <project> > `General` by
 clicking on the `Delete` command under `Project Commands`. The `Delete`
 command is only available if you have the `Delete Projects` global
 capability assigned, or if you own the project and you have the
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 7825e050..108022a9 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -28,15 +28,10 @@
 Still there are some client-side tools for Gerrit, which can be used
 optionally:
 
-* link:http://eclipse.org/mylyn/[Mylyn Gerrit Connector,role=external,window=_blank]: Gerrit
-  integration with Mylyn
 * link:https://github.com/uwolfer/gerrit-intellij-plugin[Gerrit
   IntelliJ Plugin,role=external,window=_blank]: Gerrit integration with the
   link:http://www.jetbrains.com/idea/[IntelliJ Platform,role=external,window=_blank]
-* link:https://play.google.com/store/apps/details?id=com.jbirdvegas.mgerrit[
-  mGerrit,role=external,window=_blank]: Android client for Gerrit
-* link:https://github.com/stackforge/gertty[Gertty,role=external,window=_blank]: Console-based
-  interface for Gerrit
+* link:https://opendev.org/ttygroup/gertty[gertty]: Console-based interface for Gerrit
 
 [[clone]]
 == Clone Gerrit Project
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 6c3c459..7e3fa9a 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -77,7 +77,8 @@
 * jetty:util-ajax
 * log:log4j
 * lucene:lucene-analyzers-common
-* lucene:lucene-core-and-backward-codecs-merged
+* lucene:lucene-backward-codecs
+* lucene:lucene-core
 * lucene:lucene-misc
 * lucene:lucene-queryparser
 * mime4j:core
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index df0cc42b..4302a35 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -430,6 +430,13 @@
 * `permissions/ref_filter/skip_filter_count`: Rate of ref filter operations
   where we skip full evaluation because the user can read all refs
 
+=== Validation
+
+* `validation/file_count`: Track number of files per change during commit
+  validation, if it exceeds the FILE_COUNT_WARNING_THRESHOLD threshold.
+** `file_count`: number of files in the patchset
+** `host_repo`: host and repository of the change in the format 'host/repo'
+
 === Reviewer Suggestion
 
 * `reviewer_suggestion/query_accounts`: Latency for querying accounts for
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 9ecef3f..d0c4553 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -377,7 +377,7 @@
   "Out Of Office"
 ----
 
-If the name was deleted the response is "`204 No Content`".
+If the status was deleted the response is "`204 No Content`".
 
 [[get-username]]
 === Get Username
@@ -449,6 +449,8 @@
 
 As response the new display name is returned.
 
+If the Display Name was deleted the response is "`204 No Content`".
+
 [[get-active]]
 === Get Active
 --
@@ -1314,6 +1316,7 @@
     "publish_comments_on_push": true,
     "work_in_progress_by_default": true,
     "allow_browser_notifications": true,
+    "allow_suggest_code_while_commenting": true,
     "diff_page_sidebar": "plugin-foo",
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
@@ -1368,6 +1371,7 @@
     "disable_keyboard_shortcuts": true,
     "disable_token_highlighting": true,
     "allow_browser_notifications": false,
+    "allow_suggest_code_while_commenting": false,
     "diff_page_sidebar": "NONE",
     "diff_view": "SIDE_BY_SIDE",
     "mute_common_path_prefixes": true,
@@ -2716,6 +2720,9 @@
 inline edit feature.
 |`allow_browser_notifications`  |not set if `false`|
 Whether to prompt user to enable browser notification in browser.
+|`allow_suggest_code_while_commenting`  |not set if `false`|
+Whether to receive suggested code while writing comments. This feature needs
+a plugin implementation.
 |`diff_page_sidebar`            |optional|
 String indicating which sidebar should be open on the diff page. Set to "NONE"
 if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
@@ -2791,6 +2798,9 @@
 inline edit feature.
 |`allow_browser_notifications`  |not set if `false`|
 Whether to prompt user to enable browser notification in browser.
+|`allow_suggest_code_while_commenting`  |not set if `false`|
+Whether to receive suggested code while writing comments. This feature needs
+a plugin implementation.
 |`diff_page_sidebar`            |optional|
 String indicating which sidebar should be open on the diff page. Set to "NONE"
 if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index df5566f..ec02cdd 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -63,7 +63,8 @@
     "_number": 4711,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -126,6 +127,7 @@
       "owner": {
         "name": "John Doe"
       },
+      "current_revision_number": 2
     },
     {
       "id": "demo~master~I09c8041b5867d5b33170316e2abc34b79bbb8501",
@@ -143,6 +145,7 @@
       "owner": {
         "name": "John Doe"
       },
+      "current_revision_number": 2,
       "_more_changes": true
     }
   ]
@@ -214,7 +217,8 @@
         "labels": {
           "Verified": {},
           "Code-Review": {}
-        }
+        },
+        "current_revision_number": 2
       }
     ],
     [],
@@ -437,6 +441,7 @@
       "owner": {
         "name": "Shawn Pearce"
       },
+      "current_revision_number": 1,
       "current_revision": "184ebe53805e102605d11f6b143486d15c23a09c",
       "revisions": {
         "184ebe53805e102605d11f6b143486d15c23a09c": {
@@ -598,7 +603,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -680,7 +686,8 @@
       "_number": 3965,
       "owner": {
         "name": "John Doe"
-      }
+      },
+      "current_revision_number": 2
     },
     "new_change_info": {
       "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
@@ -709,7 +716,8 @@
       "_number": 3965,
       "owner": {
         "name": "John Doe"
-      }
+      },
+      "current_revision_number": 2
     },
   }
 ----
@@ -978,7 +986,8 @@
         "message": "Patch Set 1:\n\nThis is the second message.\n\nWith a line break.",
         "_revision_number": 1
       }
-    ]
+    ],
+    "current_revision_number": 2
   }
 ----
 
@@ -1040,10 +1049,44 @@
     "owner": {
       "_account_id": 1000000
     },
+    "current_revision_number": 1,
     "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822"
   }
 ----
 
+[[get-message]]
+=== Get Commit Message
+--
+'GET /changes/link:#change-id[\{change-id\}]/message'
+--
+
+Returns the commit message of the change (from the current patch set).
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/message HTTP/1.0
+----
+
+The commit message is returned as a link:#commit-message-info[
+CommitMessageInfo] entity.
+
+Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "subject": "Add feature X",
+    "full_message": "Add Feature X\n\nFeature X helps with foo.\n\nBug: 123\nChange-Id: I10394472cbd17dd12454f229e4f6de00b143a444\n",
+    "footers": {
+      "Bug": "123",
+      "Change-Id": "I10394472cbd17dd12454f229e4f6de00b143a444"
+    }
+  }
+----
+
 [[set-message]]
 === Set Commit Message
 --
@@ -1235,7 +1278,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -1306,7 +1350,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -1331,6 +1376,9 @@
 
 Rebases a change.
 
+For merge commits always the first parent is rebased. This means the new base becomes the first
+parent of the rebased merge commit while the second parent stays intact.
+
 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.
@@ -1375,6 +1423,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 2,
     "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822",
     "revisions": {
       "27cc4558b5a3d3387dd11ee2df7a117e7e581822": {
@@ -1525,6 +1574,7 @@
         "owner": {
           "_account_id": 1000000
         },
+        "current_revision_number": 2,
         "current_revision": "c3b2ba222d42a56e05c90f88d4509a124620517d",
         "revisions": {
           "c3b2ba222d42a56e05c90f88d4509a124620517d": {
@@ -1602,6 +1652,7 @@
         "owner": {
           "_account_id": 1000000
         },
+        "current_revision_number": 2,
         "current_revision": "77eb17a9501a5c21963bc6af56085e60f281acbb",
         "revisions": {
           "77eb17a9501a5c21963bc6af56085e60f281acbb": {
@@ -1726,7 +1777,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -1805,7 +1857,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -1899,7 +1952,8 @@
         "_number": 3965,
         "owner": {
           "name": "John Doe"
-        }
+        },
+        "current_revision_number": 2
       },
       {
         "id": "anyProject~master~1eee2c9d8f352483781e772f35dc586a69ff5646",
@@ -1917,7 +1971,8 @@
         "_number": 3966,
         "owner": {
           "name": "Jane Doe"
-        }
+        },
+        "current_revision_number": 2
       }
     ]
 ----
@@ -2002,7 +2057,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -2155,6 +2211,7 @@
           }
         ]
       },
+      "current_revision_number": 1,
       "current_revision": "9adb9f4c7b40eeee0646e235de818d09164d7379",
       "revisions": {
         "9adb9f4c7b40eeee0646e235de818d09164d7379": {
@@ -2252,6 +2309,7 @@
           }
         ]
       },
+      "current_revision_number": 1,
       "current_revision": "1bd7c12a38854a2c6de426feec28800623f492c4",
       "revisions": {
         "1bd7c12a38854a2c6de426feec28800623f492c4": {
@@ -2361,6 +2419,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 1,
     "current_revision": "184ebe53805e102605d11f6b143486d15c23a09c"
   }
 ----
@@ -2477,7 +2536,7 @@
 ----
 
 [[list-change-robot-comments]]
-=== List Change Robot Comments
+=== List Change Robot Comments (deprecated)
 --
 'GET /changes/link:#change-id[\{change-id\}]/robotcomments'
 --
@@ -2625,6 +2684,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 2,
     "problems": [
       {
         "message": "Current patch set 1 not found"
@@ -2677,6 +2737,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 2,
     "problems": [
       {
         "message": "Current patch set 2 not found"
@@ -3383,6 +3444,36 @@
   HTTP/1.1 204 No Content
 ----
 
+[[put-change-edit-committer-author-identity]]
+=== Change author or committer identity in Change Edit
+--
+'PUT /changes/link:#change-id[\{change-id\}]/edit:identity'
+--
+
+Modify author or committer identity. The request body needs to include a
+link:#change-edit-identity-input[ChangeEditIdentityInput]
+entity. Either `name` or `email` must be provided. `type` must be either `AUTHOR` or `COMMITTER`.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit:identity HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "John Doe",
+    "email": "john.doe@example.com",
+    "type": "COMMITTER"
+  }
+----
+
+If a change edit doesn't exist for this change yet, it is created. As
+response "`204 No Content`" is returned.
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[get-edit-file]]
 === Retrieve file content from Change Edit
 --
@@ -3642,10 +3733,22 @@
 This REST endpoint only suggests accounts that
 
 * are active
+
 * can see the change
-* are visible to the calling user
+
+* are visible to the calling user:
++
+Whether an account is visible to the calling user depends on the
+link:config-gerrit.html#accounts.visibility[accounts.visibility] setting of the
+server. Which account visibility is configured can be checked by opening
+`https://<HOST>/config/server/info?pp=1` in a browser (see field `accounts` >
+link:rest-api-config.html#accounts-config-info[visibility] in the returned
+JSON).
+
 * are not already reviewer on the change
+
 * don't own the change
+
 * are not service users (unless
   link:config.html#suggest.skipServiceUsers[skipServiceUsers] is set to `false`)
 
@@ -4349,6 +4452,7 @@
         }
       ]
     },
+    "current_revision_number": 2,
     "current_revision": "674ac754f91e64a0efb8087e59a176484bd534d1",
     "revisions": {
       "674ac754f91e64a0efb8087e59a176484bd534d1": {
@@ -4740,6 +4844,7 @@
     "owner": {
       "name": "John Doe"
     },
+    "current_revision_number": 2,
     "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822",
     "revisions": {
       "27cc4558b5a3d3387dd11ee2df7a117e7e581822": {
@@ -4840,7 +4945,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -5392,7 +5498,7 @@
 ----
 
 [[list-robot-comments]]
-=== List Robot Comments
+=== List Robot Comments (deprecated)
 --
 'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/robotcomments/'
 --
@@ -5449,7 +5555,7 @@
 ----
 
 [[get-robot-comment]]
-=== Get Robot Comment
+=== Get Robot Comment (deprecated)
 --
 'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/robotcomments/link:#comment-id[\{comment-id\}]'
 --
@@ -5689,7 +5795,7 @@
   Content-Disposition: attachment
   Content-Type: text/plain; charset=UTF-8
 
-  The existing change edit could not be merged with another tree.
+  Rebasing change edit onto another patchset results in merge conflicts. Download the edit patchset and rebase manually to preserve changes.
 ----
 
 [[apply-provided-fix]]
@@ -6413,7 +6519,8 @@
     "_number": 3965,
     "owner": {
       "name": "John Doe"
-    }
+    },
+    "current_revision_number": 2
   }
 ----
 
@@ -7021,6 +7128,19 @@
 |`message`     ||New commit message.
 |===========================
 
+[[change-edit-identity-input]]
+=== ChangeEditIdentityInput
+The `ChangeEditIdentityInput` entity contains information for changing
+the author or committer identity within a change edit.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name    ||Description
+|`name`     |optional|The name of the author/committer. If not specified, the existing name will be used.
+|`email`    |optional|The email of the author/committer. If not specified, the existing email will be used.
+|`type`     ||Type of the identity being edited. Must be either `AUTHOR` or `COMMITTER`.
+|===========================
+
 [[change-info]]
 === ChangeInfo
 The `ChangeInfo` entity contains information about a change.
@@ -7144,11 +7264,9 @@
 |`reviewers`          |optional|
 The reviewers as a map that maps a reviewer state to a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities.
-Possible reviewer states are `REVIEWER`, `CC` and `REMOVED`. +
+Possible reviewer states are `REVIEWER`, `CC`. +
 `REVIEWER`: Users with at least one non-zero vote on the change. +
 `CC`: Users that were added to the change, but have not voted. +
-`REMOVED`: Users that were previously reviewers on the change, but have
-been removed. +
 Only set if link:#labels[labels] or
 link:#detailed-labels[detailed labels] are requested.
 |`pending_reviewers`  |optional|
@@ -7158,6 +7276,11 @@
 notified about being added to or removed from the change. +
 Only set if link:#labels[labels] or
 link:#detailed-labels[detailed labels] are requested.
+Possible states are `REVIEWER`, `CC` and `REMOVED`. +
+`REVIEWER`: Users with at least one non-zero vote on the change. +
+`CC`: Users that were added to the change, but have not voted. +
+`REMOVED`: Users that were previously reviewers on the change, but have
+been removed.
 |`reviewer_updates`|optional|
 Updates to reviewers set for the change as
 link:#review-update-info[ReviewerUpdateInfo] entities.
@@ -7166,6 +7289,8 @@
 Messages associated with the change as a list of
 link:#change-message-info[ChangeMessageInfo] entities. +
 Only set if link:#messages[messages] are requested.
+|`current_revision_number`||The number of the current patch set of this
+change. +
 |`current_revision`   |optional|
 The commit ID of the current patch set of this change. +
 Only set if link:#current-revision[the current revision] is requested
@@ -7465,7 +7590,8 @@
 Mime type of the file where the comment is written. Available only if the
 "enable-context" parameter (see link:#list-change-comments[List Change Comments])
 is set.
-
+|`fix_suggestions`|optional|Suggested fixes for this comment as a list of
+<<fix-suggestion-info,FixSuggestionInfo>> entities.
 |===========================
 
 [[comment-input]]
@@ -7513,6 +7639,8 @@
 Whether or not the comment must be addressed by the user. This value will
 default to false if the comment is an orphan, or the value of the `in_reply_to`
 comment if it is supplied.
+|`fix_suggestions`|optional|Suggested fixes for this comment as a list of
+<<fix-suggestion-info,FixSuggestionInfo>> entities.
 |===========================
 
 [[comment-range]]
@@ -7579,6 +7707,21 @@
 link:#web-link-info[WebLinkInfo] entities.
 |===========================
 
+[[commit-message-info]]
+=== CommitMessageInfo
+The `CommitMessageInfo` entity contains information about the commit
+message of a change.
+
+[options="header",cols="1,6"]
+|============================
+|Field Name     |Description
+|`subject`      |The subject of the change (first line of the commit
+message).
+|`full_message` |Full commit message of the change.
+|`footers`      |The footers from the commit message as a map of
+key-value pairs.
+|============================
+
 [[commit-message-input]]
 === CommitMessageInput
 The `CommitMessageInput` entity contains information for changing
@@ -7588,6 +7731,9 @@
 |=============================
 |Field Name      ||Description
 |`message`       ||New commit message.
+|`committer_email`|optional|
+New message is committed using this email address. Only the
+registered emails of the calling user are considered valid.
 |`notify`        |optional|
 Notify handling that defines to whom email notifications should be sent
 after the commit message was updated. +
@@ -8521,8 +8667,10 @@
 The account which modified state of the reviewer in question as
 link:rest-api-accounts.html#account-info[AccountInfo] entity.
 |`reviewer`|
-The reviewer account added or removed from the change as an
-link:rest-api-accounts.html#account-info[AccountInfo] entity.
+The reviewer added or removed from the change as an
+link:rest-api-accounts.html#account-info[AccountInfo] entity. For
+reviewers by email the `AccountInfo` doesn't contain an account ID but
+only the email and optionally a name.
 |`state`|
 The reviewer state, one of `REVIEWER`, `CC` or `REMOVED`.
 |===========================
@@ -8551,7 +8699,7 @@
 |`comments`                             |optional|
 The comments that should be added as a map that maps a file path to a
 list of link:#comment-input[CommentInput] entities.
-|`robot_comments`                       |optional|
+|`robot_comments`                       |optional, deprecated|
 The robot comments that should be added as a map that maps a file path
 to a list of link:#robot-comment-input[RobotCommentInput] entities.
 |`drafts`                               |optional|
@@ -8630,9 +8778,8 @@
 action. Not set if false.
 |`error`                  |optional|
 Error message for non-200 responses.
-|`change_info`            |optional|
-Post-update change information. Only set if `response_format_options` were
-set.
+|`change_info`            ||
+Post-update change information.
 |============================
 
 [[reviewer-info]]
@@ -8801,7 +8948,7 @@
 |===========================
 
 [[robot-comment-info]]
-=== RobotCommentInfo
+=== RobotCommentInfo (deprecated)
 The `RobotCommentInfo` entity contains information about a robot inline
 comment.
 
@@ -8817,12 +8964,10 @@
 |`url`            |optional|URL to more information.
 |`properties`     |optional|Robot specific properties as map that maps arbitrary
 keys to values.
-|`fix_suggestions`|optional|Suggested fixes for this robot comment as a list of
-<<fix-suggestion-info,FixSuggestionInfo>> entities.
 |===========================
 
 [[robot-comment-input]]
-=== RobotCommentInput
+=== RobotCommentInput (deprecated)
 The `RobotCommentInput` entity contains information for creating an inline
 robot comment.
 
@@ -8852,8 +8997,6 @@
 |`url`            |optional|URL to more information.
 |`properties`     |optional|Robot specific properties as map that maps arbitrary
 keys to values.
-|`fix_suggestions`|optional|Suggested fixes for this robot comment as a list of
-<<fix-suggestion-info,FixSuggestionInfo>> entities.
 |===========================
 
 [[rule-input]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index ec1ac03..37121350 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -875,6 +875,49 @@
   }
 ----
 
+[[list-experiments]]
+=== List Experiments
+--
+'GET /config/server/experiments'
+--
+
+Lists the experiments that are available in the system.
+
+Requires the caller to have the link:access-control.html#capability_administrateServer[
+Administrate Server] global capability.
+
+As result a map of experiment names to link:#experiment-info[Experiment] entities is returned.
+
+The entries in the map are sorted by experiment name.
+
+.Request
+----
+  GET /config/server/experiments/ HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "GerritBackendFeature__allow_fix_suggestions_in_comments": {
+      "enabled": false
+    },
+    "GerritBackendFeature__attach_nonce_to_documentation": {
+      "enabled": true
+    }
+  }
+----
+
+It is possible to specify the following options:
+
+[[list-experiments-enabled-only]]
+--
+* `enabled-only`: If specified only enabled experiments are listed.
+--
+
 [[list-tasks]]
 === List Tasks
 --
@@ -931,7 +974,7 @@
       "state": "SLEEPING",
       "start_time": "2014-06-11 12:58:51.508000000",
       "delay": 3287966,
-      "command": "Log File Compressor"
+      "command": "Log File Manager"
     }
   ]
 ----
@@ -1420,6 +1463,325 @@
 When `delete_missing` is set to `true` changes to be reindexed which are missing in NoteDb
 will be deleted in the index.
 
+[[list-indexes]]
+=== List Indexes
+--
+'GET /config/server/indexes'
+--
+
+Lists the indexes used by Gerrit. It provides details about the index versions,
+which index version is used to search and which versions are written to.
+
+This endpoint requires the
+link:access-control.html#capability_maintainServer[Maintain Server]
+capability.
+
+.Request
+----
+  GET /config/server/indexes/ HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "accounts",
+      "versions": {
+        "13": {
+          "write": true,
+          "search": true
+        }
+      }
+    },
+    {
+      "name": "changes",
+      "versions": {
+        "83": {
+          "write": true,
+          "search": true
+        },
+        "84": {
+          "write": true,
+          "search": false
+        }
+      }
+    },
+    {
+      "name": "groups",
+      "versions": {
+        "10": {
+          "write": true,
+          "search": true
+        }
+      }
+    },
+    {
+      "name": "projects",
+      "versions": {
+        "8": {
+          "write": true,
+          "search": true
+        }
+      }
+    }
+  [
+----
+
+=== Get Index
+--
+'GET /config/server/indexes/changes'
+--
+
+Get an index used by Gerrit. It provides details about the index versions, which
+index version is used to search and which versions are written to.
+
+.Request
+----
+  'GET /config/server/indexes/changes'
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "changes",
+    "versions": {
+      "83": {
+        "write": true,
+        "search": true
+      },
+      "84": {
+        "write": true,
+        "search": false
+      }
+    }
+  }
+----
+
+=== List Index Versions
+--
+'GET /config/server/indexes/changes/versions'
+--
+
+Lists versions of an index used by Gerrit.
+
+.Request
+----
+  'GET /config/server/indexes/changes/versions'
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "83": {
+      "write": true,
+      "search": true
+    },
+    "84": {
+      "write": true,
+      "search": false
+    }
+  }
+----
+
+=== Get Index Version
+--
+'GET /config/server/indexes/changes/versions/85'
+--
+
+Get info about one version of an index used by Gerrit.
+
+.Request
+----
+  'GET /config/server/indexes/changes/versions/84'
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "write": true,
+    "search": false
+  }
+----
+
+[[snapshot-index]]
+=== Create Index Snapshot
+
+These endpoints allow Gerrit admins to create index snapshots.
+Created snapshots can be used as a backup of the index.
+
+It is possible to create a snapshot of all indexes, snapshot of one index or
+snapshot of one index version.
+
+The snapshots will be stored on the server at `$SITE/index/snapshots/$ID`.
+The `$ID` can be optionally provided in link:#snapshot-index-input[SnapshotIndex.Input]
+or will default to the current local time in ISO8601 format.
+
+Only snapshots of indexes that Gerrit currently writes to can be created.
+
+Note, that the creation of multiple snapshots, e.g. of different index
+versions, is not atomic. If a consistent state over multiple indexes is
+required, the server has to be put into read-only mode before creating
+the snapshot.
+
+==== Create Snapshot of All Indexes
+--
+'POST /config/server/snapshot.indexes HTTP/1.0'
+--
+
+.Request
+----
+  POST /config/server/snapshot.indexes HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "id": "snapshot-1"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "snapshot-1"
+  }
+----
+
+==== Create Snapshot of one Index
+--
+'POST /config/server/indexes/link:#index-name[\{index-name\}]/snapshot'
+--
+
+This creates a snapshot of all write index versions of the specified index.
+
+.Request
+----
+  POST /config/server/indexes/accounts/snapshot HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "id": "snapshot-1"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "snapshot-1"
+  }
+----
+
+==== Create Snapshot of one Index Version
+--
+'POST /config/server/indexes/link:#index-name[\{index-name\}]/versions/#index-version[\{index-version\}]/snapshot'
+--
+
+This creates a snapshot of one index version of the specified index.
+
+.Request
+----
+  POST /config/server/indexes/changes/versions/84/snapshot HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "id": "snapshot-1"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "snapshot-1"
+  }
+----
+
+=== Reindex an Index Version
+--
+'POST /config/server/indexes/link:#index-name[\{index-name\}]/versions/#index-version[\{index-version\}]/reindex'
+--
+
+This endpoint allows to trigger background reindexing of an index version.  It is
+also supported to specify whether to reuse existing up-to-date (non-stale) index
+documents and whether to notifyListeners or not.
+
+.Request
+----
+  POST /config/server/indexes/changes/versions/84/reindex HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "reuse": "true",
+    "notifyListeners": "false"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 202 Accepted
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+----
+
+[[experiment-endpoints]]
+== Experiment Endpoints
+
+[[get-experiment]]
+=== Get Experiment
+--
+'GET /config/server/experiments/link:#experiment-name[\{experiment-name\}]
+--
+
+Retrieves the details of the experiment with the given name.
+
+Requires the caller to have the link:access-control.html#capability_administrateServer[
+Administrate Server] global capability.
+
+.Request
+----
+  GET /config/server/experiments/mGerritBackendFeature__attach_nonce_to_documentation HTTP/1.0
+----
+
+As response an link:#experiment-info[Experiment] entity is returned that
+describes the experiment.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "enabled": true
+  }
+----
 
 [[ids]]
 == IDs
@@ -1434,10 +1796,21 @@
 Gerrit core caches can optionally be prefixed with "gerrit":
 "gerrit-<cache-name>".
 
+[[experiment-name]]
+=== \{experiment-name\}
+The name of the experiment.
+
 [[task-id]]
 === \{task-id\}
 The ID of the task (hex string).
 
+[[index-name]]
+=== \{index-name\}
+The name of the index. Can be any of: "accounts", "changes", "groups", "projects".
+
+[[index-version]]
+=== \{index-version\}
+The version of the index. This is an integer.
 
 [[json-entities]]
 == JSON Entities
@@ -1760,6 +2133,16 @@
 |`new_value`  |The new config value, picked up after reload.
 |======================
 
+[[experiment-info]]
+=== ExperimentInfo
+The `ExperimentInfo` entity contains information about an experiment.
+
+[options="header",cols="1,6"]
+|============================
+|Field Name |Description
+|`enabled`  |Whether the experiment is enabled.
+|============================
+
 [[download-info]]
 === DownloadInfo
 The `DownloadInfo` entity contains information about supported download
@@ -1787,6 +2170,9 @@
 |`url`               ||
 The URL of the download scheme, where '${project}' is used as
 placeholder for the project name.
+|`description`       |optional|
+An optional description of how the scheme works and maybe comparing
+it to other schemes, explaining the pros and cons of each option.
 |`is_auth_required`  |not set if `false`|
 Whether this download scheme requires authentication.
 |`is_auth_supported` |not set if `false`|
@@ -2067,6 +2453,19 @@
 requirements that are applicable for changes appearing in the dashboard.
 |=======================================
 
+[[snapshot-index-input]]
+=== SnapshotIndex.Input
+The `SnapshotIndex.Input` entity contains the parameters used to create an
+index snapshot.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name ||Description
+|`id` | optional |
+A string ID that will be used as the folder name containing the
+snapshots. Defaults to current timestamp.
+|=======================
+
 [[sshd-info]]
 === SshdInfo
 The `SshdInfo` entity contains information about Gerrit
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index fff9d0b..b0e1b49 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2467,6 +2467,100 @@
   ]
 ----
 
+DescendingOrder(d)::
+Sort the returned tags in descending order.
++
+.Request
+----
+  GET /projects/work%2Fmy-project/tags?d HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "ref": "refs/tags/v3.0",
+      "revision": "c628685b3c5a3614571ecb5c8fceb85db9112313",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Signed tag\n-----BEGIN PGP SIGNATURE-----\nVersion: GnuPG v1.4.11 (GNU/Linux)\n\niQEcBAABAgAGBQJUMlqYAAoJEPI2qVPgglptp7MH/j+KFcittFbxfSnZjUl8n5IZ\nveZo7wE+syjD9sUbMH4EGv0WYeVjphNTyViBof+stGTNkB0VQzLWI8+uWmOeiJ4a\nzj0LsbDOxodOEMI5tifo02L7r4Lzj++EbqtKv8IUq2kzYoQ2xjhKfFiGjeYOn008\n9PGnhNblEHZgCHguGR6GsfN8bfA2XNl9B5Ysl5ybX1kAVs/TuLZR4oDMZ/pW2S75\nhuyNnSgcgq7vl2gLGefuPs9lxkg5Fj3GZr7XPZk4pt/x1oiH7yXxV4UzrUwg2CC1\nfHrBlNbQ4uJNY8TFcwof52Z0cagM5Qb/ZSLglHbqEDGA68HPqbwf5z2hQyU2/U4\u003d\n\u003dZtUX\n-----END PGP SIGNATURE-----",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 09:02:16.000000000",
+        "tz": 540
+      }
+    },
+    {
+      "ref": "refs/tags/v2.0",
+      "revision": "1624f5af8ae89148d1a3730df8c290413e3dcf30"
+    },
+    {
+      "ref": "refs/tags/v1.0",
+      "revision": "49ce77fdcfd3398dc0dedbe016d1a425fd52d666",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Annotated tag",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 07:35:03.000000000",
+        "tz": 540
+      }
+    }
+  ]
+----
+
+SortBy(sortby)::
+Sort the returned tags by one of the supported sort options: ref (default), creation_time.
++
+.Request
+----
+  GET /projects/work%2Fmy-project/tags?sortby=creation_time HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "ref": "refs/tags/v1.0",
+      "revision": "49ce77fdcfd3398dc0dedbe016d1a425fd52d666",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Annotated tag",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 07:35:03.000000000",
+        "tz": 540
+      }
+    },
+    {
+      "ref": "refs/tags/v3.0",
+      "revision": "c628685b3c5a3614571ecb5c8fceb85db9112313",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Signed tag\n-----BEGIN PGP SIGNATURE-----\nVersion: GnuPG v1.4.11 (GNU/Linux)\n\niQEcBAABAgAGBQJUMlqYAAoJEPI2qVPgglptp7MH/j+KFcittFbxfSnZjUl8n5IZ\nveZo7wE+syjD9sUbMH4EGv0WYeVjphNTyViBof+stGTNkB0VQzLWI8+uWmOeiJ4a\nzj0LsbDOxodOEMI5tifo02L7r4Lzj++EbqtKv8IUq2kzYoQ2xjhKfFiGjeYOn008\n9PGnhNblEHZgCHguGR6GsfN8bfA2XNl9B5Ysl5ybX1kAVs/TuLZR4oDMZ/pW2S75\nhuyNnSgcgq7vl2gLGefuPs9lxkg5Fj3GZr7XPZk4pt/x1oiH7yXxV4UzrUwg2CC1\nfHrBlNbQ4uJNY8TFcwof52Z0cagM5Qb/ZSLglHbqEDGA68HPqbwf5z2hQyU2/U4\u003d\n\u003dZtUX\n-----END PGP SIGNATURE-----",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 09:02:16.000000000",
+        "tz": 540
+      }
+    },
+    {
+      "ref": "refs/tags/v2.0",
+      "revision": "1624f5af8ae89148d1a3730df8c290413e3dcf30"
+    }
+  ]
+----
+
 Substring(m)::
 Limit the results to those tags that match the specified substring.
 +
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 0744ded..1095002 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -848,6 +848,7 @@
 The special "owner" parameter corresponds to the change owner.  Matches
 all changes that have a +2 vote from the change owner.
 
+[[non_uploader]]
 `label:Code-Review=+2,user=non_uploader`::
 `label:Code-Review=ok,user=non_uploader`::
 `label:Code-Review=+2,non_uploader`::
diff --git a/Documentation/user-suggest-edits.txt b/Documentation/user-suggest-edits.txt
index fa49eeb..55181a2 100644
--- a/Documentation/user-suggest-edits.txt
+++ b/Documentation/user-suggest-edits.txt
@@ -37,6 +37,21 @@
 Alternatively, you can use the copy to clipboard button to copy a suggested
 edit to your clipboard and then you can paste it into your editor.
 
+== Generate Suggestion
+
+Following UI needs to be activated by a plugin that implements SuggestionsProvider. Gerrit is providing just UI.
+
+** When a user types a comment, Gerrit queries a plugin for a code snippet. When there is a snippet, the user can see a preview of snippet under comment.
+
+image::images/generated-suggested-edit-preview.png["Generate Suggested Edit", align="center", width=400]
+
+** A user needs to click on "ADD SUGGESTION TO COMMENT" button if they want to use this suggestion. Otherwise the suggestion is never used.
+
+image::images/generated-suggested-edit-added.png["Added Generated Suggested Edit", align="center", width=400]
+
+** By clicking on "ADD SUGGESTION TO COMMENT" button, the suggestion is added to end of comment. The user can then edit the suggestion, if needed.
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/MODULE.bazel b/MODULE.bazel
new file mode 100644
index 0000000..0b932b8
--- /dev/null
+++ b/MODULE.bazel
@@ -0,0 +1,2 @@
+# TODO(davido): Migrate all dependencies from WORKSPACE to MODULE.bazel
+# https://issues.gerritcodereview.com/issues/303819949
diff --git a/WORKSPACE b/WORKSPACE
index 8ce21d5..1c168c6 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -26,47 +26,22 @@
 
 load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
-load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "maven_jar")
 load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
 load("//tools:nongoogle.bzl", "declare_nongoogle_deps")
 load("//tools:deps.bzl", "CAFFEINE_VERS", "java_dependencies")
 
 http_archive(
-    name = "platforms",
-    sha256 = "3a561c99e7bdbe9173aa653fd579fe849f1d8d67395780ab4770b1f381431d51",
-    urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
-        "https://github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
-    ],
+    name = "rules_nodejs",
+    patch_args = ["-p1"],
+    patches = ["//tools:rules_nodejs-5.8.4-node_versions.bzl.patch"],
+    sha256 = "8fc8e300cb67b89ceebd5b8ba6896ff273c84f6099fc88d23f24e7102319d8fd",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.4/rules_nodejs-core-5.8.4.tar.gz"],
 )
 
 http_archive(
-    name = "rbe_jdk11",
-    sha256 = "dbcfd6f26589ef506b91fe03a12dc559ca9c84699e4cf6381150522287f0e6f6",
-    strip_prefix = "rbe_autoconfig-3.1.0",
-    urls = [
-        "https://gerrit-bazel.storage.googleapis.com/rbe_autoconfig/v3.1.0.tar.gz",
-        "https://github.com/davido/rbe_autoconfig/archive/v3.1.0.tar.gz",
-    ],
-)
-
-http_archive(
-    name = "com_google_protobuf",
-    sha256 = "3bd7828aa5af4b13b99c191e8b1e884ebfa9ad371b0ce264605d347f135d2568",
-    strip_prefix = "protobuf-3.19.4",
-    urls = [
-        "https://github.com/protocolbuffers/protobuf/archive/v3.19.4.tar.gz",
-    ],
-)
-
-load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps")
-
-protobuf_deps()
-
-http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "94070eff79305be05b7699207fbac5d2608054dd53e6109f7d00d923919ff45a",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.2/rules_nodejs-5.8.2.tar.gz"],
+    sha256 = "709cc0dcb51cf9028dd57c268066e5bc8f03a119ded410a13b5c3925d6e43c48",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.4/rules_nodejs-5.8.4.tar.gz"],
 )
 
 load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
@@ -99,9 +74,11 @@
     firefox = True,
 )
 
-register_toolchains("//tools:error_prone_warnings_toolchain_java11_definition")
+declare_nongoogle_deps()
 
-register_toolchains("//tools:error_prone_warnings_toolchain_java17_definition")
+load("//tools:defs.bzl", "gerrit_init")
+
+gerrit_init()
 
 # Java-Prettify external repository consumed from git submodule
 local_repository(
@@ -137,12 +114,10 @@
     ],
 )
 
-declare_nongoogle_deps()
-
 load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
 
 node_repositories(
-    node_version = "17.9.1",
+    node_version = "20.9.0",
     yarn_version = "1.22.19",
 )
 
diff --git a/contrib/bug-icon/1_bug_icon_16x16.ai b/contrib/bug-icon/1_bug_icon_16x16.ai
new file mode 100644
index 0000000..5872a27
--- /dev/null
+++ b/contrib/bug-icon/1_bug_icon_16x16.ai
Binary files differ
diff --git a/contrib/bug-icon/1_bug_icon_16x16.svg b/contrib/bug-icon/1_bug_icon_16x16.svg
new file mode 100644
index 0000000..ff4db74
--- /dev/null
+++ b/contrib/bug-icon/1_bug_icon_16x16.svg
@@ -0,0 +1,266 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   viewBox="0 0 21.333332 21.333332"
+   height="21.333332"
+   width="21.333332"
+   xml:space="preserve"
+   id="svg2"
+   version="1.1"><metadata
+     id="metadata8"><rdf:RDF><cc:Work
+         rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
+     id="defs6"><clipPath
+       id="clipPath18"
+       clipPathUnits="userSpaceOnUse"><path
+         id="path16"
+         d="M 0,0 H 16 V 16 H 0 Z" /></clipPath></defs><g
+     transform="matrix(1.3333333,0,0,-1.3333333,0,21.333333)"
+     id="g10"><g
+       id="g12"><g
+         clip-path="url(#clipPath18)"
+         id="g14"><g
+           transform="translate(4.022,5.7807)"
+           id="g20"><path
+             id="path22"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C -0.005,-0.002 -0.011,-0.003 -0.017,-0.002 L -0.755,0.109 C -0.771,0.111 -0.783,0.124 -0.784,0.14 l -0.047,0.484 h -0.263 c -0.019,0 -0.034,0.016 -0.034,0.035 0,0.019 0.015,0.034 0.034,0.034 H -0.8 c 0.018,0 0.033,-0.014 0.035,-0.032 L -0.718,0.173 -0.007,0.066 C 0.012,0.063 0.025,0.046 0.022,0.027 0.02,0.014 0.011,0.004 0,0" /></g><g
+           transform="translate(3.3183,4.6576)"
+           id="g24"><path
+             id="path26"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.002,-0.001 -0.004,-0.001 -0.007,-0.002 l -0.138,-0.023 c -0.018,-0.003 -0.036,0.009 -0.039,0.028 -0.004,0.019 0.009,0.037 0.028,0.04 l 0.115,0.019 0.11,0.55 c 0.003,0.014 0.014,0.025 0.028,0.027 l 0.57,0.097 C 0.686,0.74 0.704,0.727 0.707,0.708 0.71,0.69 0.698,0.672 0.679,0.669 L 0.132,0.575 0.021,0.025 C 0.019,0.014 0.011,0.004 0,0" /></g><g
+           transform="translate(3.9726,3.8581)"
+           id="g28"><path
+             id="path30"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.014,-0.007 -0.031,-0.004 -0.042,0.008 -0.012,0.014 -0.011,0.036 0.003,0.049 l 0.088,0.076 -0.192,0.526 c -0.005,0.014 -0.001,0.029 0.01,0.038 L 0.303,1.078 C 0.317,1.09 0.339,1.089 0.351,1.074 0.364,1.06 0.362,1.038 0.348,1.026 L -0.07,0.661 0.122,0.134 C 0.127,0.121 0.123,0.106 0.112,0.096 L 0.007,0.005 C 0.005,0.003 0.002,0.001 0,0" /></g><g
+           transform="translate(5.0103,6.1574)"
+           id="g32"><path
+             id="path34"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c 0.005,0.002 0.01,0.005 0.014,0.01 l 0.477,0.574 c 0.01,0.012 0.011,0.03 0.002,0.042 L 0.205,1.019 0.401,1.194 C 0.415,1.207 0.416,1.228 0.404,1.243 0.391,1.257 0.369,1.258 0.355,1.245 L 0.136,1.049 C 0.122,1.037 0.12,1.017 0.131,1.003 L 0.421,0.607 -0.039,0.054 C -0.051,0.04 -0.049,0.018 -0.034,0.006 -0.024,-0.003 -0.011,-0.004 0,0" /></g><g
+           transform="translate(6.3269,4.7555)"
+           id="g36"><path
+             id="path38"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c 0.015,0.004 0.026,0.018 0.026,0.034 0,0.019 -0.016,0.034 -0.035,0.034 L -0.125,0.066 -0.332,0.587 C -0.337,0.601 -0.35,0.609 -0.364,0.609 L -0.943,0.603 C -0.962,0.602 -0.977,0.587 -0.977,0.568 c 0,-0.019 0.016,-0.034 0.035,-0.034 L -0.387,0.54 -0.18,0.019 c 0.005,-0.013 0.018,-0.022 0.032,-0.022 l 0.14,0.002 c 0.003,0 0.005,0 0.008,0.001" /></g><g
+           transform="translate(4.0513,6.5954)"
+           id="g40"><path
+             id="path42"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C -0.008,-0.003 -0.017,-0.003 -0.025,0 L -0.328,0.12 C -0.342,0.125 -0.351,0.138 -0.35,0.152 l 0.004,0.237 c 0,0.019 0.016,0.034 0.035,0.034 0.019,0 0.034,-0.016 0.034,-0.035 L -0.281,0.175 0,0.064 C 0.018,0.057 0.027,0.037 0.02,0.02 0.016,0.01 0.009,0.003 0,0" /></g><g
+           transform="translate(4.4462,6.7459)"
+           id="g44"><path
+             id="path46"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C 0.008,0.003 0.014,0.009 0.018,0.017 L 0.166,0.308 C 0.172,0.321 0.17,0.336 0.16,0.347 L -0.001,0.521 C -0.014,0.535 -0.036,0.535 -0.05,0.523 -0.064,0.51 -0.064,0.488 -0.051,0.474 L 0.093,0.318 -0.043,0.048 C -0.052,0.031 -0.045,0.01 -0.028,0.001 -0.019,-0.003 -0.009,-0.003 0,0" /></g><g
+           transform="translate(4.6939,6.6224)"
+           id="g48"><path
+             id="path50"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c 0.078,-0.206 -0.025,-0.436 -0.231,-0.515 -0.205,-0.078 -0.436,0.025 -0.514,0.231 -0.079,0.206 0.025,0.436 0.23,0.515 C -0.309,0.309 -0.078,0.206 0,0" /></g><g
+           transform="translate(5.3048,5.0195)"
+           id="g52"><path
+             id="path54"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.081,-0.117 -0.192,-0.208 -0.328,-0.26 -0.136,-0.051 -0.279,-0.057 -0.417,-0.024 0,0 -0.308,0.808 0.064,0.95 C -0.308,0.808 0,0 0,0" /></g><g
+           transform="translate(5.3871,5.9087)"
+           id="g56"><path
+             id="path58"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c 0.104,-0.274 0.089,-0.559 -0.02,-0.782 -0.018,-0.038 -0.039,-0.073 -0.062,-0.107 -0.308,0.016 -0.545,0.31 -0.545,0.31 0,0 0.014,-0.379 -0.2,-0.594 -0.04,0.009 -0.08,0.022 -0.118,0.038 -0.23,0.094 -0.431,0.297 -0.536,0.571 -0.182,0.478 0.037,0.899 0.446,1.054 C -0.626,0.646 -0.182,0.479 0,0" /></g><g
+           transform="translate(5.2032,5.8241)"
+           id="g60"><path
+             id="path62"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c 0.116,-0.305 -0.037,-0.646 -0.341,-0.762 -0.305,-0.116 -0.646,0.037 -0.762,0.342 -0.116,0.304 0.037,0.645 0.341,0.761 C -0.457,0.457 -0.116,0.305 0,0" /></g><g
+           transform="translate(4.5752,6.005)"
+           id="g64"><path
+             id="path66"
+             style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 0.164,-0.509 0.09,-0.538 -0.127,-0.048 Z" /></g><g
+           transform="translate(4.8228,5.3149)"
+           id="g68"><path
+             id="path70"
+             style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 -0.1,-0.038 -0.038,0.1 0.1,0.038 z" /></g><g
+           transform="translate(6.9544,3.7147)"
+           id="g72"><path
+             id="path74"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.004,-0.002 -0.009,-0.004 -0.015,-0.004 l -0.639,0.012 c -0.014,0 -0.026,0.01 -0.029,0.023 L -0.776,0.437 -1,0.408 c -0.016,-0.002 -0.031,0.009 -0.033,0.026 -0.002,0.016 0.009,0.031 0.026,0.033 l 0.25,0.032 c 0.015,0.002 0.029,-0.008 0.032,-0.022 L -0.63,0.067 -0.013,0.056 C 0.003,0.055 0.016,0.042 0.016,0.025 0.015,0.014 0.009,0.005 0,0" /></g><g
+           transform="translate(6.4819,2.6811)"
+           id="g76"><path
+             id="path78"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C -0.002,-0.001 -0.003,-0.002 -0.005,-0.002 L -0.12,-0.037 c -0.016,-0.005 -0.032,0.003 -0.037,0.019 -0.005,0.016 0.004,0.032 0.019,0.037 l 0.096,0.029 0.032,0.48 c 0.001,0.012 0.009,0.023 0.021,0.026 L 0.485,0.701 C 0.5,0.706 0.517,0.697 0.522,0.681 0.526,0.666 0.518,0.649 0.502,0.644 L 0.047,0.504 0.015,0.024 C 0.015,0.014 0.009,0.005 0,0" /></g><g
+           transform="translate(7.1277,2.0748)"
+           id="g80"><path
+             id="path82"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C -0.011,-0.008 -0.026,-0.007 -0.037,0.002 -0.049,0.013 -0.05,0.032 -0.039,0.044 L 0.027,0.118 -0.196,0.544 C -0.201,0.555 -0.2,0.569 -0.191,0.578 L 0.137,0.95 C 0.147,0.962 0.166,0.963 0.178,0.953 0.191,0.942 0.192,0.923 0.181,0.911 L -0.134,0.554 0.089,0.128 C 0.094,0.117 0.093,0.104 0.085,0.094 L 0.005,0.005 C 0.004,0.003 0.002,0.001 0,0" /></g><g
+           transform="translate(7.7523,4.1457)"
+           id="g84"><path
+             id="path86"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C 0.004,0.002 0.008,0.006 0.011,0.01 L 0.352,0.551 C 0.359,0.563 0.358,0.578 0.349,0.588 L 0.06,0.889 0.207,1.06 C 0.218,1.072 0.216,1.091 0.204,1.102 0.191,1.112 0.173,1.111 0.162,1.098 L -0.002,0.907 c -0.01,-0.012 -0.01,-0.029 0.001,-0.04 L 0.29,0.563 -0.039,0.042 C -0.048,0.028 -0.044,0.01 -0.03,0.001 -0.02,-0.005 -0.009,-0.005 0,0" /></g><g
+           transform="translate(9.0287,3.1014)"
+           id="g88"><path
+             id="path90"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C 0.012,0.005 0.02,0.018 0.018,0.032 0.016,0.048 0.001,0.059 -0.015,0.057 l -0.099,-0.015 -0.234,0.42 c -0.006,0.011 -0.018,0.017 -0.03,0.015 L -0.869,0.407 C -0.885,0.404 -0.897,0.389 -0.894,0.373 -0.892,0.357 -0.877,0.346 -0.861,0.348 l 0.471,0.068 0.234,-0.42 c 0.006,-0.011 0.018,-0.017 0.031,-0.015 l 0.118,0.017 C -0.004,-0.001 -0.002,-0.001 0,0" /></g><g
+           transform="translate(6.888,4.4106)"
+           id="g92"><path
+             id="path94"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.006,-0.003 -0.014,-0.005 -0.021,-0.003 l -0.272,0.068 c -0.012,0.003 -0.02,0.013 -0.022,0.025 l -0.023,0.202 c -0.001,0.016 0.01,0.031 0.026,0.033 0.017,0.002 0.031,-0.01 0.033,-0.026 L -0.258,0.117 -0.007,0.055 C 0.009,0.051 0.019,0.035 0.015,0.019 0.013,0.01 0.007,0.004 0,0" /></g><g
+           transform="translate(7.2069,4.5828)"
+           id="g96"><path
+             id="path98"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C 0.006,0.003 0.011,0.009 0.014,0.016 L 0.106,0.28 C 0.11,0.292 0.107,0.305 0.097,0.313 l -0.156,0.13 C -0.072,0.453 -0.09,0.451 -0.101,0.439 -0.111,0.426 -0.109,0.407 -0.097,0.397 L 0.044,0.28 -0.042,0.036 C -0.047,0.02 -0.039,0.003 -0.024,-0.002 -0.016,-0.005 -0.007,-0.004 0,0" /></g><g
+           transform="translate(7.4312,4.5055)"
+           id="g100"><path
+             id="path102"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c 0.09,-0.166 0.028,-0.374 -0.138,-0.463 -0.167,-0.09 -0.374,-0.028 -0.464,0.138 -0.089,0.166 -0.028,0.374 0.139,0.463 C -0.297,0.228 -0.09,0.166 0,0" /></g><g
+           transform="translate(8.1302,3.2114)"
+           id="g104"><path
+             id="path106"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.056,-0.108 -0.14,-0.198 -0.25,-0.257 -0.109,-0.06 -0.231,-0.081 -0.352,-0.068 0,0 -0.352,0.652 -0.051,0.815 C -0.352,0.652 0,0 0,0" /></g><g
+           transform="translate(8.1005,3.9765)"
+           id="g108"><path
+             id="path110"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C 0.119,-0.221 0.138,-0.465 0.071,-0.667 0.06,-0.701 0.046,-0.734 0.03,-0.765 c -0.263,-0.021 -0.499,0.203 -0.499,0.203 0,0 0.055,-0.321 -0.103,-0.528 -0.035,0.004 -0.07,0.01 -0.105,0.019 -0.205,0.055 -0.399,0.204 -0.519,0.425 -0.208,0.387 -0.069,0.769 0.261,0.947 C -0.605,0.479 -0.209,0.386 0,0" /></g><g
+           transform="translate(7.9536,3.8839)"
+           id="g112"><path
+             id="path114"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c 0.133,-0.246 0.041,-0.553 -0.205,-0.686 -0.246,-0.133 -0.553,-0.041 -0.686,0.205 -0.133,0.246 -0.041,0.553 0.205,0.686 C -0.44,0.338 -0.133,0.246 0,0" /></g><g
+           transform="translate(7.3995,3.9674)"
+           id="g116"><path
+             id="path118"
+             style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 0.197,-0.415 0.136,-0.447 -0.102,-0.055 Z" /></g><g
+           transform="translate(7.6873,3.4084)"
+           id="g120"><path
+             id="path122"
+             style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 -0.081,-0.044 -0.043,0.081 0.08,0.044 z" /></g><g
+           transform="translate(4.2747,2.386)"
+           id="g124"><path
+             id="path126"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.004,-0.002 -0.008,-0.003 -0.013,-0.003 l -0.573,0.01 c -0.012,0 -0.023,0.009 -0.025,0.021 l -0.084,0.364 -0.2,-0.026 c -0.015,-0.002 -0.028,0.008 -0.03,0.022 -0.002,0.015 0.008,0.028 0.023,0.03 l 0.224,0.029 c 0.013,0.002 0.026,-0.007 0.029,-0.02 L -0.564,0.06 -0.012,0.05 C 0.003,0.049 0.014,0.037 0.014,0.023 0.014,0.013 0.008,0.004 0,0" /></g><g
+           transform="translate(3.8515,1.4604)"
+           id="g128"><path
+             id="path130"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.002,-0.001 -0.003,-0.001 -0.005,-0.002 l -0.103,-0.032 c -0.014,-0.004 -0.028,0.004 -0.033,0.018 -0.004,0.014 0.004,0.029 0.018,0.033 l 0.085,0.026 0.029,0.43 c 10e-4,0.011 0.008,0.02 0.019,0.023 L 0.434,0.628 C 0.448,0.632 0.463,0.624 0.467,0.61 0.471,0.596 0.464,0.581 0.45,0.577 L 0.043,0.451 0.014,0.021 C 0.013,0.012 0.008,0.004 0,0" /></g><g
+           transform="translate(4.4299,0.9173)"
+           id="g132"><path
+             id="path134"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.01,-0.007 -0.023,-0.006 -0.033,0.002 -0.011,0.01 -0.012,0.026 -0.002,0.037 L 0.024,0.106 -0.175,0.488 C -0.18,0.497 -0.179,0.509 -0.172,0.517 L 0.122,0.851 C 0.132,0.862 0.149,0.863 0.16,0.853 0.171,0.843 0.172,0.827 0.162,0.816 L -0.12,0.496 0.08,0.114 C 0.085,0.105 0.083,0.093 0.076,0.085 L 0.005,0.004 C 0.003,0.003 0.002,0.001 0,0" /></g><g
+           transform="translate(4.9893,2.772)"
+           id="g136"><path
+             id="path138"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C 0.004,0.002 0.007,0.005 0.01,0.009 L 0.315,0.494 C 0.322,0.504 0.321,0.518 0.312,0.526 L 0.054,0.796 0.185,0.949 C 0.195,0.96 0.194,0.977 0.183,0.987 0.171,0.996 0.155,0.995 0.145,0.984 L -0.002,0.812 C -0.011,0.802 -0.01,0.787 -0.001,0.777 L 0.26,0.505 -0.035,0.037 C -0.043,0.025 -0.039,0.009 -0.027,0.001 -0.018,-0.004 -0.008,-0.004 0,0" /></g><g
+           transform="translate(6.1324,1.8367)"
+           id="g140"><path
+             id="path142"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C 0.011,0.004 0.018,0.016 0.016,0.028 0.014,0.043 0.001,0.053 -0.014,0.051 L -0.102,0.038 -0.312,0.414 C -0.317,0.423 -0.328,0.429 -0.339,0.427 L -0.779,0.364 C -0.793,0.362 -0.803,0.349 -0.801,0.334 -0.799,0.32 -0.786,0.31 -0.771,0.312 l 0.422,0.06 0.21,-0.376 c 0.005,-0.009 0.016,-0.015 0.027,-0.013 l 0.106,0.015 C -0.004,-0.001 -0.002,-0.001 0,0" /></g><g
+           transform="translate(4.2152,3.0093)"
+           id="g144"><path
+             id="path146"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.006,-0.003 -0.012,-0.004 -0.019,-0.002 l -0.243,0.06 c -0.011,0.003 -0.019,0.012 -0.02,0.023 l -0.02,0.181 c -0.002,0.014 0.008,0.027 0.023,0.029 0.014,0.001 0.028,-0.009 0.029,-0.023 L -0.231,0.105 -0.006,0.049 C 0.008,0.045 0.017,0.031 0.013,0.017 0.011,0.009 0.006,0.003 0,0" /></g><g
+           transform="translate(4.5008,3.1635)"
+           id="g148"><path
+             id="path150"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C 0.006,0.003 0.01,0.008 0.012,0.015 L 0.095,0.251 C 0.099,0.261 0.096,0.273 0.087,0.28 l -0.14,0.116 C -0.064,0.406 -0.081,0.404 -0.09,0.393 -0.1,0.382 -0.098,0.365 -0.087,0.356 L 0.039,0.251 -0.038,0.032 C -0.042,0.018 -0.035,0.003 -0.021,-0.002 -0.014,-0.004 -0.006,-0.003 0,0" /></g><g
+           transform="translate(4.7017,3.0943)"
+           id="g152"><path
+             id="path154"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c 0.08,-0.149 0.025,-0.335 -0.124,-0.415 -0.149,-0.08 -0.335,-0.025 -0.415,0.124 -0.08,0.149 -0.025,0.335 0.124,0.415 C -0.266,0.204 -0.08,0.149 0,0" /></g><g
+           transform="translate(5.3277,1.9352)"
+           id="g156"><path
+             id="path158"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.05,-0.097 -0.125,-0.178 -0.224,-0.231 -0.098,-0.053 -0.207,-0.071 -0.315,-0.06 0,0 -0.315,0.584 -0.046,0.73 C -0.315,0.584 0,0 0,0" /></g><g
+           transform="translate(5.3011,2.6205)"
+           id="g160"><path
+             id="path162"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c 0.107,-0.198 0.124,-0.417 0.064,-0.597 -0.011,-0.031 -0.023,-0.06 -0.037,-0.088 -0.236,-0.019 -0.447,0.182 -0.447,0.182 0,0 0.049,-0.288 -0.092,-0.473 -0.032,0.003 -0.063,0.009 -0.094,0.017 -0.184,0.049 -0.358,0.183 -0.465,0.381 C -1.258,-0.232 -1.133,0.11 -0.837,0.27 -0.541,0.429 -0.187,0.346 0,0" /></g><g
+           transform="translate(5.1696,2.5376)"
+           id="g164"><path
+             id="path166"
+             style="fill:#fc0204;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c 0.119,-0.22 0.037,-0.495 -0.183,-0.614 -0.221,-0.119 -0.496,-0.037 -0.615,0.183 -0.119,0.22 -0.037,0.495 0.184,0.614 C -0.394,0.302 -0.119,0.22 0,0" /></g><g
+           transform="translate(4.6733,2.6123)"
+           id="g168"><path
+             id="path170"
+             style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 0.176,-0.371 0.122,-0.4 -0.092,-0.05 Z" /></g><g
+           transform="translate(4.931,2.1117)"
+           id="g172"><path
+             id="path174"
+             style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 -0.072,-0.039 -0.039,0.072 0.072,0.039 z" /></g><g
+           transform="translate(10.5983,13.5956)"
+           id="g176"><path
+             id="path178"
+             style="fill:#292974;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.1,-0.21 -0.136,-0.276 -0.236,-0.234 l -0.007,0.003 h -0.008 c -0.019,-0.001 -0.037,-0.001 -0.056,-0.003 -0.035,0.005 -0.093,0.012 -0.143,0.015 0.021,0.045 0.056,0.106 0.109,0.185 C -0.12,0.303 0.183,0.505 0.365,0.601 0.176,0.369 0.074,0.155 0,0 m 17.206,-10.696 -0.021,0.016 c -0.033,0.022 -3.325,2.248 -4.636,3.731 -0.173,0.196 -0.35,0.422 -0.527,0.666 10e-4,0.017 0,0.034 0,0.052 v 0.015 l -0.01,0.011 c -0.04,0.037 -0.082,0.075 -0.125,0.113 -0.187,0.269 -0.373,0.555 -0.552,0.845 0.226,-0.145 0.453,-0.316 0.684,-0.526 l 0.065,-0.06 -0.006,0.089 c -10e-4,0.007 -0.059,0.777 -0.104,1.107 l -10e-4,0.009 -0.005,0.007 c -0.134,0.191 -0.994,0.713 -1.601,1.067 -0.033,0.062 -0.066,0.123 -0.096,0.182 0.53,-0.149 1.007,-0.44 1.61,-0.873 l 0.068,-0.049 -0.012,0.083 c -10e-4,0.008 -0.117,0.767 -0.219,1.153 l -0.002,0.007 -0.005,0.007 c -0.118,0.148 -0.88,0.528 -1.278,0.719 0.372,-0.074 0.638,-0.171 1.144,-0.396 l 0.075,-0.034 -0.028,0.079 C 11.616,-2.656 10.843,-0.633 7.987,1.04 7.601,1.266 7.227,1.435 6.869,1.56 v 0 C 6.727,1.715 6.469,1.829 6.074,1.919 5.843,1.972 5.614,2.012 5.389,2.04 5.429,2.104 5.469,2.165 5.502,2.207 5.728,2.504 6.094,3.175 6.109,3.203 L 6.128,3.237 4.605,3.852 4.593,3.815 C 4.592,3.81 4.438,3.291 4.077,2.877 3.738,2.489 3.406,2.084 3.355,2.022 2.915,1.955 2.509,1.851 2.144,1.722 1.712,1.679 0.864,1.564 0.218,1.284 -0.128,1.134 -0.458,0.804 -0.627,0.444 -0.709,0.267 -0.727,-0.033 -0.726,-0.261 -0.77,-0.272 -0.808,-0.283 -0.828,-0.289 -1.56,-0.41 -2.248,-0.714 -2.741,-1.146 -3.037,-1.404 -3.291,-1.735 -3.486,-2.105 -3.506,-2.146 -3.527,-2.186 -3.547,-2.228 -3.576,-2.257 -3.591,-2.275 -3.593,-2.276 L -3.597,-2.28 -3.6,-2.286 c -0.005,-0.012 -0.133,-0.315 -0.178,-0.477 -0.05,-0.178 -0.104,-0.535 -0.106,-0.549 l -0.014,-0.101 0.012,0.012 c -0.006,-0.126 0.004,-0.268 0.025,-0.416 -0.31,-0.111 -0.613,-0.263 -0.757,-0.452 -0.618,-0.738 -0.567,-1.514 -0.517,-1.813 0.052,-0.311 0.162,-0.522 0.243,-0.571 0.064,-0.038 0.118,-0.046 0.17,-0.037 0.081,0.014 0.156,0.075 0.241,0.145 0.04,0.035 0.086,0.073 0.139,0.11 0.037,0.027 0.082,0.061 0.135,0.101 0.271,0.205 0.725,0.547 1.168,0.716 0.046,0.018 0.09,0.034 0.131,0.048 0.243,-0.141 0.55,-0.262 0.909,-0.291 0.125,-0.01 0.247,-0.017 0.362,-0.023 l -0.017,-0.009 0.123,-0.003 c 0.55,-0.014 0.945,0.019 1.257,0.071 0.051,0.009 0.098,0.017 0.145,0.026 0.03,0.006 0.061,0.014 0.091,0.02 0.008,0.001 0.016,0.003 0.024,0.006 0.018,0.003 0.035,0.008 0.053,0.012 0.052,0.012 0.102,0.025 0.151,0.038 0.016,0.005 0.032,0.009 0.048,0.014 0.06,0.016 0.118,0.033 0.174,0.048 0.271,0.078 0.527,0.153 0.893,0.176 0.725,0.046 1.616,0.064 2.362,0.038 L 3.436,-6.039 C 3.313,-6.045 2.764,-6.106 2.132,-6.639 1.433,-7.228 1.085,-7.946 1.081,-7.953 l -0.04,-0.085 0.351,0.151 0.007,0.008 c 0.005,0.006 0.536,0.698 0.884,0.904 0.196,0.115 0.409,0.208 0.559,0.268 C 2.61,-6.895 2.21,-7.219 2.103,-7.314 1.921,-7.475 1.696,-7.776 1.687,-7.789 l -0.069,-0.091 0.464,0.159 0.006,0.004 c 0.534,0.444 1.226,0.973 1.845,1.133 0.519,0.121 1.29,-0.201 1.297,-0.205 l 0.041,0.055 -0.239,0.303 -0.01,0.004 C 5.017,-6.425 4.571,-6.265 4.33,-6.138 4.312,-6.129 4.3,-6.113 4.293,-6.089 4.261,-5.971 4.356,-5.724 4.464,-5.502 4.568,-5.513 4.665,-5.526 4.75,-5.541 5.088,-5.601 5.591,-5.62 6.044,-5.623 6.061,-5.628 6.142,-5.652 6.269,-5.689 L 6.153,-6.538 C 6.038,-6.56 5.613,-7.448 6.137,-7.23 c 0.063,0.027 0.117,0.051 0.165,0.073 0.222,0.101 0.334,0.154 0.977,0.172 0.359,0.01 0.608,-0.037 0.61,-0.037 l 0.12,-0.023 -0.267,0.249 -0.006,0.003 c -0.009,0.005 -0.232,0.098 -0.351,0.136 -0.121,0.04 -0.34,0.093 -0.423,0.111 l 0.102,0.628 c 0.681,-0.196 1.586,-0.45 2.336,-0.641 0.204,-0.053 0.407,-0.099 0.604,-0.14 0.431,-0.77 0.909,-1.561 1.356,-2.179 1.274,-1.753 4.515,-5.216 4.548,-5.25 l 0.036,-0.039 0.642,1.439 -2.529,2.712 2.626,-2.231 z" /></g><g
+           transform="translate(8.3941,8.2561)"
+           id="g180"><path
+             id="path182"
+             style="fill:#292974;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.032,-0.099 -0.122,-0.115 -0.269,-0.142 -0.13,-0.023 -0.306,-0.055 -0.558,-0.152 -0.449,-0.172 -0.91,-0.519 -1.185,-0.727 -0.053,-0.04 -0.099,-0.075 -0.137,-0.102 -0.053,-0.038 -0.1,-0.077 -0.141,-0.111 -0.087,-0.072 -0.162,-0.134 -0.244,-0.148 -0.053,-0.01 -0.109,0 -0.173,0.038 -0.083,0.05 -0.194,0.264 -0.247,0.579 -0.051,0.303 -0.102,1.091 0.525,1.839 0.331,0.433 1.467,0.677 1.712,0.673 0.219,-0.005 0.352,-0.334 0.48,-0.653 C -0.216,1.041 -0.195,0.988 -0.173,0.938 -0.024,0.584 0.053,0.163 0,0" /></g><g
+           transform="translate(3.4987,9.1203)"
+           id="g184"><path
+             id="path186"
+             style="fill:#292974;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c -0.143,-0.113 -0.35,-0.165 -0.59,-0.158 -0.037,0.001 -0.075,0.004 -0.114,0.008 -0.252,0.026 -0.531,0.113 -0.802,0.258 -0.026,0.014 -0.053,0.029 -0.079,0.044 -0.044,0.026 -0.087,0.052 -0.13,0.081 -0.221,0.146 -0.409,0.318 -0.555,0.498 -0.025,0.03 -0.048,0.06 -0.07,0.091 -0.027,0.036 -0.051,0.072 -0.074,0.109 -0.024,0.038 -0.046,0.077 -0.067,0.115 -0.094,0.18 -0.144,0.357 -0.141,0.516 0,0.032 0.003,0.062 0.007,0.092 0.012,0.079 0.04,0.152 0.082,0.217 0.09,0.135 0.238,0.22 0.423,0.258 0.049,0.01 0.1,0.017 0.153,0.02 0.036,0.002 0.073,0.003 0.11,0.002 C -1.553,2.145 -1.209,2.049 -0.879,1.865 -0.853,1.851 -0.827,1.836 -0.801,1.82 -0.769,1.801 -0.737,1.781 -0.705,1.76 -0.403,1.56 -0.161,1.311 -0.004,1.058 0.017,1.025 0.036,0.992 0.054,0.959 V 0.958 C 0.221,0.646 0.255,0.338 0.112,0.121 0.1,0.102 0.086,0.085 0.071,0.068 0.05,0.043 0.026,0.02 0,0 M 7.404,-2.984 7.374,-2.97 0.53,0.207 C 0.672,0.788 0.267,1.544 -0.508,2.057 -0.928,2.335 -1.394,2.495 -1.818,2.508 -2.274,2.521 -2.633,2.365 -2.83,2.068 -2.923,1.927 -2.972,1.766 -2.979,1.596 -2.982,1.539 -2.98,1.482 -2.973,1.423 -2.951,1.217 -2.874,1 -2.749,0.788 -2.733,0.761 -2.716,0.734 -2.698,0.707 -2.651,0.635 -2.599,0.563 -2.541,0.493 -2.51,0.457 -2.478,0.42 -2.445,0.384 c 0.15,-0.161 0.329,-0.313 0.533,-0.448 0.062,-0.042 0.126,-0.08 0.19,-0.116 0.026,-0.015 0.053,-0.029 0.079,-0.043 0.214,-0.112 0.434,-0.194 0.651,-0.242 0.035,-0.008 0.07,-0.015 0.105,-0.021 0.096,-0.016 0.192,-0.026 0.285,-0.029 0.031,-0.001 0.061,-0.001 0.091,0 0.032,0 0.063,0.002 0.094,0.004 0.062,0.005 0.121,0.013 0.178,0.025 0.087,0.017 0.167,0.043 0.242,0.075 0.056,0.025 0.109,0.054 0.158,0.088 0.085,0.057 0.158,0.126 0.218,0.207 l 6.83,-3.17 c 0,-10e-4 0,-10e-4 0,-10e-4 l 0.056,-0.026 c 0.015,-0.003 0.029,-0.006 0.045,-0.006 0.099,-10e-4 0.181,0.078 0.182,0.178 0.001,0.067 -0.035,0.126 -0.088,0.157" /></g><g
+           transform="translate(3.8027,9.3432)"
+           id="g188"><path
+             id="path190"
+             style="fill:#455a64;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 -0.01,-0.035 0.003,-0.002 c -0.026,-0.102 -0.085,-0.236 -0.162,-0.376 -0.017,0.087 -0.038,0.173 -0.064,0.258 -0.05,0.165 -0.118,0.323 -0.203,0.473 0.07,0.135 0.132,0.275 0.186,0.418 v 0 c 0.021,0.055 0.04,0.11 0.059,0.166 C 0.002,0.59 0.079,0.267 0,0 m -0.623,-1.053 c -0.025,0.109 -0.057,0.216 -0.098,0.319 -0.047,0.122 -0.106,0.24 -0.175,0.351 0.001,0.001 0.002,0.001 0.002,0.002 0.155,0.189 0.291,0.392 0.409,0.606 0.076,-0.142 0.137,-0.293 0.181,-0.448 0.029,-0.101 0.051,-0.205 0.066,-0.31 C -0.259,-0.566 -0.28,-0.6 -0.301,-0.634 -0.405,-0.794 -0.52,-0.945 -0.623,-1.053 m -0.138,-0.119 c -0.044,-0.029 -0.09,-0.058 -0.135,-0.086 -0.092,-0.058 -0.184,-0.116 -0.272,-0.18 -0.057,0.178 -0.141,0.348 -0.253,0.499 0.045,0.046 0.09,0.091 0.135,0.135 0.031,0.032 0.063,0.063 0.095,0.095 0.046,0.047 0.093,0.095 0.137,0.143 0.034,0.036 0.066,0.073 0.098,0.11 0.054,-0.09 0.101,-0.185 0.141,-0.282 0.051,-0.125 0.089,-0.255 0.116,-0.387 -0.022,-0.018 -0.042,-0.035 -0.062,-0.047 m -0.745,-0.612 c -0.026,-0.042 -0.047,-0.087 -0.067,-0.132 -0.077,0.147 -0.172,0.283 -0.287,0.403 0.03,0.056 0.061,0.111 0.096,0.164 0.081,0.123 0.179,0.236 0.281,0.344 0.108,-0.149 0.189,-0.315 0.24,-0.491 -0.101,-0.082 -0.193,-0.175 -0.263,-0.288 m -0.145,-0.331 c -0.035,-0.097 -0.071,-0.196 -0.12,-0.285 -0.06,-0.107 -0.144,-0.198 -0.241,-0.27 -0.042,0.136 -0.103,0.266 -0.185,0.382 0.047,0.102 0.09,0.207 0.131,0.309 0.051,0.127 0.104,0.256 0.164,0.381 0.117,-0.127 0.211,-0.272 0.283,-0.428 -0.011,-0.03 -0.022,-0.06 -0.032,-0.089 m -0.924,-0.746 c 0.124,0.125 0.237,0.283 0.334,0.478 0.067,-0.105 0.117,-0.22 0.151,-0.339 -0.146,-0.088 -0.316,-0.137 -0.485,-0.139 m -0.118,0.008 c -0.066,0.008 -0.131,0.023 -0.194,0.046 -0.009,0.004 -0.018,0.009 -0.027,0.013 l 0.318,0.771 c 0.006,-0.004 0.013,-0.007 0.019,-0.012 0.107,-0.071 0.199,-0.163 0.277,-0.266 -0.001,-0.003 -0.003,-0.006 -0.004,-0.009 -0.085,-0.178 -0.213,-0.387 -0.389,-0.543 m -0.301,0.098 c -0.038,0.02 -0.074,0.043 -0.107,0.069 -0.052,0.234 -0.051,0.479 0.012,0.711 0.012,0.042 0.026,0.083 0.04,0.125 0.132,-0.023 0.258,-0.065 0.375,-0.128 z m -0.18,0.803 c -0.057,-0.207 -0.067,-0.424 -0.035,-0.636 -0.184,0.195 -0.286,0.471 -0.257,0.738 0.108,0.018 0.218,0.025 0.328,0.014 -0.013,-0.038 -0.026,-0.077 -0.036,-0.116 m -0.152,0.539 c 0.048,0.108 0.095,0.219 0.124,0.335 0.127,0.007 0.254,0.001 0.379,-0.02 -0.042,-0.121 -0.097,-0.239 -0.154,-0.361 -0.045,-0.095 -0.089,-0.192 -0.128,-0.292 -0.115,0.014 -0.231,0.011 -0.345,-0.005 0.026,0.117 0.076,0.232 0.124,0.343 m 0.027,1.026 c 0.187,0.032 0.365,0.046 0.532,0.04 0.022,-0.159 0.035,-0.313 0.015,-0.469 -0.009,-0.067 -0.024,-0.133 -0.043,-0.197 -0.128,0.023 -0.259,0.031 -0.389,0.025 0.004,0.027 0.007,0.053 0.008,0.08 0.008,0.184 -0.051,0.355 -0.123,0.521 m -0.058,0.13 c -0.043,0.093 -0.087,0.189 -0.12,0.285 -0.033,0.096 -0.048,0.194 -0.051,0.293 0.167,0.078 0.344,0.133 0.526,0.163 0.055,0.009 0.11,0.016 0.166,0.02 -0.007,-0.078 -0.011,-0.156 -0.009,-0.233 0.001,-0.013 0.001,-0.026 0.002,-0.038 0.006,-0.138 0.029,-0.276 0.05,-0.41 0.004,-0.027 0.008,-0.053 0.013,-0.08 -0.176,0.005 -0.362,-0.011 -0.557,-0.045 -0.007,0.015 -0.014,0.03 -0.02,0.045 m 0.08,1.458 c 0.007,0.013 0.014,0.025 0.021,0.038 0.107,0.042 0.217,0.075 0.33,0.101 0.114,0.025 0.23,0.042 0.346,0.049 C -2.673,1.198 -2.741,1.009 -2.785,0.823 -2.803,0.746 -2.817,0.67 -2.827,0.594 -2.903,0.588 -2.978,0.579 -3.053,0.565 -3.216,0.536 -3.375,0.487 -3.526,0.42 c 0.016,0.265 0.117,0.533 0.249,0.781 m 0.329,0.521 c 0.11,0.166 0.289,0.272 0.509,0.316 0.087,0.018 0.179,0.026 0.277,0.025 C -2.196,2.018 -2.229,1.972 -2.261,1.926 -2.34,1.812 -2.415,1.695 -2.484,1.574 -2.501,1.543 -2.516,1.512 -2.533,1.481 -2.662,1.476 -2.792,1.458 -2.919,1.431 -3.01,1.412 -3.1,1.387 -3.189,1.357 c 0.079,0.132 0.162,0.255 0.241,0.365 m 0.893,0.336 C -1.767,2.037 -1.445,1.94 -1.132,1.767 L -1.183,1.642 -1.357,1.222 c -0.331,0.174 -0.702,0.261 -1.074,0.261 0.009,0.016 0.016,0.031 0.025,0.047 0.077,0.137 0.164,0.27 0.255,0.398 0.032,0.044 0.063,0.087 0.096,0.13 M -1.601,0.317 C -1.61,0.322 -1.619,0.326 -1.627,0.331 l 0.315,0.764 c 0.32,-0.189 0.585,-0.461 0.774,-0.779 -0.116,-0.219 -0.253,-0.428 -0.409,-0.621 -0.171,0.251 -0.393,0.467 -0.654,0.622 m -0.597,-1.556 c -0.02,0.013 -0.041,0.024 -0.061,0.035 l 0.269,0.655 c 0.174,-0.098 0.327,-0.229 0.453,-0.384 -0.109,-0.114 -0.214,-0.234 -0.301,-0.367 -0.032,-0.048 -0.061,-0.098 -0.088,-0.148 -0.083,0.079 -0.174,0.149 -0.272,0.209 m -0.095,-0.047 c 0.016,-0.01 0.032,-0.019 0.048,-0.028 0.1,-0.062 0.193,-0.135 0.277,-0.216 -0.067,-0.137 -0.125,-0.277 -0.181,-0.416 -0.035,-0.086 -0.071,-0.174 -0.109,-0.261 -0.078,0.094 -0.168,0.178 -0.269,0.246 -0.011,0.008 -0.023,0.015 -0.035,0.022 z m -0.079,0.041 -0.268,-0.649 c -0.117,0.062 -0.244,0.106 -0.375,0.13 0.037,0.09 0.078,0.18 0.119,0.267 0.057,0.124 0.116,0.25 0.161,0.381 0.126,-0.028 0.247,-0.072 0.363,-0.129 m -0.305,0.893 c 0.194,-0.015 0.373,-0.055 0.537,-0.124 0.024,-0.009 0.048,-0.021 0.071,-0.032 l -0.269,-0.654 c -0.118,0.058 -0.242,0.102 -0.37,0.131 0.02,0.067 0.035,0.134 0.044,0.204 0.021,0.159 0.009,0.318 -0.013,0.475 m -0.041,1.06 c 0.043,0.223 0.123,0.452 0.24,0.685 0.376,0.008 0.753,-0.076 1.087,-0.254 L -1.706,0.374 c -0.289,0.147 -0.613,0.222 -0.939,0.225 -0.03,0 -0.061,0 -0.091,-0.001 0.005,0.036 0.011,0.073 0.018,0.11 M -2.754,0.237 c -0.005,0.09 -10e-4,0.18 0.007,0.272 0.058,0.002 0.115,0.002 0.173,-0.001 0.29,-0.012 0.577,-0.085 0.834,-0.217 l -0.149,-0.362 -0.137,-0.332 -0.009,-0.023 c -0.023,0.011 -0.047,0.023 -0.071,0.033 -0.178,0.074 -0.373,0.116 -0.583,0.131 -0.006,0.033 -0.011,0.066 -0.016,0.099 -0.017,0.107 -0.034,0.216 -0.044,0.324 -0.002,0.026 -0.004,0.051 -0.005,0.076 m 1.748,-0.614 c -0.037,-0.044 -0.074,-0.087 -0.113,-0.128 -0.057,-0.062 -0.117,-0.123 -0.177,-0.183 -0.017,-0.017 -0.035,-0.035 -0.053,-0.053 -0.042,-0.042 -0.085,-0.084 -0.127,-0.127 -0.133,0.162 -0.296,0.3 -0.479,0.401 l 0.008,0.021 0.137,0.331 0.149,0.363 c 0.005,-0.003 0.01,-0.005 0.014,-0.008 0.257,-0.151 0.473,-0.365 0.639,-0.613 0.001,-0.001 0.001,-0.003 0.002,-0.004 M -1.054,1.722 C -1.014,1.699 -0.975,1.674 -0.935,1.648 -0.652,1.461 -0.42,1.234 -0.254,0.997 -0.271,0.943 -0.289,0.889 -0.308,0.835 -0.36,0.69 -0.42,0.548 -0.489,0.409 -0.686,0.723 -0.956,0.991 -1.278,1.178 l 0.173,0.42 z m 0.168,0.001 c -0.832,0.55 -1.79,0.572 -2.136,0.05 -0.085,-0.12 -0.177,-0.255 -0.261,-0.4 -0.245,-0.418 -0.435,-0.917 -0.278,-1.374 0.034,-0.1 0.079,-0.198 0.122,-0.293 0.095,-0.205 0.183,-0.399 0.174,-0.61 -0.007,-0.163 -0.073,-0.314 -0.142,-0.473 -0.051,-0.117 -0.104,-0.239 -0.133,-0.367 -0.089,-0.41 0.119,-0.859 0.475,-1.073 0.006,-0.022 0.01,-0.019 0.016,-0.018 0.005,0 0.02,-0.003 0.02,-0.003 0.036,-0.019 0.073,-0.037 0.111,-0.052 0.172,-0.065 0.359,-0.075 0.539,-0.038 0.282,0.057 0.544,0.23 0.686,0.484 0.053,0.095 0.09,0.199 0.126,0.298 0.04,0.113 0.078,0.219 0.137,0.315 0.133,0.217 0.361,0.359 0.581,0.497 0.046,0.029 0.092,0.058 0.137,0.087 0.163,0.107 0.396,0.406 0.569,0.701 0.115,0.196 0.205,0.39 0.23,0.525 l 0.001,0.006 C 0.24,0.519 -0.168,1.248 -0.886,1.723" /></g><g
+           transform="translate(10.1347,13.3765)"
+           id="g192"><path
+             id="path194"
+             style="fill:#222260;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 c 0,0 1.266,-0.746 3.119,-0.436 1.853,0.311 3.459,2.569 3.459,2.569 L 8.382,1.194 c 0,0 -2.988,-1.757 -4.465,-2.214 -1.478,-0.457 -3.249,0.579 -4.192,0.979 z" /></g><g
+           transform="translate(16.1919,14.6104)"
+           id="g196"><path
+             id="path198"
+             style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 1.069,-0.512 -0.243,-0.195 -1.06,0.502 z" /></g><g
+           transform="translate(10.3721,13.3692)"
+           id="g200"><path
+             id="path202"
+             style="fill:#222260;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 -3.67,-3.195 c 0,0 0.054,0.689 0.302,1.137 0,0 0.959,1.094 1.657,1.488 0.699,0.394 1.276,0.577 1.394,0.572 C -0.271,0.001 -0.243,0.01 -0.188,0.01 -0.105,0.01 0,0 0,0" /></g><g
+           transform="translate(15.1905,15.9486)"
+           id="g204"><path
+             id="path206"
+             style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="M 0,0 C 0,0 0.206,0.272 0.291,0.419 0.379,0.57 0.516,0.894 0.516,0.894" /></g><g
+           transform="translate(15.3113,15.8571)"
+           id="g208"><path
+             id="path210"
+             style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+             d="m 0,0 -0.242,0.183 c 0.002,0.003 0.201,0.266 0.281,0.403 0.082,0.142 0.215,0.455 0.216,0.458 L 0.534,0.926 C 0.529,0.913 0.393,0.592 0.301,0.434 0.212,0.281 0.009,0.011 0,0" /></g></g></g></g></svg>
\ No newline at end of file
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index b4b9935..96a6d32 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -19,8 +19,6 @@
 import static com.google.common.truth.OptionalSubject.optionals;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
@@ -35,7 +33,6 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
@@ -49,15 +46,13 @@
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Chars;
 import com.google.common.testing.FakeTicker;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.acceptance.config.ConfigAnnotationParser;
-import com.google.gerrit.acceptance.config.GerritSystemProperty;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -146,12 +141,11 @@
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.Revisions;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.util.git.DelegateSystemReader;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.FakeEmailSender.Message;
-import com.google.gerrit.testing.SshMode;
-import com.google.gerrit.testing.TestTimeUtil;
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -160,8 +154,6 @@
 import java.io.File;
 import java.io.IOException;
 import java.lang.reflect.Modifier;
-import java.sql.Timestamp;
-import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -190,10 +182,7 @@
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.transport.Transport;
-import org.eclipse.jgit.util.FS;
-import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.ClassRule;
@@ -207,20 +196,11 @@
 @RunWith(ConfigSuite.class)
 public abstract class AbstractDaemonTest {
 
-  /**
-   * Test methods without special annotations will use a common server for efficiency reasons. The
-   * server is torn down after the test class is done.
-   */
-  private static GerritServer commonServer;
-
-  private static Description firstTest;
-
   @ClassRule public static TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   @ConfigSuite.Parameter public Config baseConfig;
-  @ConfigSuite.Name private String configName;
+  @ConfigSuite.Name public String configName;
 
-  @Rule
   public TestRule testRunner =
       new TestRule() {
         @Override
@@ -228,18 +208,12 @@
           return new Statement() {
             @Override
             public void evaluate() throws Throwable {
-              if (firstTest == null) {
-                firstTest = description;
-              }
               beforeTest(description);
-              ProjectResetter.Config input = requireNonNull(resetProjects());
-
               try (ProjectResetter resetter =
-                  projectResetter != null ? projectResetter.builder().build(input) : null) {
-                AbstractDaemonTest.this.resetter = resetter;
+                  server.createProjectResetter(
+                      (allProjects, allUsers) -> resetProjects(allProjects, allUsers))) {
                 base.evaluate();
               } finally {
-                AbstractDaemonTest.this.resetter = null;
                 afterTest();
               }
             }
@@ -247,11 +221,36 @@
         }
       };
 
+  protected DaemonTestRule daemonTestRule;
+  protected TestConfigRule configRule;
+  protected ServerTestRule server;
+  protected TimeSettingsTestRule timeSettingsRule;
+
+  @Rule public TestRule topLevelTestRule = createTopLevelTestRule();
+
+  /**
+   * Creates test rules required for tests.
+   *
+   * <p>Creating all rules in a single method gives more flexibility in the order of creation and
+   * allows additional initialization steps if needed.
+   */
+  protected TestRule createTopLevelTestRule() {
+    daemonTestRule = createDaemonTestRule();
+    configRule = daemonTestRule.configRule();
+    timeSettingsRule = daemonTestRule.timeSettingsRule();
+    server = daemonTestRule.server();
+    return daemonTestRule;
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected DaemonTestRule createDaemonTestRule() {
+    return GerritServerDaemonTestRule.create(this);
+  }
+
   @Inject @CanonicalWebUrl protected Provider<String> canonicalWebUrl;
   @Inject @GerritPersonIdent protected Provider<PersonIdent> serverIdent;
   @Inject @GerritServerConfig protected Config cfg;
   @Inject @GerritInstanceId @Nullable protected String instanceId;
-  @Inject protected AcceptanceTestRequestScope atrScope;
   @Inject protected AccountCache accountCache;
   @Inject protected AccountCreator accountCreator;
   @Inject protected Accounts accounts;
@@ -273,7 +272,6 @@
   @Inject protected PatchSetUtil psUtil;
   @Inject protected ProjectCache projectCache;
   @Inject protected ProjectConfig.Factory projectConfigFactory;
-  @Inject protected ProjectResetter.Builder.Factory projectResetter;
   @Inject protected Provider<InternalChangeQuery> queryProvider;
   @Inject protected PushOneCommit.Factory pushFactory;
   @Inject protected PluginConfigFactory pluginConfig;
@@ -283,25 +281,22 @@
   @Inject protected BatchAbandon batchAbandon;
   @Inject protected TestSshKeys sshKeys;
   @Inject protected TestTicker testTicker;
+  @Inject protected ThreadLocalRequestContext localCtx;
+
+  @Nullable public SshSession adminSshSession;
+
+  @Nullable public SshSession userSshSession;
 
   protected EventRecorder eventRecorder;
 
-  protected GerritServer server;
-
   protected Project.NameKey project;
   protected RestSession adminRestSession;
   protected RestSession userRestSession;
   protected RestSession anonymousRestSession;
-  protected SshSession adminSshSession;
-  protected SshSession userSshSession;
   protected TestAccount admin;
   protected TestAccount user;
   protected TestRepository<InMemoryRepository> testRepo;
   protected String resourcePrefix;
-  protected Description description;
-  protected GerritServer.Description testMethodDescription;
-
-  protected boolean testRequiresSsh;
   protected BlockStrategy noSleepBlockStrategy = t -> {}; // Don't sleep in tests.
 
   @Inject private AbstractChangeNotes.Args changeNotesArgs;
@@ -314,54 +309,8 @@
   @Inject private SitePaths sitePaths;
   @Inject private ProjectOperations projectOperations;
 
-  private ProjectResetter resetter;
   private List<Repository> toClose;
-  private String systemTimeZone;
-  private SystemReader oldSystemReader;
 
-  /**
-   * The Getters and Setters below are needed for tests that run on custom {@link GerritServer}
-   * (that can be set up via {@link #initServer} and {@link #setUpDatabase} methods. Because tests
-   * inherit directly from {@link AbstractDaemonTest}, the set up has to be delegated to some other
-   * class that can share the set up logic across different test classes.
-   *
-   * <p>E.g, we need to be able to do something like:
-   *
-   * <pre>{@code
-   * public class AccountIT extends AbstractDaemonTest {...}
-   *
-   * public class AbstractDaemonTestAdapter {
-   *
-   *   protected void initServer() {...}
-   *
-   *   ...
-   *
-   * }
-   *
-   * public class CustomAccountIT extends AccountIT {
-   *
-   *   AbstractDaemonTestAdapter testAdapter;
-   *
-   *   {@literal @Override}
-   *   protected void initServer() {
-   *         testAdapter.initServer();
-   *   }
-   *   ...
-   * }
-   *
-   * public class CustomChangeIT extends ChangeIT {
-   *
-   *   AbstractDaemonTestAdapter testAdapter;
-   *
-   *   {@literal @Override}
-   *   protected void initServer() {
-   *         testAdapter.initServer();
-   *   }
-   *   ...
-   * }
-   *
-   * }</pre>
-   */
   public String getResourcePrefix() {
     return resourcePrefix;
   }
@@ -370,10 +319,6 @@
     this.resourcePrefix = resourcePrefix;
   }
 
-  public Description getDescription() {
-    return description;
-  }
-
   public TestRepository<InMemoryRepository> getTestRepo() {
     return testRepo;
   }
@@ -406,14 +351,6 @@
     this.project = project;
   }
 
-  public GerritServer getServer() {
-    return server;
-  }
-
-  public void setServer(GerritServer server) {
-    this.server = server;
-  }
-
   @Before
   public void clearSender() {
     if (sender != null) {
@@ -428,19 +365,9 @@
     }
   }
 
-  @Before
-  public void assumeSshIfRequired() {
-    if (testRequiresSsh) {
-      // If the test uses ssh, we use assume() to make sure ssh is enabled on
-      // the test suite. JUnit will skip tests annotated with @UseSsh if we
-      // disable them using the command line flag.
-      assume().that(SshMode.useSsh()).isTrue();
-    }
-  }
-
   @After
   public void verifyNoPiiInChangeNotes() throws RestApiException, IOException {
-    if (testMethodDescription.verifyNoPiiInChangeNotes()) {
+    if (configRule.methodDescription().verifyNoPiiInChangeNotes()) {
       verifyNoAccountDetailsInChangeNotes();
     }
   }
@@ -454,23 +381,18 @@
 
   @ConfigSuite.AfterConfig
   public static void stopCommonServer() throws Exception {
-    if (commonServer != null) {
-      try {
-        commonServer.close();
-      } catch (Exception e) {
-        throw new AssertionError(
-            "Error stopping common server in "
-                + (firstTest != null ? firstTest.getTestClass().getName() : "unknown test class"),
-            e);
-      } finally {
-        commonServer = null;
-      }
-    }
+    GerritServerTestRule.afterConfigChanged();
   }
 
-  /** Controls which project and branches should be reset after each test case. */
-  protected ProjectResetter.Config resetProjects() {
-    return new ProjectResetter.Config()
+  /**
+   * Controls which project and branches should be reset in the commonServer after each test case.
+   *
+   * <p>Parameters allProjects and allUsers must refer to the commonServer names - if a test doesn't
+   * use commonServer then names in the test can be different from names in commonServer.
+   */
+  protected ProjectResetter.Config resetProjects(
+      AllProjectsName allProjects, AllUsersName allUsers) {
+    return new ProjectResetter.Config.Builder()
         // Don't reset all refs so that refs/sequences/changes is not touched and change IDs are
         // not reused.
         .reset(allProjects, RefNames.REFS_CONFIG)
@@ -483,26 +405,20 @@
             RefNames.REFS_GROUPNAMES,
             RefNames.REFS_GROUPS + "*",
             RefNames.REFS_STARRED_CHANGES + "*",
-            RefNames.REFS_DRAFT_COMMENTS + "*");
+            RefNames.REFS_DRAFT_COMMENTS + "*")
+        .build();
   }
 
   protected void restartAsSlave() throws Exception {
-    closeSsh();
-    server = GerritServer.restartAsSlave(server);
+    server.restartAsSlave();
     server.getTestInjector().injectMembers(this);
-    if (resetter != null) {
-      server.getTestInjector().injectMembers(resetter);
-    }
-    initSsh();
+    updateSshSessions();
   }
 
   protected void restart() throws Exception {
-    server = GerritServer.restart(server, createModule(), createSshModule());
+    server.restart();
     server.getTestInjector().injectMembers(this);
-    if (resetter != null) {
-      server.getTestInjector().injectMembers(resetter);
-    }
-    initSsh();
+    updateSshSessions();
   }
 
   public void reindexAccount(Account.Id accountId) {
@@ -526,49 +442,58 @@
   protected void beforeTest(Description description) throws Exception {
     // SystemReader must be overridden before creating any repos, since they read the user/system
     // configs at initialization time, and are then stored in the RepositoryCache forever.
-    oldSystemReader = setFakeSystemReader(temporaryFolder.getRoot());
 
-    this.description = description;
-    GerritServer.Description classDesc =
-        GerritServer.Description.forTestClass(description, configName);
-    GerritServer.Description methodDesc =
-        GerritServer.Description.forTestMethod(description, configName);
-    testMethodDescription = methodDesc;
-
-    if (methodDesc.systemProperties() != null) {
-      ConfigAnnotationParser.parse(methodDesc.systemProperties());
+    if (enableExperimentsRejectImplicitMergesOnMerge()) {
+      // When changes are merged/submitted - reject the operation if there is an implicit merge (
+      // even if rejectImplicitMerges is disabled in the project config).
+      baseConfig.setStringList(
+          "experiments",
+          null,
+          "enabled",
+          ImmutableList.of(
+              "GerritBackendFeature__check_implicit_merges_on_merge",
+              "GerritBackendFeature__reject_implicit_merges_on_merge",
+              "GerritBackendFeature__always_reject_implicit_merges_on_merge"));
     }
 
-    if (methodDesc.systemProperty() != null) {
-      ConfigAnnotationParser.parse(methodDesc.systemProperty());
-    }
-
-    testRequiresSsh = classDesc.useSshAnnotation() || methodDesc.useSshAnnotation();
-    if (!testRequiresSsh) {
-      baseConfig.setString("sshd", null, "listenAddress", "off");
-    }
-
-    baseConfig.unset("gerrit", null, "canonicalWebUrl");
-    baseConfig.unset("httpd", null, "listenUrl");
-
-    baseConfig.setInt("index", null, "batchThreads", -1);
-
-    initServer(classDesc, methodDesc);
-
+    server.initServer();
     server.getTestInjector().injectMembers(this);
+
     Transport.register(inProcessProtocol);
     toClose = Collections.synchronizedList(new ArrayList<>());
 
-    setUpDatabase(classDesc);
-
-    // Set the clock step last, so that the test setup isn't consuming any timestamps after the
-    // clock has been set.
-    setTimeSettings(classDesc.useSystemTime(), classDesc.useClockStep(), classDesc.useTimezone());
-    setTimeSettings(
-        methodDesc.useSystemTime(), methodDesc.useClockStep(), methodDesc.useTimezone());
+    setUpDatabase();
+    server.initSsh();
+    updateSshSessions();
   }
 
-  protected void setUpDatabase(GerritServer.Description classDesc) throws Exception {
+  void updateSshSessions() throws Exception {
+    userSshSession = null;
+    adminSshSession = null;
+    if (server.sshInitialized()) {
+      try (ManualRequestContext ctx = requestScopeOperations.setNestedApiUser(user.id())) {
+        userSshSession = server.getOrCreateSshSessionForContext(ctx);
+        // The session doesn't store any reference to the context and it remains open after the ctx
+        // is closed.
+        userSshSession.open();
+      }
+
+      try (ManualRequestContext ctx = requestScopeOperations.setNestedApiUser(admin.id())) {
+        adminSshSession = server.getOrCreateSshSessionForContext(ctx);
+        // The session doesn't store any reference to the context and it remains open after the ctx
+        // is closed.
+        adminSshSession.open();
+      }
+    }
+  }
+
+  protected boolean enableExperimentsRejectImplicitMergesOnMerge() {
+    // By default any attempt to make an explicit merge is rejected. This allows to check
+    // that existing workflows continue to work even if gerrit rejects implicit merges on merge.
+    return true;
+  }
+
+  protected void setUpDatabase() throws Exception {
     admin = accountCreator.admin();
     user = accountCreator.user1();
 
@@ -576,90 +501,22 @@
     reindexAccount(admin.id());
     reindexAccount(user.id());
 
-    adminRestSession = new RestSession(server, admin);
-    userRestSession = new RestSession(server, user);
-    anonymousRestSession = new RestSession(server, null);
+    adminRestSession = server.createRestSession(admin);
+    userRestSession = server.createRestSession(user);
+    anonymousRestSession = server.createRestSession(null);
 
-    initSsh();
-
-    String testMethodName = description.getMethodName();
+    String testMethodName = configRule.description().getMethodName();
     resourcePrefix =
         UNSAFE_PROJECT_NAME
-            .matcher(description.getClassName() + "_" + testMethodName + "_")
+            .matcher(configRule.description().getClassName() + "_" + testMethodName + "_")
             .replaceAll("");
 
-    setRequestScope(admin);
-    ProjectInput in = projectInput(description);
+    requestScopeOperations.setApiUser(admin.id());
+    ProjectInput in = projectInput(configRule.description());
     gApi.projects().create(in);
     project = Project.nameKey(in.name);
-    if (!classDesc.skipProjectClone()) {
-      testRepo = cloneProject(project, getCloneAsAccount(description));
-    }
-  }
-
-  protected void initServer(GerritServer.Description classDesc, GerritServer.Description methodDesc)
-      throws Exception {
-    Module module = createModule();
-    Module auditModule = createAuditModule();
-    Module sshModule = createSshModule();
-    if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
-      if (commonServer == null) {
-        commonServer =
-            GerritServer.initAndStart(
-                temporaryFolder, classDesc, baseConfig, module, auditModule, sshModule);
-      }
-      server = commonServer;
-    } else {
-      server =
-          GerritServer.initAndStart(
-              temporaryFolder, methodDesc, baseConfig, module, auditModule, sshModule);
-    }
-  }
-
-  private static SystemReader setFakeSystemReader(File tempDir) {
-    SystemReader oldSystemReader = SystemReader.getInstance();
-    SystemReader.setInstance(
-        new DelegateSystemReader(oldSystemReader) {
-          @Override
-          public FileBasedConfig openJGitConfig(Config parent, FS fs) {
-            return new FileBasedConfig(parent, new File(tempDir, "jgit.config"), FS.detect());
-          }
-
-          @Override
-          public FileBasedConfig openUserConfig(Config parent, FS fs) {
-            return new FileBasedConfig(parent, new File(tempDir, "user.config"), FS.detect());
-          }
-
-          @Override
-          public FileBasedConfig openSystemConfig(Config parent, FS fs) {
-            return new FileBasedConfig(parent, new File(tempDir, "system.config"), FS.detect());
-          }
-        });
-    return oldSystemReader;
-  }
-
-  private void setTimeSettings(
-      boolean useSystemTime,
-      @Nullable UseClockStep useClockStep,
-      @Nullable UseTimezone useTimezone) {
-    if (useSystemTime) {
-      TestTimeUtil.useSystemTime();
-    } else if (useClockStep != null) {
-      TestTimeUtil.resetWithClockStep(useClockStep.clockStep(), useClockStep.clockStepUnit());
-      if (useClockStep.startAtEpoch()) {
-        TestTimeUtil.setClock(Timestamp.from(Instant.EPOCH));
-      }
-    }
-    if (useTimezone != null) {
-      systemTimeZone = System.setProperty("user.timezone", useTimezone.timezone());
-    }
-  }
-
-  private void resetTimeSettings() {
-    TestTimeUtil.useSystemTime();
-    if (systemTimeZone != null) {
-      System.setProperty("user.timezone", systemTimeZone);
-      systemTimeZone = null;
+    if (!configRule.classDescription().skipProjectClone()) {
+      testRepo = cloneProject(project, getCloneAsAccount(configRule.description()));
     }
   }
 
@@ -678,23 +535,6 @@
     return null;
   }
 
-  protected void initSsh() throws Exception {
-    if (testRequiresSsh
-        && SshMode.useSsh()
-        && (adminSshSession == null || userSshSession == null)) {
-      // Create Ssh sessions
-      SshSessionFactory.initSsh();
-      Context ctx = newRequestContext(user);
-      atrScope.set(ctx);
-      userSshSession = ctx.getSession();
-      userSshSession.open();
-      ctx = newRequestContext(admin);
-      atrScope.set(ctx);
-      adminSshSession = ctx.getSession();
-      adminSshSession.open();
-    }
-  }
-
   protected TestAccount getCloneAsAccount(Description description) {
     TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
     return accountCreator.get(ann != null ? ann.cloneAs() : "admin");
@@ -754,9 +594,10 @@
    * @return name prefixed by a string unique to this test method.
    */
   protected String name(String name) {
-    return resourcePrefix + name;
+    return daemonTestRule.name(name);
   }
 
+  @CanIgnoreReturnValue
   protected Project.NameKey createProjectOverAPI(
       String nameSuffix,
       @Nullable Project.NameKey parent,
@@ -778,7 +619,7 @@
 
   protected TestRepository<InMemoryRepository> cloneProject(
       Project.NameKey p, TestAccount testAccount) throws Exception {
-    return GitUtil.cloneProject(p, registerRepoConnection(p, testAccount));
+    return daemonTestRule.cloneProject(p, testAccount);
   }
 
   /**
@@ -786,8 +627,7 @@
    *
    * @return a URI string that can be used to connect to this repository for both fetch and push.
    */
-  protected String registerRepoConnection(Project.NameKey p, TestAccount testAccount)
-      throws Exception {
+  String registerRepoConnection(Project.NameKey p, TestAccount testAccount) throws Exception {
     InProcessProtocol.Context ctx =
         new InProcessProtocol.Context(identifiedUserFactory, testAccount.id(), p);
     Repository repo = repoManager.openRepository(p);
@@ -800,42 +640,11 @@
     for (Repository repo : toClose) {
       repo.close();
     }
-    closeSsh();
-    resetTimeSettings();
-    if (server != commonServer) {
-      server.close();
-      server = null;
-    }
 
-    GerritServer.Description methodDesc =
-        GerritServer.Description.forTestMethod(description, configName);
-    if (methodDesc.systemProperties() != null) {
-      for (GerritSystemProperty sysProp : methodDesc.systemProperties().value()) {
-        System.clearProperty(sysProp.name());
-      }
-    }
-
-    if (methodDesc.systemProperty() != null) {
-      System.clearProperty(methodDesc.systemProperty().name());
-    }
-
-    SystemReader.setInstance(oldSystemReader);
-    oldSystemReader = null;
     // Set useDefaultTicker in afterTest, so the next beforeTest will use the default ticker
     testTicker.useDefaultTicker();
   }
 
-  protected void closeSsh() {
-    if (adminSshSession != null) {
-      adminSshSession.close();
-      adminSshSession = null;
-    }
-    if (userSshSession != null) {
-      userSshSession.close();
-      userSshSession = null;
-    }
-  }
-
   /**
    * Verify that NoteDB commits do not persist user-sensitive information, by running checks for all
    * commits in {@link RefNames#changeMetaRef} for all changes, created during the test.
@@ -874,7 +683,7 @@
             if (accountState.userName().isPresent()) {
               assertThat(fullMessage).doesNotContain(accountState.userName().get());
             }
-            List<String> allEmails =
+            ImmutableList<String> allEmails =
                 accountState.externalIds().stream()
                     .map(ExternalId::email)
                     .filter(Objects::nonNull)
@@ -906,10 +715,12 @@
     return b;
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result createChange() throws Exception {
     return createChange("refs/for/master");
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result createChange(String ref) throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result result = push.to(ref);
@@ -917,6 +728,7 @@
     return result;
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result createChange(TestRepository<InMemoryRepository> repo)
       throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
@@ -925,10 +737,12 @@
     return result;
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result createMergeCommitChange(String ref) throws Exception {
     return createMergeCommitChange(ref, "foo");
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result createMergeCommitChange(String ref, String file) throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
 
@@ -962,6 +776,7 @@
     return result;
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result createNParentsMergeCommitChange(String ref, List<String> fileNames)
       throws Exception {
     // This method creates n different commits and creates a merge commit pointing to all n parents.
@@ -1013,6 +828,7 @@
     return result;
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result createCommitAndPush(
       TestRepository<InMemoryRepository> repo,
       String ref,
@@ -1026,6 +842,7 @@
     return result;
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result createChangeWithTopic(
       TestRepository<InMemoryRepository> repo,
       String topic,
@@ -1038,12 +855,14 @@
         repo, "refs/for/master%topic=" + name(topic), commitMsg, fileName, content);
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result createChange(String subject, String fileName, String content)
       throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content);
     return push.to("refs/for/master");
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result createChange(
       TestRepository<?> repo,
       String branch,
@@ -1057,6 +876,7 @@
         "refs/for/" + branch + (Strings.isNullOrEmpty(topic) ? "" : "%topic=" + name(topic)));
   }
 
+  @CanIgnoreReturnValue
   protected BranchApi createBranch(BranchNameKey branch) throws Exception {
     return gApi.projects()
         .name(branch.project().get())
@@ -1064,6 +884,7 @@
         .create(new BranchInput());
   }
 
+  @CanIgnoreReturnValue
   protected BranchApi createBranchWithRevision(BranchNameKey branch, String revision)
       throws Exception {
     BranchInput in = new BranchInput();
@@ -1074,6 +895,7 @@
   private static final List<Character> RANDOM =
       Chars.asList('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h');
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result amendChangeWithUploader(
       PushOneCommit.Result change, Project.NameKey projectName, TestAccount account)
       throws Exception {
@@ -1092,10 +914,12 @@
     return result;
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result amendChange(String changeId) throws Exception {
     return amendChange(changeId, "refs/for/master", admin, testRepo);
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result amendChange(
       String changeId, String ref, TestAccount testAccount, TestRepository<?> repo)
       throws Exception {
@@ -1110,11 +934,13 @@
         new String(Chars.toArray(RANDOM)));
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result amendChange(
       String changeId, String subject, String fileName, String content) throws Exception {
     return amendChange(changeId, "refs/for/master", admin, testRepo, subject, fileName, content);
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result amendChange(
       String changeId,
       String ref,
@@ -1158,17 +984,6 @@
     return gApi.changes().query(q).get();
   }
 
-  /** Sets up {@code account} as a caller in tests. */
-  public void setRequestScope(TestAccount account) {
-    Context ctx = newRequestContext(account);
-    atrScope.set(ctx);
-  }
-
-  protected Context newRequestContext(TestAccount account) {
-    requestScopeOperations.setApiUser(account.id());
-    return atrScope.get();
-  }
-
   protected Account getAccount(Account.Id accountId) {
     return getAccountState(accountId).account();
   }
@@ -1184,10 +999,8 @@
 
   protected AutoCloseable disableNoteDb() {
     changeNotesArgs.failOnLoadForTest.set(true);
-    Context oldContext = atrScope.disableNoteDb();
     return () -> {
       changeNotesArgs.failOnLoadForTest.set(false);
-      atrScope.set(oldContext);
     };
   }
 
@@ -1240,6 +1053,7 @@
         .update();
   }
 
+  @CanIgnoreReturnValue
   protected PushOneCommit.Result pushTo(String ref) throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     return push.to(ref);
@@ -1358,7 +1172,7 @@
   protected ChangeResource parseChangeResource(String changeId) throws Exception {
     List<ChangeNotes> notes = changeFinder.find(changeId);
     assertThat(notes).hasSize(1);
-    return changeResourceFactory.create(notes.get(0), atrScope.get().getUser());
+    return changeResourceFactory.create(notes.get(0), localCtx.getContext().getUser());
   }
 
   protected RevCommit getHead(Repository repo, String name) throws Exception {
@@ -1736,6 +1550,7 @@
     assertThat(res).isEqualTo(expectedContent);
   }
 
+  @CanIgnoreReturnValue
   protected RevCommit createNewCommitWithoutChangeId(String branch, String file, String content)
       throws Exception {
     try (Repository repo = repoManager.openRepository(project);
@@ -1831,7 +1646,7 @@
     return new ProjectConfigUpdate(projectName);
   }
 
-  protected class ProjectConfigUpdate implements AutoCloseable {
+  public class ProjectConfigUpdate implements AutoCloseable {
     private final ProjectConfig projectConfig;
     private MetaDataUpdate metaDataUpdate;
 
@@ -1953,12 +1768,14 @@
     }
 
     /** Switches to system ticker */
+    @CanIgnoreReturnValue
     public Ticker useDefaultTicker() {
       this.actualTicker = Ticker.systemTicker();
       return actualTicker;
     }
 
     /** Switches to {@link FakeTicker} */
+    @CanIgnoreReturnValue
     public FakeTicker useFakeTicker() {
       if (!(this.actualTicker instanceof FakeTicker)) {
         this.actualTicker = new FakeTicker();
diff --git a/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
index 4e8d20d..11f2a41 100644
--- a/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
@@ -82,6 +82,10 @@
   }
 
   public static class PluginOneSshModule extends CommandModule {
+    public PluginOneSshModule() {
+      super(/* slaveMode= */ false);
+    }
+
     @Override
     public void configure() {
       command(LS_SAMPLES).to(ListSamplesCommand.class);
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 8a9e56a..7681734 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -26,6 +26,7 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
 import com.google.common.truth.Truth;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Address;
@@ -41,6 +42,8 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
@@ -67,10 +70,11 @@
   }
 
   @Override
-  protected ProjectResetter.Config resetProjects() {
+  protected ProjectResetter.Config resetProjects(
+      AllProjectsName allProjects, AllUsersName allUsers) {
     // Don't reset anything so that stagedUsers can be cached across all tests.
     // Without this caching these tests become much too slow.
-    return new ProjectResetter.Config();
+    return new ProjectResetter.Config.Builder().build();
   }
 
   protected static FakeEmailSenderSubject assertThat(FakeEmailSender sender) {
@@ -109,14 +113,14 @@
       fakeEmailSender = target;
     }
 
-    public FakeEmailSenderSubject didNotSend() {
+    public void didNotSend() {
       Message message = fakeEmailSender.peekMessage();
       if (message != null) {
         failWithoutActual(fact("expected no message", message));
       }
-      return this;
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject sent(String messageType, StagedUsers users) {
       message = fakeEmailSender.nextMessage();
       if (message == null) {
@@ -183,18 +187,22 @@
       return addrList.getAddressList().stream().map(Address::email).collect(toList());
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject to(String... emails) {
       return rcpt(users.supportReviewersByEmail ? TO : null, emails);
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject cc(String... emails) {
       return rcpt(users.supportReviewersByEmail ? CC : null, emails);
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject bcc(String... emails) {
       return rcpt(users.supportReviewersByEmail ? BCC : null, emails);
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject title(String expectedEmailTitle) {
       if (!emailTitle.equals(expectedEmailTitle)) {
         failWithoutActual(
@@ -204,6 +212,7 @@
       return this;
     }
 
+    @CanIgnoreReturnValue
     private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, String[] emails) {
       for (String email : emails) {
         rcpt(type, email);
@@ -230,6 +239,7 @@
       }
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject noOneElse() {
       for (Map.Entry<NotifyType, TestAccount> watchEntry : users.watchers.entrySet()) {
         if (!accountedFor.contains(watchEntry.getValue().email())) {
@@ -257,22 +267,27 @@
       return this;
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject notTo(String... emails) {
       return rcpt(null, emails);
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject to(TestAccount... accounts) {
       return rcpt(TO, accounts);
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject cc(TestAccount... accounts) {
       return rcpt(CC, accounts);
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject bcc(TestAccount... accounts) {
       return rcpt(BCC, accounts);
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject notTo(TestAccount... accounts) {
       return rcpt(null, accounts);
     }
@@ -288,18 +303,22 @@
       rcpt(type, account.email());
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject to(NotifyType... watches) {
       return rcpt(TO, watches);
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject cc(NotifyType... watches) {
       return rcpt(CC, watches);
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject bcc(NotifyType... watches) {
       return rcpt(BCC, watches);
     }
 
+    @CanIgnoreReturnValue
     public FakeEmailSenderSubject notTo(NotifyType... watches) {
       return rcpt(null, watches);
     }
@@ -350,7 +369,7 @@
     public boolean supportReviewersByEmail;
 
     private String usersCacheKey() {
-      return description.getClassName();
+      return configRule.description().getClassName();
     }
 
     private TestAccount reindexAndCopy(TestAccount account) {
@@ -491,10 +510,12 @@
     }
   }
 
+  @CanIgnoreReturnValue
   protected StagedPreChange stagePreChange(String ref) throws Exception {
     return new StagedPreChange(ref);
   }
 
+  @CanIgnoreReturnValue
   protected StagedPreChange stagePreChange(
       String ref, @Nullable PushOptionGenerator pushOptionGenerator) throws Exception {
     return new StagedPreChange(ref, pushOptionGenerator);
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index fe845c0..3dced64 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -20,6 +20,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.common.Nullable;
@@ -297,14 +298,15 @@
     assertThat(getter.call(id).get(id)).isNull();
   }
 
-  protected static List<PluginDefinedInfo> pluginInfoFromSingletonList(
+  protected static ImmutableList<PluginDefinedInfo> pluginInfoFromSingletonList(
       List<ChangeInfo> changeInfos) {
     assertThat(changeInfos).hasSize(1);
     return pluginInfoFromChangeInfo(changeInfos.get(0));
   }
 
   @Nullable
-  protected static List<PluginDefinedInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
+  protected static ImmutableList<PluginDefinedInfo> pluginInfoFromChangeInfo(
+      ChangeInfo changeInfo) {
     List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
     if (pluginInfo == null) {
       return null;
diff --git a/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java b/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
deleted file mode 100644
index c4bf20c..0000000
--- a/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
+++ /dev/null
@@ -1,183 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.RequestCleanup;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestScopePropagator;
-import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.inject.Inject;
-import com.google.inject.Key;
-import com.google.inject.OutOfScopeException;
-import com.google.inject.Provider;
-import com.google.inject.Scope;
-import java.util.HashMap;
-import java.util.Map;
-
-/** Guice scopes for state during an Acceptance Test connection. */
-public class AcceptanceTestRequestScope {
-  private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
-
-  public static class Context implements RequestContext {
-    private final RequestCleanup cleanup = new RequestCleanup();
-    private final Map<Key<?>, Object> map = new HashMap<>();
-    private final SshSession session;
-    private final CurrentUser user;
-
-    final long created;
-    volatile long started;
-    volatile long finished;
-
-    private Context(SshSession s, CurrentUser u, long at) {
-      session = s;
-      user = u;
-      created = started = finished = at;
-      map.put(RC_KEY, cleanup);
-    }
-
-    private Context(Context p, SshSession s, CurrentUser c) {
-      this(s, c, p.created);
-      started = p.started;
-      finished = p.finished;
-    }
-
-    public SshSession getSession() {
-      return session;
-    }
-
-    @Override
-    public CurrentUser getUser() {
-      if (user == null) {
-        throw new IllegalStateException("user == null, forgot to set it?");
-      }
-      return user;
-    }
-
-    synchronized <T> T get(Key<T> key, Provider<T> creator) {
-      @SuppressWarnings("unchecked")
-      T t = (T) map.get(key);
-      if (t == null) {
-        t = creator.get();
-        map.put(key, t);
-      }
-      return t;
-    }
-  }
-
-  static class ContextProvider implements Provider<Context> {
-    @Override
-    public Context get() {
-      return requireContext();
-    }
-  }
-
-  static class SshSessionProvider implements Provider<SshSession> {
-    @Override
-    public SshSession get() {
-      return requireContext().getSession();
-    }
-  }
-
-  static class Propagator extends ThreadLocalRequestScopePropagator<Context> {
-    private final AcceptanceTestRequestScope atrScope;
-
-    @Inject
-    Propagator(AcceptanceTestRequestScope atrScope, ThreadLocalRequestContext local) {
-      super(REQUEST, current, local);
-      this.atrScope = atrScope;
-    }
-
-    @Override
-    protected Context continuingContext(Context ctx) {
-      // The cleanup is not chained, since the RequestScopePropagator executors
-      // the Context's cleanup when finished executing.
-      return atrScope.newContinuingContext(ctx);
-    }
-  }
-
-  private static final ThreadLocal<Context> current = new ThreadLocal<>();
-
-  private static Context requireContext() {
-    final Context ctx = current.get();
-    if (ctx == null) {
-      throw new OutOfScopeException("Not in command/request");
-    }
-    return ctx;
-  }
-
-  private final ThreadLocalRequestContext local;
-
-  @Inject
-  AcceptanceTestRequestScope(ThreadLocalRequestContext local) {
-    this.local = local;
-  }
-
-  public Context newContext(SshSession s, CurrentUser user) {
-    return new Context(s, user, TimeUtil.nowMs());
-  }
-
-  private Context newContinuingContext(Context ctx) {
-    return new Context(ctx, ctx.getSession(), ctx.getUser());
-  }
-
-  public Context set(Context ctx) {
-    Context old = current.get();
-    current.set(ctx);
-    local.setContext(ctx);
-    return old;
-  }
-
-  public Context get() {
-    return current.get();
-  }
-
-  /**
-   * Disables read and write access to NoteDb and returns the context prior to that modification.
-   */
-  public Context disableNoteDb() {
-    Context old = current.get();
-    Context ctx = new Context(old.session, old.user, old.created);
-
-    current.set(ctx);
-    local.setContext(ctx);
-    return old;
-  }
-
-  /** Returns exactly one instance per command executed. */
-  static final Scope REQUEST =
-      new Scope() {
-        @Override
-        public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<>() {
-            @Override
-            public T get() {
-              return requireContext().get(key, creator);
-            }
-
-            @Override
-            public String toString() {
-              return String.format("%s[%s]", creator, REQUEST);
-            }
-          };
-        }
-
-        @Override
-        public String toString() {
-          return "Acceptance Test Scope.REQUEST";
-        }
-      };
-}
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index f3881f2..8654f82 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.gerrit.common.UsedAt.Project.GOOGLE;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
@@ -56,7 +58,8 @@
   private final ExternalIdFactory externalIdFactory;
 
   @Inject
-  AccountCreator(
+  @UsedAt(GOOGLE)
+  protected AccountCreator(
       Sequences sequences,
       @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       GroupCache groupCache,
@@ -87,7 +90,7 @@
     List<ExternalId> extIds = new ArrayList<>(2);
     String httpPass = null;
     if (username != null) {
-      httpPass = "http-pass";
+      httpPass = externalIdFactory.arePasswordsAllowed() ? "http-pass" : null;
       extIds.add(externalIdFactory.createUsername(username, id, httpPass));
     }
 
@@ -107,14 +110,9 @@
                     .addExternalIds(extIds));
 
     ImmutableList.Builder<String> tags = ImmutableList.builder();
+    addUserToGroups(id, groupNames);
     if (groupNames != null) {
       for (String n : groupNames) {
-        AccountGroup.NameKey k = AccountGroup.nameKey(n);
-        Optional<InternalGroup> group = groupCache.get(k);
-        if (!group.isPresent()) {
-          throw new NoSuchGroupException(n);
-        }
-        addGroupMember(group.get().getGroupUUID(), id);
         if (ServiceUserClassifier.SERVICE_USERS.equals(n)) {
           tags.add("SERVICE_USER");
         }
@@ -129,6 +127,19 @@
     return account;
   }
 
+  protected void addUserToGroups(Account.Id id, String... groupNames) throws Exception {
+    if (groupNames != null) {
+      for (String n : groupNames) {
+        AccountGroup.NameKey k = AccountGroup.nameKey(n);
+        Optional<InternalGroup> group = groupCache.get(k);
+        if (!group.isPresent()) {
+          throw new NoSuchGroupException(n);
+        }
+        addGroupMember(group.get().getGroupUUID(), id);
+      }
+    }
+  }
+
   public TestAccount create(@Nullable String username, String group) throws Exception {
     return create(username, null, username, null, group);
   }
@@ -178,7 +189,7 @@
     return ImmutableList.copyOf(accounts.values());
   }
 
-  private void addGroupMember(AccountGroup.UUID groupUuid, Account.Id accountId)
+  protected void addGroupMember(AccountGroup.UUID groupUuid, Account.Id accountId)
       throws IOException, NoSuchGroupException, ConfigInvalidException {
     GroupDelta groupDelta =
         GroupDelta.builder()
diff --git a/java/com/google/gerrit/acceptance/AccountIndexedCounter.java b/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
index 88b97c7..17e0559 100644
--- a/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
+++ b/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
@@ -52,6 +52,11 @@
     countsByAccount.remove(accountId.get());
   }
 
+  public void assertReindexAtLeastOnceOf(Account.Id accountId) {
+    assertThat(countsByAccount.asMap().getOrDefault(accountId.get(), 0L)).isAtLeast(1);
+    countsByAccount.remove(accountId.get());
+  }
+
   public void assertNoReindex() {
     assertThat(countsByAccount.asMap()).isEmpty();
   }
diff --git a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
index 5991646..98287c8 100644
--- a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
+++ b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
@@ -41,6 +41,10 @@
     return countsByChange.get(info._number);
   }
 
+  public long getTotalCount() {
+    return countsByChange.asMap().values().stream().reduce(0L, Long::sum);
+  }
+
   public void assertReindexOf(ChangeInfo info) {
     assertReindexOf(info, 1);
   }
diff --git a/java/com/google/gerrit/acceptance/DaemonTestRule.java b/java/com/google/gerrit/acceptance/DaemonTestRule.java
new file mode 100644
index 0000000..fc2a0cc
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/DaemonTestRule.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.rules.TestRule;
+
+/**
+ * Test rule required to run {@link AbstractDaemonTest}.
+ *
+ * <p>The Google internal implementation uses own infrastructure instead of the {@link
+ * GerritServer}.
+ */
+@UsedAt(Project.GOOGLE)
+public interface DaemonTestRule extends TestRule {
+  TestConfigRule configRule();
+
+  ServerTestRule server();
+
+  TimeSettingsTestRule timeSettingsRule();
+
+  TestRepository<InMemoryRepository> cloneProject(NameKey p, TestAccount testAccount)
+      throws Exception;
+
+  String name(String name);
+}
diff --git a/java/com/google/gerrit/acceptance/EventRecorder.java b/java/com/google/gerrit/acceptance/EventRecorder.java
index 1618573..702b3b4 100644
--- a/java/com/google/gerrit/acceptance/EventRecorder.java
+++ b/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -172,7 +172,8 @@
   }
 
   public void assertNoRefUpdatedEvents(String project, String branch) throws Exception {
-    getRefUpdatedEvents(project, branch, 0);
+    @SuppressWarnings("unused")
+    var unused = getRefUpdatedEvents(project, branch, 0);
   }
 
   public void assertRefUpdatedEvents(String project, String branch, String... expected)
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index f3527f0..d2051d5 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
@@ -45,6 +46,7 @@
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeETagComputation;
+import com.google.gerrit.server.change.FilterIncludedIn;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.ChangeMessageModifier;
@@ -87,6 +89,7 @@
   private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
   private final DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners;
   private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
+  private final DynamicSet<FilterIncludedIn> filterIncludedIns;
   private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
   private final DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks;
   private final DynamicSet<EditWebLink> editWebLinks;
@@ -133,6 +136,7 @@
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
       DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
+      DynamicSet<FilterIncludedIn> filterIncludedIns,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
       DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks,
       DynamicSet<EditWebLink> editWebLinks,
@@ -174,6 +178,7 @@
     this.refUpdatedListeners = refUpdatedListeners;
     this.batchRefUpdateListeners = batchRefUpdateListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
+    this.filterIncludedIns = filterIncludedIns;
     this.patchSetWebLinks = patchSetWebLinks;
     this.editWebLinks = editWebLinks;
     this.fileWebLinks = fileWebLinks;
@@ -205,172 +210,219 @@
   public class Registration implements AutoCloseable {
     private final List<RegistrationHandle> registrationHandles = new ArrayList<>();
 
+    @CanIgnoreReturnValue
     public Registration add(AccountIndexedListener accountIndexedListener) {
       return add(accountIndexedListeners, accountIndexedListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ChangeIndexedListener changeIndexedListener) {
       return add(changeIndexedListeners, changeIndexedListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(GroupIndexedListener groupIndexedListener) {
       return add(groupIndexedListeners, groupIndexedListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ProjectIndexedListener projectIndexedListener) {
       return add(projectIndexedListeners, projectIndexedListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(CommitValidationListener commitValidationListener) {
       return add(commitValidationListeners, commitValidationListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(TopicEditedListener topicEditedListener) {
       return add(topicEditedListeners, topicEditedListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ExceptionHook exceptionHook) {
       return add(exceptionHooks, exceptionHook);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(PerformanceLogger performanceLogger) {
       return add(performanceLoggers, performanceLogger);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ProjectCreationValidationListener projectCreationListener) {
       return add(projectCreationValidationListeners, projectCreationListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(SubmitRule submitRule) {
       return add(submitRules, submitRule);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(SubmitRequirement submitRequirement) {
       return add(submitRequirements, submitRequirement);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ChangeHasOperandFactory hasOperand, String exportName) {
       return add(hasOperands, hasOperand, exportName);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ChangeIsOperandFactory isOperand, String exportName) {
       return add(isOperands, isOperand, exportName);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ChangeMessageModifier changeMessageModifier) {
       return add(changeMessageModifiers, changeMessageModifier);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ChangeMessageModifier changeMessageModifier, String exportName) {
       return add(changeMessageModifiers, changeMessageModifier, exportName);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ChangeETagComputation changeETagComputation) {
       return add(changeETagComputations, changeETagComputation);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ActionVisitor actionVisitor) {
       return add(actionVisitors, actionVisitor);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(DownloadScheme downloadScheme, String exportName) {
       return add(downloadSchemes, downloadScheme, exportName);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(RefOperationValidationListener refOperationValidationListener) {
       return add(refOperationValidationListeners, refOperationValidationListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(CommentAddedListener commentAddedListener) {
       return add(commentAddedListeners, commentAddedListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(GitReferenceUpdatedListener refUpdatedListener) {
       return add(refUpdatedListeners, refUpdatedListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(GitBatchRefUpdateListener batchRefUpdateListener) {
       return add(batchRefUpdateListeners, batchRefUpdateListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(FileHistoryWebLink fileHistoryWebLink) {
       return add(fileHistoryWebLinks, fileHistoryWebLink);
     }
 
+    @CanIgnoreReturnValue
+    public Registration add(FilterIncludedIn filterIncludedIn) {
+      return add(filterIncludedIns, filterIncludedIn);
+    }
+
+    @CanIgnoreReturnValue
     public Registration add(PatchSetWebLink patchSetWebLink) {
       return add(patchSetWebLinks, patchSetWebLink);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ResolveConflictsWebLink resolveConflictsWebLink) {
       return add(resolveConflictsWebLinks, resolveConflictsWebLink);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(EditWebLink editWebLink) {
       return add(editWebLinks, editWebLink);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(FileWebLink fileWebLink) {
       return add(fileWebLinks, fileWebLink);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(RevisionCreatedListener revisionCreatedListener) {
       return add(revisionCreatedListeners, revisionCreatedListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(GroupBackend groupBackend) {
       return add(groupBackends, groupBackend);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(
         AccountActivationValidationListener accountActivationValidationListener) {
       return add(accountActivationValidationListeners, accountActivationValidationListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(AccountActivationListener accountDeactivatedListener) {
       return add(accountActivationListeners, accountDeactivatedListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(OnSubmitValidationListener onSubmitValidationListener) {
       return add(onSubmitValidationListeners, onSubmitValidationListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(WorkInProgressStateChangedListener workInProgressStateChangedListener) {
       return add(workInProgressStateChangedListeners, workInProgressStateChangedListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(AttentionSetListener attentionSetListener) {
       return add(attentionSetListeners, attentionSetListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(CapabilityDefinition capabilityDefinition, String exportName) {
       return add(capabilityDefinitions, capabilityDefinition, exportName);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(
         PluginProjectPermissionDefinition pluginProjectPermissionDefinition, String exportName) {
       return add(pluginProjectPermissionDefinitions, pluginProjectPermissionDefinition, exportName);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ProjectConfigEntry pluginConfigEntry, String exportName) {
       return add(pluginConfigEntries, pluginConfigEntry, exportName);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(PluginPushOption pluginPushOption) {
       return add(pluginPushOptions, pluginPushOption);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(OnPostReview onPostReview) {
       return add(onPostReviews, onPostReview);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ReviewerAddedListener reviewerAddedListener) {
       return add(reviewerAddedListeners, reviewerAddedListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ReviewerDeletedListener reviewerDeletedListener) {
       return add(reviewerDeletedListeners, reviewerDeletedListener);
     }
 
+    @CanIgnoreReturnValue
     public Registration add(ReviewerSuggestion reviewerSuggestion, String exportName) {
       return add(reviewerSuggestions, reviewerSuggestion, exportName);
     }
diff --git a/java/com/google/gerrit/acceptance/FakeGroupAuditService.java b/java/com/google/gerrit/acceptance/FakeGroupAuditService.java
index a1c28b9..433c149 100644
--- a/java/com/google/gerrit/acceptance/FakeGroupAuditService.java
+++ b/java/com/google/gerrit/acceptance/FakeGroupAuditService.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Ints;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.httpd.GitOverHttpServlet;
 import com.google.gerrit.server.AuditEvent;
@@ -77,6 +78,7 @@
     }
   }
 
+  @CanIgnoreReturnValue
   public ImmutableList<HttpAuditEvent> drainHttpAuditEvents() throws Exception {
     // Assumes that all HttpAuditEvents are produced by GitOverHttpServlet.
     int expectedSize = Ints.checkedCast(httpMetrics.getRequestsStarted() - drainedSoFar.get());
diff --git a/java/com/google/gerrit/acceptance/GerritServerDaemonTestRule.java b/java/com/google/gerrit/acceptance/GerritServerDaemonTestRule.java
new file mode 100644
index 0000000..4f4d67a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/GerritServerDaemonTestRule.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.entities.Project.NameKey;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.rules.RuleChain;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+/** Implements {@link DaemonTestRule} using {@link GerritServer}. */
+public class GerritServerDaemonTestRule implements DaemonTestRule {
+  public static GerritServerDaemonTestRule create(AbstractDaemonTest test) {
+    TestConfigRule configRule = new TestConfigRule(AbstractDaemonTest.temporaryFolder, test);
+    GerritServerTestRule server =
+        new GerritServerTestRule(
+            configRule,
+            AbstractDaemonTest.temporaryFolder,
+            () -> test.createModule(),
+            () -> test.createAuditModule(),
+            () -> test.createSshModule());
+    TimeSettingsTestRule timeSettingsRule = new TimeSettingsTestRule(configRule);
+    return new GerritServerDaemonTestRule(test, configRule, server, timeSettingsRule);
+  }
+
+  private final RuleChain ruleChain;
+  private final TestConfigRule configRule;
+  private final ServerTestRule server;
+
+  private final TimeSettingsTestRule timeSettingsRule;
+
+  private final AbstractDaemonTest test;
+
+  private GerritServerDaemonTestRule(
+      AbstractDaemonTest test,
+      TestConfigRule configRule,
+      ServerTestRule server,
+      TimeSettingsTestRule timeSettingsRule) {
+    this.configRule = configRule;
+    this.server = server;
+    this.timeSettingsRule = timeSettingsRule;
+    this.test = test;
+    // Set the clock step as almost the last step, so that the test setup isn't consuming any
+    // timestamps after the
+    // clock has been set.
+    ruleChain =
+        RuleChain.outerRule(configRule)
+            .around(server)
+            .around(test.testRunner)
+            .around(timeSettingsRule);
+  }
+
+  @Override
+  public TestConfigRule configRule() {
+    return configRule;
+  }
+
+  @Override
+  public ServerTestRule server() {
+    return server;
+  }
+
+  @Override
+  public TimeSettingsTestRule timeSettingsRule() {
+    return timeSettingsRule;
+  }
+
+  @Override
+  public TestRepository<InMemoryRepository> cloneProject(NameKey p, TestAccount testAccount)
+      throws Exception {
+    return GitUtil.cloneProject(p, test.registerRepoConnection(p, testAccount));
+  }
+
+  @Override
+  public String name(String name) {
+    return test.resourcePrefix + name;
+  }
+
+  @Override
+  public Statement apply(Statement statement, Description description) {
+    return ruleChain.apply(statement, description);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/GerritServerRestSession.java b/java/com/google/gerrit/acceptance/GerritServerRestSession.java
new file mode 100644
index 0000000..c2c77fe
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/GerritServerRestSession.java
@@ -0,0 +1,148 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.net.HttpHeaders.ACCEPT;
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
+import static com.google.gerrit.json.OutputFormat.JSON_COMPACT;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.RawInput;
+import java.io.IOException;
+import org.apache.http.Header;
+import org.apache.http.client.fluent.Request;
+import org.apache.http.entity.BufferedHttpEntity;
+import org.apache.http.entity.InputStreamEntity;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.message.BasicHeader;
+
+/** Sends requests to {@link GerritServer} as a specified user. */
+public class GerritServerRestSession extends HttpSession implements RestSession {
+
+  public GerritServerRestSession(GerritServer server, @Nullable TestAccount account) {
+    super(server, account);
+  }
+
+  @Override
+  public RestResponse get(String endPoint) throws IOException {
+    return getWithHeaders(endPoint);
+  }
+
+  @Override
+  public RestResponse getJsonAccept(String endPoint) throws IOException {
+    return getWithHeaders(endPoint, new BasicHeader(ACCEPT, "application/json"));
+  }
+
+  @Override
+  public RestResponse getWithHeaders(String endPoint, Header... headers) throws IOException {
+    Request get = Request.Get(getUrl(endPoint));
+    if (headers != null) {
+      get.setHeaders(headers);
+    }
+    return execute(get);
+  }
+
+  @Override
+  public RestResponse head(String endPoint) throws IOException {
+    return execute(Request.Head(getUrl(endPoint)));
+  }
+
+  @Override
+  public RestResponse put(String endPoint) throws IOException {
+    return put(endPoint, /* content = */ null);
+  }
+
+  @Override
+  public RestResponse put(String endPoint, Object content) throws IOException {
+    return putWithHeaders(endPoint, content);
+  }
+
+  @Override
+  public RestResponse putWithHeaders(String endPoint, Header... headers) throws IOException {
+    return putWithHeaders(endPoint, /* content= */ null, headers);
+  }
+
+  @Override
+  public RestResponse putWithHeaders(String endPoint, Object content, Header... headers)
+      throws IOException {
+    Request put = Request.Put(getUrl(endPoint));
+    if (headers != null) {
+      put.setHeaders(headers);
+    }
+    if (content != null) {
+      addContentToRequest(put, content);
+    }
+    return execute(put);
+  }
+
+  @Override
+  public RestResponse putRaw(String endPoint, RawInput stream) throws IOException {
+    requireNonNull(stream);
+    Request put = Request.Put(getUrl(endPoint));
+    put.addHeader(new BasicHeader(CONTENT_TYPE, stream.getContentType()));
+    put.body(
+        new BufferedHttpEntity(
+            new InputStreamEntity(stream.getInputStream(), stream.getContentLength())));
+    return execute(put);
+  }
+
+  @Override
+  public RestResponse post(String endPoint) throws IOException {
+    return post(endPoint, /* content = */ null);
+  }
+
+  @Override
+  public RestResponse post(String endPoint, Object content) throws IOException {
+    return postWithHeaders(endPoint, content);
+  }
+
+  @Override
+  public RestResponse postWithHeaders(String endPoint, Object content, Header... headers)
+      throws IOException {
+    Request post = Request.Post(getUrl(endPoint));
+    if (headers != null) {
+      post.setHeaders(headers);
+    }
+    if (content != null) {
+      addContentToRequest(post, content);
+    }
+    return execute(post);
+  }
+
+  private static void addContentToRequest(Request request, Object content) {
+    request.addHeader(new BasicHeader(CONTENT_TYPE, "application/json"));
+    request.body(new StringEntity(JSON_COMPACT.newGson().toJson(content), UTF_8));
+  }
+
+  @Override
+  public RestResponse delete(String endPoint) throws IOException {
+    return execute(Request.Delete(getUrl(endPoint)));
+  }
+
+  @Override
+  public RestResponse deleteWithHeaders(String endPoint, Header... headers) throws IOException {
+    Request delete = Request.Delete(getUrl(endPoint));
+    if (headers != null) {
+      delete.setHeaders(headers);
+    }
+    return execute(delete);
+  }
+
+  private String getUrl(String endPoint) {
+    return url + (account != null ? "/a" : "") + endPoint;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/GerritServerTestRule.java b/java/com/google/gerrit/acceptance/GerritServerTestRule.java
new file mode 100644
index 0000000..d3bc008
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/GerritServerTestRule.java
@@ -0,0 +1,285 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.acceptance.GerritServer.TestSshServerAddress;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.testing.SshMode;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Module;
+import java.net.InetSocketAddress;
+import java.util.HashMap;
+import java.util.Optional;
+import java.util.function.BiFunction;
+import java.util.function.Supplier;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+public class GerritServerTestRule implements ServerTestRule {
+  /**
+   * Test methods without special annotations will use a common server for efficiency reasons. The
+   * server is torn down after the test class is done or when the config is changed.
+   */
+  private static GerritServer commonServer;
+
+  private static Description firstTest;
+
+  private final TemporaryFolder temporaryFolder;
+  @Nullable private final Supplier<Module> testSysModule;
+  @Nullable private final Supplier<Module> testAuditModule;
+  @Nullable private final Supplier<Module> testSshModule;
+  private final TestConfigRule config;
+
+  @Inject private TestSshKeys sshKeys;
+  @Inject @Nullable @TestSshServerAddress private InetSocketAddress sshAddress;
+
+  @Inject private AccountOperations accountOperations;
+
+  private boolean sshInitialized;
+
+  private final HashMap<RequestContext, SshSession> sshSessionByContext = new HashMap<>();
+
+  public GerritServer server;
+
+  public GerritServerTestRule(
+      TestConfigRule config,
+      TemporaryFolder temporaryFolder,
+      @Nullable Supplier<Module> testSysModule,
+      @Nullable Supplier<Module> testAuditModule,
+      @Nullable Supplier<Module> testSshModule) {
+    this.config = config;
+    this.testSysModule = testSysModule;
+    this.testAuditModule = testAuditModule;
+    this.testSshModule = testSshModule;
+    this.temporaryFolder = temporaryFolder;
+  }
+
+  @Override
+  public Statement apply(Statement statement, Description description) {
+    return new Statement() {
+      @Override
+      public void evaluate() throws Throwable {
+        if (firstTest == null) {
+          firstTest = description;
+        }
+        if (config.testRequiresSsh()) {
+          // If the test uses ssh, we use assume() to make sure ssh is enabled on
+          // the test suite. JUnit will skip tests annotated with @UseSsh if we
+          // disable them using the command line flag.
+          assume().that(SshMode.useSsh()).isTrue();
+        }
+        statement.evaluate();
+        afterTest();
+      }
+    };
+  }
+
+  public void afterTest() throws Exception {
+    closeSsh();
+    if (server != commonServer) {
+      server.close();
+      server = null;
+    }
+  }
+
+  @Override
+  public void initServer() throws Exception {
+
+    if (config.classDescription().equals(config.methodDescription())
+        && !config.classDescription().sandboxed()
+        && !config.methodDescription().sandboxed()) {
+      if (commonServer == null) {
+        commonServer =
+            GerritServer.initAndStart(
+                temporaryFolder,
+                config.classDescription(),
+                config.baseConfig(),
+                testSysModule.get(),
+                testAuditModule.get(),
+                testSshModule.get());
+      }
+      server = commonServer;
+    } else {
+      server =
+          GerritServer.initAndStart(
+              temporaryFolder,
+              config.methodDescription(),
+              config.baseConfig(),
+              testSysModule.get(),
+              testAuditModule.get(),
+              testSshModule.get());
+    }
+    getTestInjector().injectMembers(this);
+  }
+
+  @Override
+  public void initSsh() throws Exception {
+    if (config.testRequiresSsh() && SshMode.useSsh()) {
+      checkState(!sshInitialized, "The ssh has been alread initialized. Call closeSsh first.");
+      // Create Ssh sessions
+      SshSessionFactory.initSsh();
+      sshInitialized = true;
+    }
+  }
+
+  @Override
+  public boolean sshInitialized() {
+    return sshInitialized;
+  }
+
+  @Override
+  public SshSession getOrCreateSshSessionForContext(RequestContext ctx) {
+    checkState(
+        config.testRequiresSsh(),
+        "The test or the test class must be annotated with @UseSsh to use this method.");
+    return sshSessionByContext.computeIfAbsent(
+        ctx,
+        (c) ->
+            SshSessionFactory.createSession(
+                sshKeys,
+                sshAddress,
+                accountOperations.account(ctx.getUser().getAccountId()).get()));
+  }
+
+  /**
+   * This method is only required for some tests and is not a part of interface.
+   *
+   * <p>After restarting the server with this method, the caller can still get exit value of the
+   * last command executed before restarting (using non-closed sessions). This is used in
+   * SshDaemonIT tests.
+   */
+  public void restartKeepSessionOpen() throws Exception {
+    checkState(
+        server != commonServer,
+        "The commonServer can't be restarted; to use this method, the test must be @Sandboxed");
+    server = GerritServer.restart(server, testSysModule.get(), testSshModule.get());
+    getTestInjector().injectMembers(this);
+  }
+
+  @Override
+  public void restart() throws Exception {
+    checkState(
+        server != commonServer,
+        "The commonServer can't be restarted; to use this method, the test must be @Sandboxed");
+    closeSsh();
+    server = GerritServer.restart(server, testSysModule.get(), testSshModule.get());
+    getTestInjector().injectMembers(this);
+    initSsh();
+  }
+
+  @Override
+  public void restartAsSlave() throws Exception {
+    checkState(
+        server != commonServer,
+        "The commonServer can't be restarted; to use this method, the test must be @Sandboxed");
+    closeSsh();
+    server = GerritServer.restartAsSlave(server);
+    getTestInjector().injectMembers(this);
+    initSsh();
+  }
+
+  protected void closeSsh() {
+    sshSessionByContext.values().forEach(SshSession::close);
+    sshSessionByContext.clear();
+    sshInitialized = false;
+  }
+
+  @Override
+  public Injector getTestInjector() {
+    return server.getTestInjector();
+  }
+
+  @Override
+  public Optional<Injector> getHttpdInjector() {
+    return server.getHttpdInjector();
+  }
+
+  @Override
+  public RestSession createRestSession(@Nullable TestAccount account) {
+    return new GerritServerRestSession(server, account);
+  }
+
+  @Nullable
+  @Override
+  public ProjectResetter createProjectResetter(
+      BiFunction<AllProjectsName, AllUsersName, ProjectResetter.Config> resetConfigSupplier)
+      throws Exception {
+    // Only commonServer can be shared between tests and should be restored after each
+    // test. It can be that the commonServer is started, but a test actually don't use
+    // it and instead the test uses a separate server instance.
+    // In this case, the separate server is stopped after each test and so doesn't require
+    // cleanup (for simplicity, the commonServer is always cleaned up even if it is not
+    // used in a test).
+    if (commonServer == null) {
+      return null;
+    }
+    Injector testInjector = commonServer.testInjector;
+    ProjectResetter.Config config =
+        requireNonNull(
+            resetConfigSupplier.apply(
+                testInjector.getInstance(AllProjectsName.class),
+                testInjector.getInstance(AllUsersName.class)));
+    ProjectResetter.Builder.Factory projectResetterFactory =
+        testInjector.getInstance(ProjectResetter.Builder.Factory.class);
+    return projectResetterFactory != null ? projectResetterFactory.builder().build(config) : null;
+  }
+
+  @Override
+  public boolean isReplica() {
+    return server.isReplica();
+  }
+
+  @Override
+  public Optional<InetSocketAddress> getHttpAddress() {
+    return server.getHttpAddress();
+  }
+
+  @Override
+  public String getGitUrl() {
+    return server.getUrl();
+  }
+
+  @Override
+  public boolean isUsernameSupported() {
+    return true;
+  }
+
+  public static void afterConfigChanged() {
+    if (commonServer != null) {
+      try {
+        commonServer.close();
+      } catch (Exception e) {
+        throw new AssertionError(
+            "Error stopping common server in "
+                + (firstTest != null ? firstTest.getTestClass().getName() : "unknown test class"),
+            e);
+      } finally {
+        commonServer = null;
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/GitUtil.java b/java/com/google/gerrit/acceptance/GitUtil.java
index 94d329d..335e97c 100644
--- a/java/com/google/gerrit/acceptance/GitUtil.java
+++ b/java/com/google/gerrit/acceptance/GitUtil.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.Project;
 import java.io.IOException;
@@ -98,6 +99,7 @@
     return testRepo;
   }
 
+  @CanIgnoreReturnValue
   public static Ref createAnnotatedTag(TestRepository<?> testRepo, String name, PersonIdent tagger)
       throws GitAPIException {
     TagCommand cmd =
@@ -105,6 +107,7 @@
     return cmd.call();
   }
 
+  @CanIgnoreReturnValue
   public static Ref updateAnnotatedTag(TestRepository<?> testRepo, String name, PersonIdent tagger)
       throws GitAPIException {
     TagCommand tc = testRepo.git().tag().setName(name);
@@ -117,21 +120,25 @@
     fetch.call();
   }
 
+  @CanIgnoreReturnValue
   public static PushResult pushHead(TestRepository<?> testRepo, String ref) throws GitAPIException {
     return pushHead(testRepo, ref, false);
   }
 
+  @CanIgnoreReturnValue
   public static PushResult pushHead(TestRepository<?> testRepo, String ref, boolean pushTags)
       throws GitAPIException {
     return pushHead(testRepo, ref, pushTags, false);
   }
 
+  @CanIgnoreReturnValue
   public static PushResult pushHead(
       TestRepository<?> testRepo, String ref, boolean pushTags, boolean force)
       throws GitAPIException {
     return pushOne(testRepo, "HEAD", ref, pushTags, force, null);
   }
 
+  @CanIgnoreReturnValue
   public static PushResult pushHead(
       TestRepository<?> testRepo,
       String ref,
@@ -142,11 +149,13 @@
     return pushOne(testRepo, "HEAD", ref, pushTags, force, pushOptions);
   }
 
+  @CanIgnoreReturnValue
   public static PushResult deleteRef(TestRepository<?> testRepo, String ref)
       throws GitAPIException {
     return pushOne(testRepo, "", ref, false, true, null);
   }
 
+  @CanIgnoreReturnValue
   public static PushResult pushOne(
       TestRepository<?> testRepo,
       String source,
diff --git a/java/com/google/gerrit/acceptance/HttpSession.java b/java/com/google/gerrit/acceptance/HttpSession.java
index 833c53b..ef14a3d 100644
--- a/java/com/google/gerrit/acceptance/HttpSession.java
+++ b/java/com/google/gerrit/acceptance/HttpSession.java
@@ -25,7 +25,7 @@
 import org.apache.http.impl.client.HttpClientBuilder;
 
 public class HttpSession {
-  protected TestAccount account;
+  protected @Nullable TestAccount account;
   protected final String url;
   private final Executor executor;
 
diff --git a/java/com/google/gerrit/acceptance/InProcessProtocol.java b/java/com/google/gerrit/acceptance/InProcessProtocol.java
index 17ce595..abcc108 100644
--- a/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ b/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -230,7 +230,9 @@
       // have an easy way to run code when this instance is done being used.
       // Each operation is run in its own thread, so we don't need to recover
       // its original context anyway.
-      threadContext.setContext(req);
+      @SuppressWarnings("unused")
+      var unused = threadContext.setContext(req);
+
       current.set(req);
 
       PermissionBackend.ForProject perm = permissionBackend.currentUser().project(req.project);
@@ -300,7 +302,9 @@
       // have an easy way to run code when this instance is done being used.
       // Each operation is run in its own thread, so we don't need to recover
       // its original context anyway.
-      threadContext.setContext(req);
+      @SuppressWarnings("unused")
+      var unused = threadContext.setContext(req);
+
       current.set(req);
       try {
         permissionBackend
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
index c8ab1a9..c71641b 100644
--- a/java/com/google/gerrit/acceptance/ProjectResetter.java
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -20,6 +20,8 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Sets;
@@ -137,19 +139,37 @@
   }
 
   public static class Config {
-    private final Multimap<Project.NameKey, String> refsByProject;
+    private final ImmutableMultimap<Project.NameKey, String> refsByProject;
 
-    public Config() {
-      this.refsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
+    private Config(ImmutableMultimap<Project.NameKey, String> refsByProject) {
+      this.refsByProject = refsByProject;
     }
 
-    public Config reset(Project.NameKey project, String... refPatterns) {
-      List<String> refPatternList = Arrays.asList(refPatterns);
-      if (refPatternList.isEmpty()) {
-        refPatternList = ImmutableList.of(RefNames.REFS + "*");
+    public Builder toBuilder() {
+      Builder builder = new Builder();
+      builder.refsByProject.putAll(refsByProject);
+      return builder;
+    }
+
+    public static class Builder {
+      private final ListMultimap<Project.NameKey, String> refsByProject;
+
+      public Builder() {
+        this.refsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
       }
-      refsByProject.putAll(project, refPatternList);
-      return this;
+
+      public Builder reset(Project.NameKey project, String... refPatterns) {
+        List<String> refPatternList = Arrays.asList(refPatterns);
+        if (refPatternList.isEmpty()) {
+          refPatternList = ImmutableList.of(RefNames.REFS + "*");
+        }
+        refsByProject.putAll(project, refPatternList);
+        return this;
+      }
+
+      public Config build() {
+        return new Config(ImmutableMultimap.copyOf(refsByProject));
+      }
     }
   }
 
@@ -166,12 +186,12 @@
   private final Multimap<Project.NameKey, String> refsPatternByProject;
 
   // State to which to reset to.
-  private final Multimap<Project.NameKey, RefState> savedRefStatesByProject;
+  private final ListMultimap<Project.NameKey, RefState> savedRefStatesByProject;
 
   // Results of the resetting
-  private Multimap<Project.NameKey, String> keptRefsByProject;
-  private Multimap<Project.NameKey, String> restoredRefsByProject;
-  private Multimap<Project.NameKey, String> deletedRefsByProject;
+  private ListMultimap<Project.NameKey, String> keptRefsByProject;
+  private ListMultimap<Project.NameKey, String> restoredRefsByProject;
+  private ListMultimap<Project.NameKey, String> deletedRefsByProject;
 
   private ProjectResetter(
       GitRepositoryManager repoManager,
@@ -212,13 +232,13 @@
   }
 
   /** Read the states of all matching refs. */
-  private Multimap<Project.NameKey, RefState> readRefStates() throws IOException {
-    Multimap<Project.NameKey, RefState> refStatesByProject =
+  private ListMultimap<Project.NameKey, RefState> readRefStates() throws IOException {
+    ListMultimap<Project.NameKey, RefState> refStatesByProject =
         MultimapBuilder.hashKeys().arrayListValues().build();
     for (Map.Entry<Project.NameKey, Collection<String>> e :
         refsPatternByProject.asMap().entrySet()) {
       try (Repository repo = repoManager.openRepository(e.getKey())) {
-        Collection<Ref> refs = repo.getRefDatabase().getRefs();
+        List<Ref> refs = repo.getRefDatabase().getRefs();
         for (String refPattern : e.getValue()) {
           RefPatternMatcher matcher = RefPatternMatcher.getMatcher(refPattern);
           for (Ref ref : refs) {
@@ -262,7 +282,7 @@
     for (Map.Entry<Project.NameKey, Collection<String>> e :
         refsPatternByProject.asMap().entrySet()) {
       try (Repository repo = repoManager.openRepository(e.getKey())) {
-        Collection<Ref> nonRestoredRefs =
+        Set<Ref> nonRestoredRefs =
             repo.getRefDatabase().getRefs().stream()
                 .filter(
                     r ->
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index a61fa46..2d53533 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -22,6 +22,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
@@ -95,6 +96,9 @@
     PushOneCommit create(PersonIdent i, TestRepository<?> testRepo);
 
     PushOneCommit create(
+        PersonIdent i, TestRepository<?> testRepo, boolean insertChangeIdIfNotExist);
+
+    PushOneCommit create(
         PersonIdent i, TestRepository<?> testRepo, @Assisted("changeId") String changeId);
 
     PushOneCommit create(
@@ -172,7 +176,7 @@
   private final TestRepository<?>.CommitBuilder commitBuilder;
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo)
@@ -181,7 +185,24 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
+      Result.Factory pushResultFactory,
+      @Assisted PersonIdent i,
+      @Assisted TestRepository<?> testRepo,
+      @Assisted boolean insertChangeIdIfNotExist)
+      throws Exception {
+    this(
+        pushResultFactory,
+        i,
+        testRepo,
+        SUBJECT,
+        ImmutableMap.of(FILE_NAME, FILE_CONTENT),
+        /* changeId= */ null,
+        insertChangeIdIfNotExist);
+  }
+
+  @AssistedInject
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -191,7 +212,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -203,18 +224,19 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted String subject,
       @Assisted Map<String, String> files)
       throws Exception {
-    this(pushResultFactory, i, testRepo, subject, files, null);
+    this(
+        pushResultFactory, i, testRepo, subject, files, null, /* insertChangeIdIfNotExist= */ true);
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -223,11 +245,18 @@
       @Assisted("content") String content,
       @Nullable @Assisted("changeId") String changeId)
       throws Exception {
-    this(pushResultFactory, i, testRepo, subject, ImmutableMap.of(fileName, content), changeId);
+    this(
+        pushResultFactory,
+        i,
+        testRepo,
+        subject,
+        ImmutableMap.of(fileName, content),
+        changeId,
+        /* insertChangeIdIfNotExist= */ true);
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -235,6 +264,26 @@
       @Assisted Map<String, String> files,
       @Nullable @Assisted("changeId") String changeId)
       throws Exception {
+    this(
+        pushResultFactory,
+        i,
+        testRepo,
+        subject,
+        files,
+        changeId,
+        /* insertChangeIdIfNotExist= */ true);
+  }
+
+  @AssistedInject
+  public PushOneCommit(
+      Result.Factory pushResultFactory,
+      @Assisted PersonIdent i,
+      @Assisted TestRepository<?> testRepo,
+      @Assisted("subject") String subject,
+      @Assisted Map<String, String> files,
+      @Nullable @Assisted("changeId") String changeId,
+      @Assisted boolean insertChangeIdIfNotExist)
+      throws Exception {
     this.testRepo = testRepo;
     this.subject = subject;
     this.files = files;
@@ -242,16 +291,24 @@
     this.pushResultFactory = pushResultFactory;
     if (changeId != null) {
       commitBuilder = testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    } else {
+    } else if (insertChangeIdIfNotExist) {
       if (subject.contains("\nChange-Id: ")) {
         commitBuilder = testRepo.amendRef("HEAD");
       } else {
         commitBuilder = testRepo.branch("HEAD").commit().insertChangeId(nextChangeId());
       }
+    } else {
+      commitBuilder = testRepo.amendRef("HEAD");
     }
     commitBuilder.message(subject).author(i).committer(new PersonIdent(i, testRepo.getDate()));
   }
 
+  @UsedAt(Project.GOOGLE)
+  protected TestRepository<?> testRepository() {
+    return testRepo;
+  }
+
+  @CanIgnoreReturnValue
   public PushOneCommit setParents(List<RevCommit> parents) throws Exception {
     commitBuilder.noParents();
     for (RevCommit p : parents) {
@@ -266,17 +323,20 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PushOneCommit setParent(RevCommit parent) throws Exception {
     commitBuilder.noParents();
     commitBuilder.parent(parent);
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PushOneCommit noParent() {
     commitBuilder.noParents();
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PushOneCommit addFile(String path, String content, int fileMode) throws Exception {
     RevBlob blobId = testRepo.blob(content);
     commitBuilder.edit(
@@ -290,6 +350,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PushOneCommit addSymlink(String path, String target) throws Exception {
     RevBlob blobId = testRepo.blob(target);
     commitBuilder.edit(
@@ -303,6 +364,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PushOneCommit addGitSubmodule(String modulePath, ObjectId commitId) {
     commitBuilder.edit(
         new PathEdit(modulePath) {
@@ -315,11 +377,13 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PushOneCommit rmFile(String filename) {
     commitBuilder.rm(filename);
     return this;
   }
 
+  @CanIgnoreReturnValue
   public Result to(String ref) throws Exception {
     for (Map.Entry<String, String> e : files.entrySet()) {
       commitBuilder.add(e.getKey(), e.getValue());
@@ -327,6 +391,7 @@
     return execute(ref);
   }
 
+  @CanIgnoreReturnValue
   public Result rm(String ref) throws Exception {
     for (String fileName : files.keySet()) {
       commitBuilder.rm(fileName);
@@ -334,10 +399,11 @@
     return execute(ref);
   }
 
+  @CanIgnoreReturnValue
   public Result execute(String ref) throws Exception {
     RevCommit c = commitBuilder.create();
     if (changeId == null) {
-      changeId = GitUtil.getChangeId(testRepo, c).get();
+      changeId = GitUtil.getChangeId(testRepo, c).orElse(null);
     }
     if (tag != null) {
       TagCommand tagCommand = testRepo.git().tag().setName(tag.name);
@@ -387,7 +453,7 @@
       Result create(
           @Assisted("ref") String ref,
           @Assisted("subject") String subject,
-          @Assisted("changeId") String changeId,
+          @Nullable @Assisted("changeId") String changeId,
           @Nullable PushResult resSubj,
           @Nullable RevCommit commit,
           @Nullable List<String> pushOptions);
@@ -413,7 +479,7 @@
         Provider<InternalChangeQuery> queryProvider,
         @Assisted("ref") String ref,
         @Assisted("subject") String subject,
-        @Assisted("changeId") String changeId,
+        @Assisted("changeId") @Nullable String changeId,
         @Assisted @Nullable PushResult resSubj,
         @Assisted @Nullable RevCommit commit,
         @Assisted @Nullable List<String> pushOptions) {
@@ -473,7 +539,7 @@
 
     private void assertReviewers(
         Change c, ReviewerStateInternal state, List<TestAccount> expectedReviewers) {
-      Iterable<Account.Id> actualIds =
+      ImmutableSet<Account.Id> actualIds =
           approvalsUtil.getReviewers(notesFactory.createChecked(c)).byState(state);
       assertThat(actualIds)
           .containsExactlyElementsIn(Sets.newHashSet(TestAccount.ids(expectedReviewers)));
diff --git a/java/com/google/gerrit/acceptance/RestResponse.java b/java/com/google/gerrit/acceptance/RestResponse.java
index a045d80..a9c14aa 100644
--- a/java/com/google/gerrit/acceptance/RestResponse.java
+++ b/java/com/google/gerrit/acceptance/RestResponse.java
@@ -30,6 +30,8 @@
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
 
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
@@ -37,7 +39,8 @@
 
 public class RestResponse extends HttpResponse {
 
-  RestResponse(org.apache.http.HttpResponse response) {
+  @UsedAt(Project.GOOGLE)
+  public RestResponse(org.apache.http.HttpResponse response) {
     super(response);
   }
 
diff --git a/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
index 342cbd0..0865e31 100644
--- a/java/com/google/gerrit/acceptance/RestSession.java
+++ b/java/com/google/gerrit/acceptance/RestSession.java
@@ -11,123 +11,45 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-
 package com.google.gerrit.acceptance;
 
-import static com.google.common.net.HttpHeaders.ACCEPT;
-import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
-import static com.google.gerrit.json.OutputFormat.JSON_COMPACT;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
-
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.extensions.restapi.RawInput;
-import java.io.IOException;
 import org.apache.http.Header;
 import org.apache.http.client.fluent.Request;
-import org.apache.http.entity.BufferedHttpEntity;
-import org.apache.http.entity.InputStreamEntity;
-import org.apache.http.entity.StringEntity;
-import org.apache.http.message.BasicHeader;
 
-public class RestSession extends HttpSession {
+/** Makes rest requests to gerrit backend. */
+@UsedAt(UsedAt.Project.GOOGLE) // Google has own implementation of this interface in tests.
+public interface RestSession {
+  String url();
 
-  public RestSession(GerritServer server, @Nullable TestAccount account) {
-    super(server, account);
-  }
+  RestResponse execute(Request request) throws Exception;
 
-  public RestResponse get(String endPoint) throws IOException {
-    return getWithHeaders(endPoint);
-  }
+  RestResponse get(String endPoint) throws Exception;
 
-  public RestResponse getJsonAccept(String endPoint) throws IOException {
-    return getWithHeaders(endPoint, new BasicHeader(ACCEPT, "application/json"));
-  }
+  RestResponse getJsonAccept(String endPoint) throws Exception;
 
-  public RestResponse getWithHeaders(String endPoint, Header... headers) throws IOException {
-    Request get = Request.Get(getUrl(endPoint));
-    if (headers != null) {
-      get.setHeaders(headers);
-    }
-    return execute(get);
-  }
+  RestResponse getWithHeaders(String endPoint, Header... headers) throws Exception;
 
-  public RestResponse head(String endPoint) throws IOException {
-    return execute(Request.Head(getUrl(endPoint)));
-  }
+  RestResponse head(String endPoint) throws Exception;
 
-  public RestResponse put(String endPoint) throws IOException {
-    return put(endPoint, /* content = */ null);
-  }
+  RestResponse put(String endPoint) throws Exception;
 
-  public RestResponse put(String endPoint, Object content) throws IOException {
-    return putWithHeaders(endPoint, content);
-  }
+  RestResponse put(String endPoint, Object content) throws Exception;
 
-  public RestResponse putWithHeaders(String endPoint, Header... headers) throws IOException {
-    return putWithHeaders(endPoint, /* content= */ null, headers);
-  }
+  RestResponse putWithHeaders(String endPoint, Header... headers) throws Exception;
 
-  public RestResponse putWithHeaders(String endPoint, Object content, Header... headers)
-      throws IOException {
-    Request put = Request.Put(getUrl(endPoint));
-    if (headers != null) {
-      put.setHeaders(headers);
-    }
-    if (content != null) {
-      addContentToRequest(put, content);
-    }
-    return execute(put);
-  }
+  RestResponse putWithHeaders(String endPoint, Object content, Header... headers) throws Exception;
 
-  public RestResponse putRaw(String endPoint, RawInput stream) throws IOException {
-    requireNonNull(stream);
-    Request put = Request.Put(getUrl(endPoint));
-    put.addHeader(new BasicHeader(CONTENT_TYPE, stream.getContentType()));
-    put.body(
-        new BufferedHttpEntity(
-            new InputStreamEntity(stream.getInputStream(), stream.getContentLength())));
-    return execute(put);
-  }
+  RestResponse putRaw(String endPoint, RawInput stream) throws Exception;
 
-  public RestResponse post(String endPoint) throws IOException {
-    return post(endPoint, /* content = */ null);
-  }
+  RestResponse post(String endPoint) throws Exception;
 
-  public RestResponse post(String endPoint, Object content) throws IOException {
-    return postWithHeaders(endPoint, content);
-  }
+  RestResponse post(String endPoint, Object content) throws Exception;
 
-  public RestResponse postWithHeaders(String endPoint, Object content, Header... headers)
-      throws IOException {
-    Request post = Request.Post(getUrl(endPoint));
-    if (headers != null) {
-      post.setHeaders(headers);
-    }
-    if (content != null) {
-      addContentToRequest(post, content);
-    }
-    return execute(post);
-  }
+  RestResponse postWithHeaders(String endPoint, Object content, Header... headers) throws Exception;
 
-  private static void addContentToRequest(Request request, Object content) {
-    request.addHeader(new BasicHeader(CONTENT_TYPE, "application/json"));
-    request.body(new StringEntity(JSON_COMPACT.newGson().toJson(content), UTF_8));
-  }
+  RestResponse delete(String endPoint) throws Exception;
 
-  public RestResponse delete(String endPoint) throws IOException {
-    return execute(Request.Delete(getUrl(endPoint)));
-  }
-
-  public RestResponse deleteWithHeaders(String endPoint, Header... headers) throws IOException {
-    Request delete = Request.Delete(getUrl(endPoint));
-    if (headers != null) {
-      delete.setHeaders(headers);
-    }
-    return execute(delete);
-  }
-
-  private String getUrl(String endPoint) {
-    return url + (account != null ? "/a" : "") + endPoint;
-  }
+  RestResponse deleteWithHeaders(String endPoint, Header... headers) throws Exception;
 }
diff --git a/java/com/google/gerrit/acceptance/ServerTestRule.java b/java/com/google/gerrit/acceptance/ServerTestRule.java
new file mode 100644
index 0000000..a057a6e
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ServerTestRule.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.acceptance.ProjectResetter.Config;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.inject.Injector;
+import java.net.InetSocketAddress;
+import java.util.Optional;
+import java.util.function.BiFunction;
+import org.junit.rules.TestRule;
+
+public interface ServerTestRule extends TestRule {
+  /**
+   * Initialize a server.
+   *
+   * <p>All other methods must be called after this method is executed.
+   */
+  void initServer() throws Exception;
+
+  @Nullable
+  ProjectResetter createProjectResetter(
+      BiFunction<AllProjectsName, AllUsersName, Config> resetConfigSupplier) throws Exception;
+
+  Injector getTestInjector();
+
+  Optional<Injector> getHttpdInjector();
+
+  /**
+   * Initializes Ssh if a test requires it.
+   *
+   * <p>The method shouldn't throw an exception if the test doesn't require Ssh. If the test
+   * requires ssh and ssh is not supported (e.g. in internal google tests) the method throws {@link
+   * UnsupportedOperationException}.
+   */
+  void initSsh() throws Exception;
+
+  /**
+   * Restart backend as a replica and re-init Ssh if a test requires ssh.
+   *
+   * <p>The method throws {@link UnsupportedOperationException} if restarting is not supported (e.g.
+   * in internal google tests).
+   */
+  void restartAsSlave() throws Exception;
+
+  /**
+   * Restart backend as a primary and re-init Ssh if a test requires ssh.
+   *
+   * <p>The method throws {@link UnsupportedOperationException} if restarting is not supported (e.g.
+   * in internal google tests).
+   */
+  void restart() throws Exception;
+
+  /**
+   * Creates {@link RestSession} which sends all requests as a specified account.
+   *
+   * <p>For sending anonymous requests pass null as the {@code account}.
+   */
+  RestSession createRestSession(@Nullable TestAccount account);
+
+  /** Returns true if the started server is a replica. */
+  boolean isReplica();
+
+  /** Returns address to be used for http requests (if present). */
+  Optional<InetSocketAddress> getHttpAddress();
+
+  /**
+   * Gets or creates a session associated with the given context.
+   *
+   * <p>The method throws {@link UnsupportedOperationException} if ssh is not supported (e.g. in
+   * internal google tests). The method must be called only if a test or class is annotated with the
+   * UseSsh annotation.
+   */
+  SshSession getOrCreateSshSessionForContext(RequestContext ctx);
+
+  /** Returns url to be used for git operations. */
+  String getGitUrl();
+
+  /** Returns true if ssh has been initialized. */
+  boolean sshInitialized();
+
+  /**
+   * Returns true if username is supported.
+   *
+   * <p>If it is not supported tests must either skip username checks or use something else instead
+   * (e.g. email)
+   */
+  boolean isUsernameSupported();
+}
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index 23bfd0b..ac1a73d 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import java.io.Reader;
@@ -38,6 +39,7 @@
 
   public abstract void close();
 
+  @CanIgnoreReturnValue
   public abstract String exec(String command) throws Exception;
 
   public abstract int execAndReturnStatus(String command) throws Exception;
diff --git a/java/com/google/gerrit/acceptance/SshSessionMina.java b/java/com/google/gerrit/acceptance/SshSessionMina.java
index 89096e4..bac4ed6 100644
--- a/java/com/google/gerrit/acceptance/SshSessionMina.java
+++ b/java/com/google/gerrit/acceptance/SshSessionMina.java
@@ -79,7 +79,8 @@
 
   @Override
   public void open() throws Exception {
-    getMinaSession();
+    @SuppressWarnings("unused")
+    var unused = getMinaSession();
   }
 
   @Override
diff --git a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
index dcb49a5..01e705c 100644
--- a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Streams;
 import com.google.common.io.ByteStreams;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -75,7 +76,8 @@
 
       try {
         // ServerContext ctor is called multiple times but the group can be only created once
-        gApi.groups().id("Group");
+        @SuppressWarnings("unused")
+        var unused = gApi.groups().id("Group");
       } catch (ResourceNotFoundException e) {
         GroupInput in = new GroupInput();
         in.members = Collections.singletonList("admin");
@@ -211,11 +213,13 @@
     runGerrit(Arrays.stream(multiArgs).flatMap(Streams::stream).toArray(String[]::new));
   }
 
+  @CanIgnoreReturnValue
   protected static String execute(
       ImmutableList<String> cmd, File dir, ImmutableMap<String, String> env) throws IOException {
     return execute(cmd, dir, env, null);
   }
 
+  @CanIgnoreReturnValue
   protected static String execute(
       ImmutableList<String> cmd,
       File dir,
diff --git a/java/com/google/gerrit/acceptance/TestAccount.java b/java/com/google/gerrit/acceptance/TestAccount.java
index 67d8a05..85c2ddca 100644
--- a/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/TestAccount.java
@@ -78,7 +78,7 @@
     return new PersonIdent(fullName(), email());
   }
 
-  public String getHttpUrl(GerritServer server) {
+  public String getHttpUrl(ServerTestRule server) {
     checkState(server.getHttpAddress().isPresent(), "GerritServer must have httpAddress");
     InetSocketAddress addr = server.getHttpAddress().get();
     return new URIBuilder()
diff --git a/java/com/google/gerrit/acceptance/TestConfigRule.java b/java/com/google/gerrit/acceptance/TestConfigRule.java
new file mode 100644
index 0000000..a7f051a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/TestConfigRule.java
@@ -0,0 +1,142 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.acceptance.config.ConfigAnnotationParser;
+import com.google.gerrit.acceptance.config.GerritSystemProperty;
+import com.google.gerrit.server.util.git.DelegateSystemReader;
+import java.io.File;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
+import org.junit.rules.TemporaryFolder;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+public class TestConfigRule implements TestRule {
+  private final TemporaryFolder temporaryFolder;
+  private final AbstractDaemonTest test;
+  private Description description;
+  private GerritServer.Description methodDescription;
+  GerritServer.Description classDescription;
+  private boolean testRequiresSsh;
+  private SystemReader oldSystemReader;
+
+  public TestConfigRule(TemporaryFolder temporaryFolder, AbstractDaemonTest test) {
+    this.temporaryFolder = temporaryFolder;
+    this.test = test;
+  }
+
+  @Override
+  public Statement apply(Statement statement, Description description) {
+    return new Statement() {
+      @Override
+      public void evaluate() throws Throwable {
+        setTestConfigFromDescription(description);
+        statement.evaluate();
+        clear();
+      }
+    };
+  }
+
+  private void setTestConfigFromDescription(Description description) {
+    oldSystemReader = setFakeSystemReader(temporaryFolder.getRoot());
+
+    this.description = description;
+    classDescription = GerritServer.Description.forTestClass(description, test.configName);
+    methodDescription = GerritServer.Description.forTestMethod(description, test.configName);
+
+    if (methodDescription.systemProperties() != null) {
+      ConfigAnnotationParser.parse(methodDescription.systemProperties());
+    }
+
+    if (methodDescription.systemProperty() != null) {
+      ConfigAnnotationParser.parse(methodDescription.systemProperty());
+    }
+
+    test.baseConfig.unset("gerrit", null, "canonicalWebUrl");
+    test.baseConfig.unset("httpd", null, "listenUrl");
+
+    test.baseConfig.setInt("index", null, "batchThreads", -1);
+
+    testRequiresSsh = classDescription.useSshAnnotation() || methodDescription.useSshAnnotation();
+    if (!testRequiresSsh) {
+      test.baseConfig.setString("sshd", null, "listenAddress", "off");
+    }
+  }
+
+  private static SystemReader setFakeSystemReader(File tempDir) {
+    SystemReader oldSystemReader = SystemReader.getInstance();
+    SystemReader.setInstance(
+        new DelegateSystemReader(oldSystemReader) {
+          @Override
+          public FileBasedConfig openJGitConfig(Config parent, FS fs) {
+            return new FileBasedConfig(parent, new File(tempDir, "jgit.config"), FS.detect());
+          }
+
+          @Override
+          public FileBasedConfig openUserConfig(Config parent, FS fs) {
+            return new FileBasedConfig(parent, new File(tempDir, "user.config"), FS.detect());
+          }
+
+          @Override
+          public FileBasedConfig openSystemConfig(Config parent, FS fs) {
+            return new FileBasedConfig(parent, new File(tempDir, "system.config"), FS.detect());
+          }
+        });
+    return oldSystemReader;
+  }
+
+  private void clear() {
+    if (methodDescription.systemProperties() != null) {
+      for (GerritSystemProperty sysProp : methodDescription.systemProperties().value()) {
+        System.clearProperty(sysProp.name());
+      }
+    }
+
+    if (methodDescription.systemProperty() != null) {
+      System.clearProperty(methodDescription.systemProperty().name());
+    }
+    description = null;
+    methodDescription = null;
+    classDescription = null;
+    testRequiresSsh = false;
+
+    SystemReader.setInstance(oldSystemReader);
+    oldSystemReader = null;
+  }
+
+  public Description description() {
+    return description;
+  }
+
+  public GerritServer.Description methodDescription() {
+    return methodDescription;
+  }
+
+  public GerritServer.Description classDescription() {
+    return classDescription;
+  }
+
+  public boolean testRequiresSsh() {
+    return testRequiresSsh;
+  }
+
+  public Config baseConfig() {
+    return test.baseConfig;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/TimeSettingsTestRule.java b/java/com/google/gerrit/acceptance/TimeSettingsTestRule.java
new file mode 100644
index 0000000..631dc06
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/TimeSettingsTestRule.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.sql.Timestamp;
+import java.time.Instant;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+public class TimeSettingsTestRule implements TestRule {
+  private final TestConfigRule config;
+  private String systemTimeZone;
+
+  public TimeSettingsTestRule(TestConfigRule config) {
+    this.config = config;
+  }
+
+  @Override
+  public Statement apply(Statement statement, Description description) {
+    return new Statement() {
+      @Override
+      public void evaluate() throws Throwable {
+        beforeTest();
+        statement.evaluate();
+        afterTest();
+      }
+    };
+  }
+
+  private void beforeTest() {
+    setTimeSettings(
+        config.classDescription().useSystemTime(),
+        config.classDescription().useClockStep(),
+        config.classDescription().useTimezone());
+    setTimeSettings(
+        config.methodDescription().useSystemTime(),
+        config.methodDescription().useClockStep(),
+        config.methodDescription().useTimezone());
+  }
+
+  private void afterTest() {
+    resetTimeSettings();
+  }
+
+  private void setTimeSettings(
+      boolean useSystemTime,
+      @Nullable UseClockStep useClockStep,
+      @Nullable UseTimezone useTimezone) {
+    if (useSystemTime) {
+      TestTimeUtil.useSystemTime();
+    } else if (useClockStep != null) {
+      TestTimeUtil.resetWithClockStep(useClockStep.clockStep(), useClockStep.clockStepUnit());
+      if (useClockStep.startAtEpoch()) {
+        TestTimeUtil.setClock(Timestamp.from(Instant.EPOCH));
+      }
+    }
+    if (useTimezone != null) {
+      systemTimeZone = System.setProperty("user.timezone", useTimezone.timezone());
+    }
+  }
+
+  private void resetTimeSettings() {
+    TestTimeUtil.useSystemTime();
+    if (systemTimeZone != null) {
+      System.setProperty("user.timezone", systemTimeZone);
+      systemTimeZone = null;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/config/BUILD b/java/com/google/gerrit/acceptance/config/BUILD
index 0da68b0..1da975f 100644
--- a/java/com/google/gerrit/acceptance/config/BUILD
+++ b/java/com/google/gerrit/acceptance/config/BUILD
@@ -12,5 +12,6 @@
         "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/acceptance/config/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/acceptance/config/package-info.java
index 0709b86..753b75a 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/acceptance/config/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.acceptance.config;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/acceptance/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/acceptance/package-info.java
index 0709b86..428b5fb 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/acceptance/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.acceptance;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/acceptance/rest/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/acceptance/rest/package-info.java
index 0709b86..923b0c5 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/acceptance/rest/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.acceptance.rest;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
index 626092b..f20851c 100644
--- a/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
+++ b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
@@ -17,6 +17,10 @@
 import com.google.gerrit.sshd.CommandModule;
 
 public class TestSshCommandModule extends CommandModule {
+  public TestSshCommandModule() {
+    super(/* slaveMode= */ false);
+  }
+
   @Override
   protected void configure() {
     command("graceful").to(GracefulCommand.class);
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/acceptance/ssh/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/acceptance/ssh/package-info.java
index 0709b86..940b42e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/acceptance/ssh/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.acceptance.ssh;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index edbb1ee..5b5895f 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
@@ -66,10 +67,11 @@
 
   @Override
   public TestAccountCreation.Builder newAccount() {
-    return TestAccountCreation.builder(this::createAccount);
+    return TestAccountCreation.builder(
+        this::createAccount, externalIdFactory.arePasswordsAllowed());
   }
 
-  private Account.Id createAccount(TestAccountCreation testAccountCreation) throws Exception {
+  protected Account.Id createAccount(TestAccountCreation testAccountCreation) throws Exception {
     Account.Id accountId = Account.id(seq.nextAccountId());
     Consumer<AccountDelta.Builder> accountCreation =
         deltaBuilder -> initAccountDelta(deltaBuilder, testAccountCreation, accountId);
@@ -159,6 +161,7 @@
       checkState(updatedAccount.isPresent(), "Tried to update non-existing test account");
     }
 
+    @CanIgnoreReturnValue
     private Optional<AccountState> updateAccount(ConfigureDeltaFromState configureDeltaFromState)
         throws IOException, ConfigInvalidException {
       return accountsUpdate.update("Update Test Account", accountId, configureDeltaFromState);
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
index 042dc9a..5d40517 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
@@ -18,6 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.Account;
 import java.util.Optional;
@@ -41,50 +42,62 @@
 
   abstract ThrowingFunction<TestAccountCreation, Account.Id> accountCreator();
 
-  public static Builder builder(ThrowingFunction<TestAccountCreation, Account.Id> accountCreator) {
-    return new AutoValue_TestAccountCreation.Builder()
-        .accountCreator(accountCreator)
-        .httpPassword("http-pass");
+  public static Builder builder(
+      ThrowingFunction<TestAccountCreation, Account.Id> accountCreator,
+      boolean arePasswordsAllowed) {
+    TestAccountCreation.Builder builder =
+        new AutoValue_TestAccountCreation.Builder().accountCreator(accountCreator);
+    if (arePasswordsAllowed) {
+      builder.httpPassword("http-pass");
+    }
+    return builder;
   }
 
   @AutoValue.Builder
   public abstract static class Builder {
     public abstract Builder fullname(String fullname);
 
+    @CanIgnoreReturnValue
     public Builder clearFullname() {
       return fullname("");
     }
 
     public abstract Builder httpPassword(String httpPassword);
 
+    @CanIgnoreReturnValue
     public Builder clearHttpPassword() {
       return httpPassword("");
     }
 
     public abstract Builder preferredEmail(String preferredEmail);
 
+    @CanIgnoreReturnValue
     public Builder clearPreferredEmail() {
       return preferredEmail("");
     }
 
     public abstract Builder username(String username);
 
+    @CanIgnoreReturnValue
     public Builder clearUsername() {
       return username("");
     }
 
     public abstract Builder status(String status);
 
+    @CanIgnoreReturnValue
     public Builder clearStatus() {
       return status("");
     }
 
     abstract Builder active(boolean active);
 
+    @CanIgnoreReturnValue
     public Builder active() {
       return active(true);
     }
 
+    @CanIgnoreReturnValue
     public Builder inactive() {
       return active(false);
     }
@@ -93,6 +106,7 @@
 
     abstract ImmutableSet.Builder<String> secondaryEmailsBuilder();
 
+    @CanIgnoreReturnValue
     public Builder addSecondaryEmail(String secondaryEmail) {
       secondaryEmailsBuilder().add(secondaryEmail);
       return this;
@@ -103,6 +117,7 @@
 
     abstract TestAccountCreation autoBuild();
 
+    @CanIgnoreReturnValue
     public Account.Id create() {
       TestAccountCreation accountCreation = autoBuild();
       if (accountCreation.preferredEmail().isPresent()) {
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/acceptance/testsuite/account/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/acceptance/testsuite/account/package-info.java
index 0709b86..e211ecd 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.acceptance.testsuite.account;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
index 3bd355b..1fd780f 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -181,7 +181,8 @@
               side,
               message,
               unresolved,
-              parentUuid);
+              parentUuid,
+              null);
       // For draft comments, only the tag set on the HumanComment (and not on the ChangeUpdate)
       // matters.
       commentCreation.tag().ifPresent(tag -> newComment.tag = tag);
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
index a0746e2..eb714d45 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -19,7 +19,9 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -32,6 +34,9 @@
 /** Initial attributes of the change. If not provided, arbitrary values will be used. */
 @AutoValue
 public abstract class TestChangeCreation {
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public abstract Optional<String> host();
+
   public abstract Optional<Project.NameKey> project();
 
   public abstract String branch();
@@ -72,6 +77,10 @@
 
   @AutoValue.Builder
   public abstract static class Builder {
+    /** Host name in a multi-tenant deployment. */
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public abstract Builder host(String host);
+
     /** Target project/Repository of the change. Must be an existing project. */
     public abstract Builder project(Project.NameKey project);
 
@@ -238,6 +247,7 @@
      *
      * @return the {@code Change.Id} of the created change
      */
+    @CanIgnoreReturnValue
     public Change.Id create() {
       TestChangeCreation changeUpdate = build();
       return changeUpdate.changeCreator().applyAndThrowSilently(changeUpdate);
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
index 2031bde..9828e6c 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.testsuite.change;
 
 import com.google.auto.value.AutoValue;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.acceptance.testsuite.change.TestRange.Position;
 import com.google.gerrit.common.Nullable;
@@ -68,6 +69,7 @@
   @AutoValue.Builder
   public abstract static class Builder {
 
+    @CanIgnoreReturnValue
     public Builder noMessage() {
       return message("");
     }
@@ -76,11 +78,13 @@
     public abstract Builder message(String message);
 
     /** Indicates a patchset-level comment. */
+    @CanIgnoreReturnValue
     public Builder onPatchsetLevel() {
       return file(Patch.PATCHSET_LEVEL);
     }
 
     /** Indicates a file comment. The comment will be on the specified file. */
+    @CanIgnoreReturnValue
     public Builder onFileLevelOf(String filePath) {
       return file(filePath).line(null).range(null);
     }
@@ -122,6 +126,7 @@
      * <p>On the UI, such comments are shown on the right side of a diff view when a diff against
      * base is selected. See {@link #onParentCommit()} for comments shown on the left side.
      */
+    @CanIgnoreReturnValue
     public Builder onPatchsetCommit() {
       return side(CommentSide.PATCHSET_COMMIT);
     }
@@ -135,11 +140,13 @@
      *
      * <p>For merge commits, this indicates the first parent commit.
      */
+    @CanIgnoreReturnValue
     public Builder onParentCommit() {
       return side(CommentSide.PARENT_COMMIT);
     }
 
     /** Like {@link #onParentCommit()} but for the second parent of a merge commit. */
+    @CanIgnoreReturnValue
     public Builder onSecondParentCommit() {
       return side(CommentSide.SECOND_PARENT_COMMIT);
     }
@@ -148,6 +155,7 @@
      * Like {@link #onParentCommit()} but for the AutoMerge commit created from the parents of a
      * merge commit.
      */
+    @CanIgnoreReturnValue
     public Builder onAutoMergeCommit() {
       return side(CommentSide.AUTO_MERGE_COMMIT);
     }
@@ -155,11 +163,13 @@
     abstract Builder side(CommentSide side);
 
     /** Indicates a resolved comment. */
+    @CanIgnoreReturnValue
     public Builder resolved() {
       return unresolved(false);
     }
 
     /** Indicates an unresolved comment. */
+    @CanIgnoreReturnValue
     public Builder unresolved() {
       return unresolved(true);
     }
@@ -211,6 +221,7 @@
      *
      * @return the UUID of the created comment
      */
+    @CanIgnoreReturnValue
     public String create() {
       TestCommentCreation commentCreation = autoBuild();
       return commentCreation.commentCreator().applyAndThrowSilently(commentCreation);
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
index f8ca977..d9ac490 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -18,6 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
@@ -181,6 +182,7 @@
      *
      * @return the {@code PatchSet.Id} of the created patchset
      */
+    @CanIgnoreReturnValue
     public PatchSet.Id create() {
       TestPatchsetCreation patchsetCreation = build();
       return patchsetCreation.patchsetCreator().applyAndThrowSilently(patchsetCreation);
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/acceptance/testsuite/change/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/acceptance/testsuite/change/package-info.java
index 0709b86..3863f72 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.acceptance.testsuite.change;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/BUILD b/java/com/google/gerrit/acceptance/testsuite/group/BUILD
index 2052105..2bc2b62 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/BUILD
+++ b/java/com/google/gerrit/acceptance/testsuite/group/BUILD
@@ -21,6 +21,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:lang3",
+        "//lib/errorprone:annotations",
         "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
index 99899cf..7549c84 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
@@ -19,6 +19,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -54,6 +55,7 @@
 
     public abstract Builder description(String description);
 
+    @CanIgnoreReturnValue
     public Builder clearDescription() {
       return description("");
     }
@@ -62,10 +64,12 @@
 
     public abstract Builder visibleToAll(boolean visibleToAll);
 
+    @CanIgnoreReturnValue
     public Builder clearMembers() {
       return members(ImmutableSet.of());
     }
 
+    @CanIgnoreReturnValue
     public Builder members(Account.Id member1, Account.Id... otherMembers) {
       return members(Sets.union(ImmutableSet.of(member1), ImmutableSet.copyOf(otherMembers)));
     }
@@ -74,15 +78,18 @@
 
     abstract ImmutableSet.Builder<Account.Id> membersBuilder();
 
+    @CanIgnoreReturnValue
     public Builder addMember(Account.Id member) {
       membersBuilder().add(member);
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder clearSubgroups() {
       return subgroups(ImmutableSet.of());
     }
 
+    @CanIgnoreReturnValue
     public Builder subgroups(AccountGroup.UUID subgroup1, AccountGroup.UUID... otherSubgroups) {
       return subgroups(Sets.union(ImmutableSet.of(subgroup1), ImmutableSet.copyOf(otherSubgroups)));
     }
@@ -91,6 +98,7 @@
 
     abstract ImmutableSet.Builder<AccountGroup.UUID> subgroupsBuilder();
 
+    @CanIgnoreReturnValue
     public Builder addSubgroup(AccountGroup.UUID subgroup) {
       subgroupsBuilder().add(subgroup);
       return this;
@@ -106,6 +114,7 @@
      *
      * @return the UUID of the created group
      */
+    @CanIgnoreReturnValue
     public AccountGroup.UUID create() {
       TestGroupCreation groupCreation = autoBuild();
       return testRefAction(() -> groupCreation.groupCreator().applyAndThrowSilently(groupCreation));
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java
index 47c7117..e375760 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupUpdate.java
@@ -17,6 +17,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -56,6 +57,7 @@
 
     public abstract Builder description(String description);
 
+    @CanIgnoreReturnValue
     public Builder clearDescription() {
       return description("");
     }
@@ -69,10 +71,12 @@
 
     abstract Function<ImmutableSet<Account.Id>, Set<Account.Id>> memberModification();
 
+    @CanIgnoreReturnValue
     public Builder clearMembers() {
       return memberModification(originalMembers -> ImmutableSet.of());
     }
 
+    @CanIgnoreReturnValue
     public Builder addMember(Account.Id member) {
       Function<ImmutableSet<Account.Id>, Set<Account.Id>> previousModification =
           memberModification();
@@ -82,6 +86,7 @@
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder removeMember(Account.Id member) {
       Function<ImmutableSet<Account.Id>, Set<Account.Id>> previousModification =
           memberModification();
@@ -98,10 +103,12 @@
     abstract Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>>
         subgroupModification();
 
+    @CanIgnoreReturnValue
     public Builder clearSubgroups() {
       return subgroupModification(originalMembers -> ImmutableSet.of());
     }
 
+    @CanIgnoreReturnValue
     public Builder addSubgroup(AccountGroup.UUID subgroup) {
       Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>> previousModification =
           subgroupModification();
@@ -111,6 +118,7 @@
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder removeSubgroup(AccountGroup.UUID subgroup) {
       Function<ImmutableSet<AccountGroup.UUID>, Set<AccountGroup.UUID>> previousModification =
           subgroupModification();
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/acceptance/testsuite/group/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/acceptance/testsuite/group/package-info.java
index 0709b86..78778e1 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.acceptance.testsuite.group;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/acceptance/testsuite/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/acceptance/testsuite/package-info.java
index 0709b86..2f43e09 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/acceptance/testsuite/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.acceptance.testsuite;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/BUILD b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
index 4ac2705..be51a64 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/BUILD
+++ b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
@@ -21,6 +21,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:lang3",
+        "//lib/errorprone:annotations",
         "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
index 3337fc3..e8df7ed 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
@@ -19,6 +19,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
@@ -66,6 +67,7 @@
      * "refs/heads/" prefix of the branch name can be omitted. The specified branches are ignored if
      * {@link #noEmptyCommit()} is used.
      */
+    @CanIgnoreReturnValue
     public TestProjectCreation.Builder branches(String branch1, String... otherBranches) {
       return branches(Sets.union(ImmutableSet.of(branch1), ImmutableSet.copyOf(otherBranches)));
     }
@@ -77,10 +79,12 @@
     public abstract TestProjectCreation.Builder permissionOnly(boolean value);
 
     /** Skips the empty commit on creation. This means that project's branches will not exist. */
+    @CanIgnoreReturnValue
     public TestProjectCreation.Builder noEmptyCommit() {
       return createEmptyCommit(false);
     }
 
+    @CanIgnoreReturnValue
     public TestProjectCreation.Builder addOwner(AccountGroup.UUID owner) {
       ownersBuilder().add(requireNonNull(owner, "owner"));
       return this;
@@ -98,6 +102,7 @@
      *
      * @return the name of the created project
      */
+    @CanIgnoreReturnValue
     public Project.NameKey create() {
       TestProjectCreation creation = autoBuild();
       return creation.projectCreator().applyAndThrowSilently(creation);
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
index 5634c78..cc57ba6 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -21,6 +21,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccountGroup;
@@ -75,6 +76,7 @@
       abstract Optional<Integer> max();
 
       /** Sets the minimum and maximum values for the capability. */
+      @CanIgnoreReturnValue
       public Builder range(int min, int max) {
         checkNonInvertedRange(min, max);
         return min(min).max(max);
@@ -353,61 +355,72 @@
      * Removes all access sections. Useful when testing against a specific set of access sections or
      * permissions.
      */
+    @CanIgnoreReturnValue
     public Builder removeAllAccessSections() {
       return removeAllAccessSections(true);
     }
 
     /** Adds a permission to be included in this update. */
+    @CanIgnoreReturnValue
     public Builder add(TestPermission testPermission) {
       addedPermissionsBuilder().add(testPermission);
       return this;
     }
 
     /** Adds a permission to be included in this update. */
+    @CanIgnoreReturnValue
     public Builder add(TestPermission.Builder testPermissionBuilder) {
       return add(testPermissionBuilder.build());
     }
 
     /** Adds a label permission to be included in this update. */
+    @CanIgnoreReturnValue
     public Builder add(TestLabelPermission testLabelPermission) {
       addedLabelPermissionsBuilder().add(testLabelPermission);
       return this;
     }
 
     /** Adds a label permission to be included in this update. */
+    @CanIgnoreReturnValue
     public Builder add(TestLabelPermission.Builder testLabelPermissionBuilder) {
       return add(testLabelPermissionBuilder.build());
     }
 
     /** Adds a capability to be included in this update. */
+    @CanIgnoreReturnValue
     public Builder add(TestCapability testCapability) {
       addedCapabilitiesBuilder().add(testCapability);
       return this;
     }
 
     /** Adds a capability to be included in this update. */
+    @CanIgnoreReturnValue
     public Builder add(TestCapability.Builder testCapabilityBuilder) {
       return add(testCapabilityBuilder.build());
     }
 
     /** Removes a permission, label permission, or capability as part of this update. */
+    @CanIgnoreReturnValue
     public Builder remove(TestPermissionKey testPermissionKey) {
       removedPermissionsBuilder().add(testPermissionKey);
       return this;
     }
 
     /** Removes a permission, label permission, or capability as part of this update. */
+    @CanIgnoreReturnValue
     public Builder remove(TestPermissionKey.Builder testPermissionKeyBuilder) {
       return remove(testPermissionKeyBuilder.build());
     }
 
     /** Sets the exclusive bit bit for the given permission key. */
+    @CanIgnoreReturnValue
     public Builder setExclusiveGroup(
         TestPermissionKey.Builder testPermissionKeyBuilder, boolean exclusive) {
       return setExclusiveGroup(testPermissionKeyBuilder.build(), exclusive);
     }
 
     /** Sets the exclusive bit bit for the given permission key. */
+    @CanIgnoreReturnValue
     public Builder setExclusiveGroup(TestPermissionKey testPermissionKey, boolean exclusive) {
       checkArgument(
           !testPermissionKey.group().isPresent(),
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/acceptance/testsuite/project/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/acceptance/testsuite/project/package-info.java
index 0709b86..dfe56d6 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.acceptance.testsuite.project;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java
index a9914b3..8bad32c 100644
--- a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperations.java
@@ -14,9 +14,10 @@
 
 package com.google.gerrit.acceptance.testsuite.request;
 
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
 
 /**
  * An aggregation of operations on Guice request scopes for test purposes.
@@ -25,54 +26,68 @@
  */
 public interface RequestScopeOperations {
   /**
-   * Sets the Guice request scope to the given account.
+   * Sets the Guice request scope to the given account without closing the existing context.
    *
-   * <p>The resulting context has an SSH session attached. In order to use the SSH session returned
-   * by {@link com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context#getSession()}, SSH
-   * must be enabled in the test and the account must have a username set. However, these are not
-   * requirements simply to call this method.
+   * <p>Returns newly created context. To restore previous context call {@link
+   * ManualRequestContext#close()} method of the returned context.
+   *
+   * <p>In order to create and use the SSH session for the newly set context, SSH must be enabled in
+   * the test and the account must have a username set.
+   *
+   * <p>The session associated with the returned context can be obtained by calling {@link
+   * com.google.gerrit.acceptance.AbstractDaemonTest#getOrCreateSshSessionForContext}.
    *
    * @param accountId account ID. Must exist; throws an unchecked exception otherwise.
-   * @return the previous request scope.
    */
-  AcceptanceTestRequestScope.Context setApiUser(Account.Id accountId);
+  ManualRequestContext setNestedApiUser(Account.Id accountId) throws Exception;
 
   /**
    * Sets the Guice request scope to the given account.
    *
-   * <p>The resulting context has an SSH session attached. In order to use the SSH session returned
-   * by {@link com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context#getSession()}, SSH
-   * must be enabled in the test and the account must have a username set. However, these are not
-   * requirements simply to call this method.
+   * <p>After calling this method, the new context can be obtained using the {@link
+   * ThreadLocalRequestContext#getContext()} method.
+   *
+   * <p>If the previous context is a {@link ManualRequestContext}. the method closes it before
+   * setting the new context. This prevents stacking of contexts.
+   *
+   * <p>In order to create and use the SSH session for the new context, SSH must be enabled in the
+   * test and the account must have a username set. To get the session associated with the newly set
+   * context use the {@link
+   * com.google.gerrit.acceptance.AbstractDaemonTest#getOrCreateSshSessionForContext} method.
+   *
+   * @param accountId account ID. Must exist; throws an unchecked exception otherwise.
+   */
+  void setApiUser(Account.Id accountId) throws Exception;
+
+  /**
+   * Sets the Guice request scope to the given account.
+   *
+   * <p>See {@link #setApiUser(Account.Id)} for details.
    *
    * @param testAccount test account from {@code AccountOperations}.
-   * @return the previous request scope.
    */
-  AcceptanceTestRequestScope.Context setApiUser(TestAccount testAccount);
+  void setApiUser(TestAccount testAccount) throws Exception;
 
   /**
    * Enforces a new request context for the current API user.
    *
-   * <p>This recreates the {@code IdentifiedUser}, hence everything which is cached in the {@code
-   * IdentifiedUser} is reloaded (e.g. the email addresses of the user).
+   * <p>See {@link #setApiUser(Account.Id)} for details.
    *
-   * <p>The current user must be an identified user.
-   *
-   * @return the previous request scope.
+   * <p>The current user (i.e. a user set before calling this method) must be an identified user.
    */
-  AcceptanceTestRequestScope.Context resetCurrentApiUser();
+  void resetCurrentApiUser() throws Exception;
 
   /**
    * Sets the Guice request scope to the anonymous user.
    *
-   * @return the previous request scope.
+   * <p>See {@link #setApiUser(Account.Id)} for details.
    */
-  AcceptanceTestRequestScope.Context setApiUserAnonymous();
+  void setApiUserAnonymous() throws Exception;
 
   /**
    * Sets the Guice request scope to the internal server user.
    *
-   * @return the previous request scope.
+   * <p>See {@link #setApiUser(Account.Id)} for details.
    */
-  AcceptanceTestRequestScope.Context setApiUserInternal();
+  void setApiUserInternal() throws Exception;
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
index 895c7a0..03336ca 100644
--- a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
@@ -17,12 +17,8 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.Objects.requireNonNull;
 
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
-import com.google.gerrit.acceptance.GerritServer.TestSshServerAddress;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
-import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
@@ -30,10 +26,12 @@
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.net.InetSocketAddress;
 
 /**
  * The implementation of {@code RequestScopeOperations}.
@@ -43,64 +41,76 @@
  */
 @Singleton
 public class RequestScopeOperationsImpl implements RequestScopeOperations {
-  private final AcceptanceTestRequestScope atrScope;
+  private final ThreadLocalRequestContext localContext;
   private final AccountCache accountCache;
   private final AccountOperations accountOperations;
   private final IdentifiedUser.GenericFactory userFactory;
   private final Provider<AnonymousUser> anonymousUserProvider;
   private final InternalUser.Factory internalUserFactory;
-  private final InetSocketAddress sshAddress;
-  private final TestSshKeys testSshKeys;
 
   @Inject
   RequestScopeOperationsImpl(
-      AcceptanceTestRequestScope atrScope,
+      ThreadLocalRequestContext localContext,
       AccountCache accountCache,
       AccountOperations accountOperations,
       GenericFactory userFactory,
       Provider<AnonymousUser> anonymousUserProvider,
-      InternalUser.Factory internalUserFactory,
-      @Nullable @TestSshServerAddress InetSocketAddress sshAddress,
-      TestSshKeys testSshKeys) {
-    this.atrScope = atrScope;
+      InternalUser.Factory internalUserFactory) {
+    this.localContext = localContext;
     this.accountCache = accountCache;
     this.accountOperations = accountOperations;
     this.userFactory = userFactory;
     this.anonymousUserProvider = anonymousUserProvider;
     this.internalUserFactory = internalUserFactory;
-    this.sshAddress = sshAddress;
-    this.testSshKeys = testSshKeys;
   }
 
   @Override
-  public AcceptanceTestRequestScope.Context setApiUser(Account.Id accountId) {
-    return setApiUser(accountOperations.account(accountId).get());
+  public ManualRequestContext setNestedApiUser(Account.Id accountId) {
+    return new ManualRequestContext(createIdentifiedUser(accountId), localContext);
   }
 
   @Override
-  public AcceptanceTestRequestScope.Context setApiUser(TestAccount testAccount) {
-    return atrScope.set(
-        atrScope.newContext(
-            SshSessionFactory.createSession(testSshKeys, sshAddress, testAccount),
-            createIdentifiedUser(testAccount.accountId())));
+  public void setApiUser(Account.Id accountId) {
+    setApiUser(accountOperations.account(accountId).get());
   }
 
   @Override
-  public AcceptanceTestRequestScope.Context resetCurrentApiUser() {
-    CurrentUser user = atrScope.get().getUser();
+  public void setApiUser(TestAccount testAccount) {
+    setApiUser(createIdentifiedUser(testAccount.accountId()));
+  }
+
+  @Override
+  public void resetCurrentApiUser() {
+    RequestContext currentContext = localContext.getContext();
+    checkState(
+        currentContext != null, "can only reset IdentifiedUser, but the RequestContext is null");
+    CurrentUser user = localContext.getContext().getUser();
     // More special cases for anonymous users etc. can be added as needed.
     checkState(user.isIdentifiedUser(), "can only reset IdentifiedUser, not %s", user);
-    return setApiUser(user.getAccountId());
+    setApiUser(user.getAccountId());
   }
 
   @Override
-  public AcceptanceTestRequestScope.Context setApiUserAnonymous() {
-    return atrScope.set(atrScope.newContext(null, anonymousUserProvider.get()));
+  public void setApiUserAnonymous() {
+    setApiUser(anonymousUserProvider.get());
   }
 
   @Override
-  public AcceptanceTestRequestScope.Context setApiUserInternal() {
-    return atrScope.set(atrScope.newContext(null, internalUserFactory.create()));
+  public void setApiUserInternal() {
+    setApiUser(internalUserFactory.create());
+  }
+
+  private void setApiUser(CurrentUser newUser) {
+    // The ManualRequestContext stores the previous context. When the close() method is called,
+    // the old context is restored.
+    RequestContext oldContext = localContext.getContext();
+    if (oldContext instanceof ManualRequestContext) {
+      ((ManualRequestContext) oldContext).close();
+    }
+    // The created object is not used, because the constructor of the ManualRequestContext sets the
+    // active context to itself. It is not needed to explicitly call localContext.setContext after
+    // an instance is created.
+    var unused = new ManualRequestContext(newUser, localContext);
   }
 
   private IdentifiedUser createIdentifiedUser(Account.Id accountId) {
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/acceptance/testsuite/request/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/acceptance/testsuite/request/package-info.java
index 0709b86..943bd21 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/acceptance/testsuite/request/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.acceptance.testsuite.request;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/asciidoctor/AsciiDoctor.java b/java/com/google/gerrit/asciidoctor/AsciiDoctor.java
index 9d0a28e..25ed813 100644
--- a/java/com/google/gerrit/asciidoctor/AsciiDoctor.java
+++ b/java/com/google/gerrit/asciidoctor/AsciiDoctor.java
@@ -22,7 +22,7 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.file.Files;
-import java.nio.file.Paths;
+import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -164,7 +164,7 @@
     if (bazel) {
       renderFiles(inputFiles, null);
     } else {
-      try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(Paths.get(zipFile)))) {
+      try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(Path.of(zipFile)))) {
         renderFiles(inputFiles, zip);
 
         File[] cssFiles = tmpdir.listFiles((dir, name) -> name.endsWith(".css"));
diff --git a/java/com/google/gerrit/asciidoctor/BUILD b/java/com/google/gerrit/asciidoctor/BUILD
index 94ec20d..a132095 100644
--- a/java/com/google/gerrit/asciidoctor/BUILD
+++ b/java/com/google/gerrit/asciidoctor/BUILD
@@ -35,6 +35,6 @@
         "//lib:args4j",
         "//lib:guava",
         "//lib/lucene:lucene-analyzers-common",
-        "//lib/lucene:lucene-core-and-backward-codecs",
+        "//lib/lucene:lucene-core",
     ],
 )
diff --git a/java/com/google/gerrit/asciidoctor/DocIndexer.java b/java/com/google/gerrit/asciidoctor/DocIndexer.java
index acd6aad..fd8161b 100644
--- a/java/com/google/gerrit/asciidoctor/DocIndexer.java
+++ b/java/com/google/gerrit/asciidoctor/DocIndexer.java
@@ -24,7 +24,7 @@
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.nio.file.Files;
-import java.nio.file.Paths;
+import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.jar.JarEntry;
@@ -82,7 +82,7 @@
       return;
     }
 
-    try (JarOutputStream jar = new JarOutputStream(Files.newOutputStream(Paths.get(outFile)))) {
+    try (JarOutputStream jar = new JarOutputStream(Files.newOutputStream(Path.of(outFile)))) {
       byte[] compressedIndex = zip(index());
       JarEntry entry = new JarEntry(String.format("%s/%s", Constants.PACKAGE, Constants.INDEX_ZIP));
       entry.setSize(compressedIndex.length);
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/asciidoctor/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/asciidoctor/package-info.java
index 0709b86..504c3f5 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/asciidoctor/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.asciidoctor;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/auth/ldap/LdapRealm.java b/java/com/google/gerrit/auth/ldap/LdapRealm.java
index 7dc2b1b..e33e5cb 100644
--- a/java/com/google/gerrit/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/auth/ldap/LdapRealm.java
@@ -331,7 +331,10 @@
     final DirContext ctx = helper.open();
     try {
       Helper.LdapSchema schema = helper.getSchema(ctx);
-      helper.findAccount(schema, ctx, username, false);
+
+      @SuppressWarnings("unused")
+      var unused = helper.findAccount(schema, ctx, username, false);
+
       return true;
     } catch (NoSuchUserException e) {
       return false;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/auth/ldap/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/auth/ldap/package-info.java
index 0709b86..45e1bc2 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/auth/ldap/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.auth.ldap;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/auth/oauth/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/auth/oauth/package-info.java
index 0709b86..53c6829 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/auth/oauth/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.auth.oauth;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/auth/openid/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/auth/openid/package-info.java
index 0709b86..2b6f77d 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/auth/openid/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.auth.openid;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/auth/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/auth/package-info.java
index 0709b86..6538de4 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/auth/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.auth;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/common/BUILD b/java/com/google/gerrit/common/BUILD
index 8f930bb..8f85311 100644
--- a/java/com/google/gerrit/common/BUILD
+++ b/java/com/google/gerrit/common/BUILD
@@ -30,6 +30,7 @@
         "//lib:servlet-api",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
     ],
 )
diff --git a/java/com/google/gerrit/common/FileUtil.java b/java/com/google/gerrit/common/FileUtil.java
index 5b0925e..35cf848 100644
--- a/java/com/google/gerrit/common/FileUtil.java
+++ b/java/com/google/gerrit/common/FileUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
@@ -84,6 +85,7 @@
     }
   }
 
+  @CanIgnoreReturnValue
   public static Path mkdirsOrDie(Path p, String errMsg) {
     try {
       if (!Files.isDirectory(p)) {
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 5ea5177..83af551 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -41,6 +41,7 @@
     PLUGIN_CHECKS,
     PLUGIN_CODE_OWNERS,
     PLUGIN_DELETE_PROJECT,
+    PLUGIN_GITHUB,
     PLUGIN_HIGH_AVAILABILITY,
     PLUGIN_MULTI_SITE,
     PLUGIN_PULL_REPLICATION,
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/common/auth/openid/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/common/auth/openid/package-info.java
index 0709b86..931ed56 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/common/auth/openid/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.common.auth.openid;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/common/data/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/common/data/package-info.java
index 0709b86..9f3539c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/common/data/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.common.data;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/common/data/testing/BUILD b/java/com/google/gerrit/common/data/testing/BUILD
index d39d05c..a580c77 100644
--- a/java/com/google/gerrit/common/data/testing/BUILD
+++ b/java/com/google/gerrit/common/data/testing/BUILD
@@ -7,6 +7,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/entities",
+        "//lib/errorprone:annotations",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/common/data/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/common/data/testing/package-info.java
index 0709b86..24c3708 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/common/data/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.common.data.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/common/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/common/package-info.java
index 0709b86..5f97d56 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/common/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.common;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/entities/AccessSection.java b/java/com/google/gerrit/entities/AccessSection.java
index 8ae0a5d..7dca72e 100644
--- a/java/com/google/gerrit/entities/AccessSection.java
+++ b/java/com/google/gerrit/entities/AccessSection.java
@@ -20,6 +20,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
 import java.util.List;
@@ -121,27 +122,32 @@
 
     public abstract String getName();
 
+    @CanIgnoreReturnValue
     public Builder modifyPermissions(Consumer<List<Permission.Builder>> modification) {
       modification.accept(permissionBuilders);
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder addPermission(Permission.Builder permission) {
       requireNonNull(permission, "permission must be non-null");
       return modifyPermissions(p -> p.add(permission));
     }
 
+    @CanIgnoreReturnValue
     public Builder remove(Permission.Builder permission) {
       requireNonNull(permission, "permission must be non-null");
       return removePermission(permission.getName());
     }
 
+    @CanIgnoreReturnValue
     public Builder removePermission(String name) {
       requireNonNull(name, "name must be non-null");
       return modifyPermissions(
           p -> p.removeIf(permissionBuilder -> name.equalsIgnoreCase(permissionBuilder.getName())));
     }
 
+    @CanIgnoreReturnValue
     public Permission.Builder upsertPermission(String permissionName) {
       requireNonNull(permissionName, "permissionName must be non-null");
 
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index 52ad0a9..efbac97 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -19,7 +19,9 @@
 import static com.google.gerrit.entities.RefNames.REFS_USERS;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
 import com.google.common.primitives.Ints;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import java.time.Instant;
@@ -160,6 +162,17 @@
   public abstract String metaId();
 
   /**
+   * A unique tag which identifies the current version of the account.
+   *
+   * <p>It can be any non-empty string. For open-source gerrit it is the same as metaId; internally
+   * in google a different value is assigned.
+   *
+   * <p>The value can be null only during account updating/creation.
+   */
+  @Nullable
+  public abstract String uniqueTag();
+
+  /**
    * Create a new account.
    *
    * @param newId unique id, see Sequences#nextAccountId().
@@ -261,6 +274,7 @@
 
     public abstract Builder setInactive(boolean inactive);
 
+    @CanIgnoreReturnValue
     public Builder setActive(boolean active) {
       return setInactive(!active);
     }
@@ -275,6 +289,11 @@
 
     public abstract Builder setMetaId(@Nullable String metaId);
 
+    @Nullable
+    public abstract String uniqueTag();
+
+    public abstract Builder setUniqueTag(@Nullable String uniqueTag);
+
     public abstract Account build();
   }
 
@@ -282,4 +301,18 @@
   public final String toString() {
     return getName();
   }
+
+  public final String debugString() {
+    return MoreObjects.toStringHelper(this)
+        .add("id", id())
+        .add("registeredOn", registeredOn())
+        .add("fullName", fullName())
+        .add("displayName", displayName())
+        .add("preferredEmail", preferredEmail())
+        .add("inactive", inactive())
+        .add("status", status())
+        .add("metaId", metaId())
+        .add("uniqueTag", uniqueTag())
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/entities/AccountGroupByIdAudit.java b/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
index 0ef51e5..5db5af5 100644
--- a/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
+++ b/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.time.Instant;
 import java.util.Optional;
 
@@ -39,6 +40,7 @@
 
     abstract Builder removedOn(Instant removedOn);
 
+    @CanIgnoreReturnValue
     public Builder removed(Account.Id removedBy, Instant removedOn) {
       return removedBy(removedBy).removedOn(removedOn);
     }
diff --git a/java/com/google/gerrit/entities/AccountGroupMemberAudit.java b/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
index 913956e..0cb1c62 100644
--- a/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
+++ b/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.time.Instant;
 import java.util.Optional;
 
@@ -43,10 +44,12 @@
 
     abstract Builder removedOn(Instant removedOn);
 
+    @CanIgnoreReturnValue
     public Builder removed(Account.Id removedBy, Instant removedOn) {
       return removedBy(removedBy).removedOn(removedOn);
     }
 
+    @CanIgnoreReturnValue
     public Builder removedLegacy() {
       return removed(addedBy(), addedOn());
     }
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index be4a1cf..6dc9e32 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -148,31 +149,37 @@
 
     public abstract Builder setBranchOrderSection(Optional<BranchOrderSection> value);
 
+    @CanIgnoreReturnValue
     public Builder addGroup(GroupReference groupReference) {
       groupsBuilder().put(groupReference.getUUID(), groupReference);
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder addAccessSection(AccessSection accessSection) {
       accessSectionsBuilder().put(accessSection.getName(), accessSection);
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder addContributorAgreement(ContributorAgreement contributorAgreement) {
       contributorAgreementsBuilder().put(contributorAgreement.getName(), contributorAgreement);
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder addNotifySection(NotifyConfig notifyConfig) {
       notifySectionsBuilder().put(notifyConfig.getName(), notifyConfig);
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder addLabelSection(LabelType labelType) {
       labelSectionsBuilder().put(labelType.getName(), labelType);
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder addSubmitRequirementSection(SubmitRequirement submitRequirement) {
       submitRequirementSectionsBuilder().put(submitRequirement.name(), submitRequirement);
       return this;
@@ -180,11 +187,13 @@
 
     public abstract Builder setMimeTypes(ConfiguredMimeTypes value);
 
+    @CanIgnoreReturnValue
     public Builder addSubscribeSection(SubscribeSection subscribeSection) {
       subscribeSectionsBuilder().put(subscribeSection.project(), subscribeSection);
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder addCommentLinkSection(StoredCommentLinkInfo storedCommentLinkInfo) {
       commentLinkSectionsBuilder().put(storedCommentLinkInfo.getName(), storedCommentLinkInfo);
       return this;
@@ -213,6 +222,7 @@
 
     abstract ImmutableMap.Builder<String, String> pluginConfigsBuilder();
 
+    @CanIgnoreReturnValue
     public Builder addPluginConfig(String pluginName, String pluginConfig) {
       pluginConfigsBuilder().put(pluginName, pluginConfig);
       return this;
@@ -222,6 +232,7 @@
 
     abstract ImmutableMap.Builder<String, ImmutableConfig> parsedProjectLevelConfigsBuilder();
 
+    @CanIgnoreReturnValue
     public Builder addProjectLevelConfig(String configFileName, String config) {
       projectLevelConfigsBuilder().put(configFileName, config);
       try {
diff --git a/java/com/google/gerrit/entities/Comment.java b/java/com/google/gerrit/entities/Comment.java
index e1e143c..35a60eb 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -20,6 +20,7 @@
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.Comparator;
+import java.util.List;
 import java.util.Objects;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
@@ -216,7 +217,7 @@
   public int lineNbr;
 
   public Identity author;
-  protected Identity realAuthor;
+  public Identity realAuthor;
 
   // TODO(issue-15525): Migrate this field from Timestamp to Instant
   public Timestamp writtenOn;
@@ -227,6 +228,8 @@
   public Range range;
   public String tag;
 
+  @Nullable public List<FixSuggestion> fixSuggestions;
+
   /**
    * Hex commit SHA1 of the commit of the patchset to which this comment applies. Other classes call
    * this "commitId", but this class uses the old ReviewDb term "revId", and this field name is
@@ -300,7 +303,14 @@
         + (key != null ? nullableLength(key.filename, key.uuid) : 0);
   }
 
-  public abstract int getApproximateSize();
+  public int getApproximateSize() {
+    int approximateSize = getCommentFieldApproximateSize();
+    approximateSize +=
+        fixSuggestions != null
+            ? fixSuggestions.stream().mapToInt(FixSuggestion::getApproximateSize).sum()
+            : 0;
+    return approximateSize;
+  }
 
   static int nullableLength(String... strings) {
     int length = 0;
@@ -327,7 +337,8 @@
         && Objects.equals(range, c.range)
         && Objects.equals(tag, c.tag)
         && Objects.equals(revId, c.revId)
-        && Objects.equals(serverId, c.serverId);
+        && Objects.equals(serverId, c.serverId)
+        && Objects.equals(fixSuggestions, c.fixSuggestions);
   }
 
   @Override
@@ -344,7 +355,8 @@
         range,
         tag,
         revId,
-        serverId);
+        serverId,
+        fixSuggestions);
   }
 
   @Override
@@ -364,6 +376,7 @@
         .add("parentUuid", Objects.toString(parentUuid, ""))
         .add("range", Objects.toString(range, ""))
         .add("revId", Objects.toString(revId, ""))
-        .add("tag", Objects.toString(tag, ""));
+        .add("tag", Objects.toString(tag, ""))
+        .add("fixSuggestions", Objects.toString(fixSuggestions, ""));
   }
 }
diff --git a/java/com/google/gerrit/entities/FixReplacement.java b/java/com/google/gerrit/entities/FixReplacement.java
index fbbf746..aa15ffc 100644
--- a/java/com/google/gerrit/entities/FixReplacement.java
+++ b/java/com/google/gerrit/entities/FixReplacement.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.entities;
 
+import java.util.Objects;
+
 public final class FixReplacement {
   public final String path;
   public final Comment.Range range;
@@ -43,4 +45,20 @@
   int getApproximateSize() {
     return path.length() + replacement.length();
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof FixReplacement)) {
+      return false;
+    }
+    FixReplacement f = (FixReplacement) o;
+    return Objects.equals(path, f.path)
+        && Objects.equals(range, f.range)
+        && Objects.equals(replacement, f.replacement);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(path, range, replacement);
+  }
 }
diff --git a/java/com/google/gerrit/entities/FixSuggestion.java b/java/com/google/gerrit/entities/FixSuggestion.java
index 892e324..737c23e 100644
--- a/java/com/google/gerrit/entities/FixSuggestion.java
+++ b/java/com/google/gerrit/entities/FixSuggestion.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import java.util.List;
+import java.util.Objects;
 
 public final class FixSuggestion {
   public final String fixId;
@@ -47,4 +48,20 @@
         + description.length()
         + replacements.stream().mapToInt(FixReplacement::getApproximateSize).sum();
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof FixSuggestion)) {
+      return false;
+    }
+    FixSuggestion fs = (FixSuggestion) o;
+    return Objects.equals(fixId, fs.fixId)
+        && Objects.equals(description, fs.description)
+        && Objects.equals(replacements, fs.replacements);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(fixId, description, replacements);
+  }
 }
diff --git a/java/com/google/gerrit/entities/HumanComment.java b/java/com/google/gerrit/entities/HumanComment.java
index d287fa0..1e48f11 100644
--- a/java/com/google/gerrit/entities/HumanComment.java
+++ b/java/com/google/gerrit/entities/HumanComment.java
@@ -47,11 +47,6 @@
   }
 
   @Override
-  public int getApproximateSize() {
-    return super.getCommentFieldApproximateSize();
-  }
-
-  @Override
   public String toString() {
     return toStringHelper().add("unresolved", unresolved).toString();
   }
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index 7a3266ecc..ff4b8f9 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -20,6 +20,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
 import java.util.List;
@@ -39,6 +40,7 @@
     return create(name, values);
   }
 
+  @CanIgnoreReturnValue
   public static String checkName(String name) throws IllegalArgumentException {
     checkNameInternal(name);
     if ("SUBM".equals(name)) {
@@ -47,6 +49,7 @@
     return name;
   }
 
+  @CanIgnoreReturnValue
   public static String checkNameInternal(String name) throws IllegalArgumentException {
     if (name == null || name.isEmpty()) {
       throw new IllegalArgumentException("Empty label name");
@@ -246,7 +249,7 @@
         setRefPatterns(null);
       }
 
-      List<LabelValue> valueList = sortValues(getValues());
+      ImmutableList<LabelValue> valueList = sortValues(getValues());
       setValues(valueList);
       if (!valueList.isEmpty()) {
         if (valueList.get(0).getValue() < 0) {
diff --git a/java/com/google/gerrit/entities/NotifyConfig.java b/java/com/google/gerrit/entities/NotifyConfig.java
index 5c0a3db..d3123c4 100644
--- a/java/com/google/gerrit/entities/NotifyConfig.java
+++ b/java/com/google/gerrit/entities/NotifyConfig.java
@@ -18,6 +18,7 @@
 import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import java.util.EnumSet;
 import java.util.Set;
@@ -74,11 +75,13 @@
 
     public abstract Builder setHeader(Header hdr);
 
+    @CanIgnoreReturnValue
     public Builder addGroup(GroupReference group) {
       groupsBuilder().add(group);
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder addAddress(Address address) {
       addressesBuilder().add(address);
       return this;
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 0e959e7..1f2f151 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -18,6 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
 import java.util.Iterator;
@@ -274,15 +275,18 @@
 
     public abstract Builder setExclusiveGroup(boolean value);
 
+    @CanIgnoreReturnValue
     public Builder modifyRules(Consumer<List<PermissionRule.Builder>> modification) {
       modification.accept(rulesBuilders);
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder add(PermissionRule.Builder rule) {
       return modifyRules(r -> r.add(rule));
     }
 
+    @CanIgnoreReturnValue
     public Builder remove(PermissionRule rule) {
       if (rule != null) {
         return removeRule(rule.getGroup());
@@ -290,10 +294,12 @@
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder removeRule(GroupReference group) {
       return modifyRules(rules -> rules.removeIf(rule -> sameGroup(rule.build(), group)));
     }
 
+    @CanIgnoreReturnValue
     public Builder clearRules() {
       return modifyRules(r -> r.clear());
     }
diff --git a/java/com/google/gerrit/entities/PermissionRule.java b/java/com/google/gerrit/entities/PermissionRule.java
index 1665c1c..706091f 100644
--- a/java/com/google/gerrit/entities/PermissionRule.java
+++ b/java/com/google/gerrit/entities/PermissionRule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 
 @AutoValue
 public abstract class PermissionRule implements Comparable<PermissionRule> {
@@ -239,14 +240,17 @@
 
   @AutoValue.Builder
   public abstract static class Builder {
+    @CanIgnoreReturnValue
     public Builder setDeny() {
       return setAction(Action.DENY);
     }
 
+    @CanIgnoreReturnValue
     public Builder setBlock() {
       return setAction(Action.BLOCK);
     }
 
+    @CanIgnoreReturnValue
     public Builder setRange(int newMin, int newMax) {
       if (newMax < newMin) {
         setMin(newMax);
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 72ca6a9..9c2866c 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -18,6 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -195,6 +196,7 @@
   public abstract static class Builder {
     public abstract Builder setDescription(String description);
 
+    @CanIgnoreReturnValue
     public Builder setBooleanConfig(BooleanProjectConfig config, InheritableBoolean val) {
       Map<BooleanProjectConfig, InheritableBoolean> map = new HashMap<>(getBooleanConfigs());
       map.replace(config, val);
@@ -214,6 +216,7 @@
 
     public abstract Builder setParent(NameKey n);
 
+    @CanIgnoreReturnValue
     public Builder setParent(String n) {
       return setParent(n != null ? nameKey(n) : null);
     }
diff --git a/java/com/google/gerrit/entities/RobotComment.java b/java/com/google/gerrit/entities/RobotComment.java
index 1d46d3b..a4288ca 100644
--- a/java/com/google/gerrit/entities/RobotComment.java
+++ b/java/com/google/gerrit/entities/RobotComment.java
@@ -15,16 +15,15 @@
 package com.google.gerrit.entities;
 
 import java.time.Instant;
-import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 
+@Deprecated
 public final class RobotComment extends Comment {
   public String robotId;
   public String robotRunId;
   public String url;
   public Map<String, String> properties;
-  public List<FixSuggestion> fixSuggestions;
 
   public RobotComment(
       Key key,
@@ -64,7 +63,6 @@
         .add("robotRunId", robotRunId)
         .add("url", url)
         .add("properties", Objects.toString(properties, ""))
-        .add("fixSuggestions", Objects.toString(fixSuggestions, ""))
         .toString();
   }
 
@@ -78,12 +76,11 @@
         && Objects.equals(robotId, c.robotId)
         && Objects.equals(robotRunId, c.robotRunId)
         && Objects.equals(url, c.url)
-        && Objects.equals(properties, c.properties)
-        && Objects.equals(fixSuggestions, c.fixSuggestions);
+        && Objects.equals(properties, c.properties);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(super.hashCode(), robotId, robotRunId, url, properties, fixSuggestions);
+    return Objects.hash(super.hashCode(), robotId, robotRunId, url, properties);
   }
 }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index fbb2fd7..f9a5aeb 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gson.Gson;
 import com.google.gson.TypeAdapter;
 import java.util.Optional;
@@ -203,6 +204,7 @@
 
       public abstract Builder status(boolean value);
 
+      @CanIgnoreReturnValue
       public Builder addChildPredicateResult(PredicateResult result) {
         childPredicateResultsBuilder().add(result);
         return this;
diff --git a/java/com/google/gerrit/entities/SubscribeSection.java b/java/com/google/gerrit/entities/SubscribeSection.java
index 574cae8..7751907 100644
--- a/java/com/google/gerrit/entities/SubscribeSection.java
+++ b/java/com/google/gerrit/entities/SubscribeSection.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Set;
@@ -51,12 +52,14 @@
 
     abstract ImmutableList.Builder<RefSpec> multiMatchRefSpecsBuilder();
 
+    @CanIgnoreReturnValue
     public Builder addMatchingRefSpec(String matchingRefSpec) {
       matchingRefSpecsBuilder()
           .add(new RefSpec(matchingRefSpec, RefSpec.WildcardMode.REQUIRE_MATCH));
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder addMultiMatchRefSpec(String multiMatchRefSpec) {
       multiMatchRefSpecsBuilder()
           .add(new RefSpec(multiMatchRefSpec, RefSpec.WildcardMode.ALLOW_MISMATCH));
diff --git a/java/com/google/gerrit/entities/converter/AccountInputProtoConverter.java b/java/com/google/gerrit/entities/converter/AccountInputProtoConverter.java
new file mode 100644
index 0000000..c073a5f
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/AccountInputProtoConverter.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.gerrit.entities.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+
+/**
+ * Proto converter between {@link AccountInput} and {@link
+ * com.google.gerrit.proto.Entities.AccountInput}.
+ */
+@Immutable
+public enum AccountInputProtoConverter
+    implements ProtoConverter<Entities.AccountInput, AccountInput> {
+  INSTANCE;
+
+  @Override
+  public Entities.AccountInput toProto(AccountInput accountInput) {
+    Entities.AccountInput.Builder builder = Entities.AccountInput.newBuilder();
+    if (accountInput.username != null) {
+      builder.setUsername(accountInput.username);
+    }
+    if (accountInput.name != null) {
+      builder.setName(accountInput.name);
+    }
+    if (accountInput.displayName != null) {
+      builder.setDisplayName(accountInput.displayName);
+    }
+    if (accountInput.email != null) {
+      builder.setEmail(accountInput.email);
+    }
+    if (accountInput.sshKey != null) {
+      builder.setSshKey(accountInput.sshKey);
+    }
+    if (accountInput.httpPassword != null) {
+      builder.setHttpPassword(accountInput.httpPassword);
+    }
+    if (accountInput.groups != null) {
+      builder.addAllGroups(accountInput.groups);
+    }
+
+    return builder.build();
+  }
+
+  @Override
+  public AccountInput fromProto(Entities.AccountInput proto) {
+    AccountInput accountInput = new AccountInput();
+    if (proto.hasUsername()) {
+      accountInput.username = proto.getUsername();
+    }
+    if (proto.hasName()) {
+      accountInput.name = proto.getName();
+    }
+    if (proto.hasDisplayName()) {
+      accountInput.displayName = proto.getDisplayName();
+    }
+    if (proto.hasEmail()) {
+      accountInput.email = proto.getEmail();
+    }
+    if (proto.hasSshKey()) {
+      accountInput.sshKey = proto.getSshKey();
+    }
+    if (proto.hasHttpPassword()) {
+      accountInput.httpPassword = proto.getHttpPassword();
+    }
+    if (proto.getGroupsCount() > 0) {
+      accountInput.groups = proto.getGroupsList();
+    }
+    return accountInput;
+  }
+
+  @Override
+  public Parser<Entities.AccountInput> getParser() {
+    return Entities.AccountInput.parser();
+  }
+}
diff --git a/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java b/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java
new file mode 100644
index 0000000..fc862b0
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.gerrit.entities.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+
+/**
+ * Proto converter between {@link ApplyPatchInput} and {@link
+ * com.google.gerrit.proto.Entities.ApplyPatchInput}.
+ */
+@Immutable
+public enum ApplyPatchInputProtoConverter
+    implements ProtoConverter<Entities.ApplyPatchInput, ApplyPatchInput> {
+  INSTANCE;
+
+  @Override
+  public Entities.ApplyPatchInput toProto(ApplyPatchInput applyPatchInput) {
+    Entities.ApplyPatchInput.Builder builder = Entities.ApplyPatchInput.newBuilder();
+    if (applyPatchInput.patch != null) {
+      builder.setPatch(applyPatchInput.patch);
+    }
+    return builder.build();
+  }
+
+  @Override
+  public ApplyPatchInput fromProto(Entities.ApplyPatchInput proto) {
+    ApplyPatchInput applyPatchInput = new ApplyPatchInput();
+    if (proto.hasPatch()) {
+      applyPatchInput.patch = proto.getPatch();
+    }
+    return applyPatchInput;
+  }
+
+  @Override
+  public Parser<Entities.ApplyPatchInput> getParser() {
+    return Entities.ApplyPatchInput.parser();
+  }
+}
diff --git a/java/com/google/gerrit/entities/converter/ChangeInputProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeInputProtoConverter.java
new file mode 100644
index 0000000..81c7878
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/ChangeInputProtoConverter.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.gerrit.entities.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Proto converter between {@link ChangeInput} and {@link
+ * com.google.gerrit.proto.Entities.ChangeInput}.
+ */
+@Immutable
+public enum ChangeInputProtoConverter implements ProtoConverter<Entities.ChangeInput, ChangeInput> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.MergeInput, MergeInput> mergeInputConverter =
+      MergeInputProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.ApplyPatchInput, ApplyPatchInput> applyPatchInputConverter =
+      ApplyPatchInputProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.AccountInput, AccountInput> accountInputConverter =
+      AccountInputProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.NotifyInfo, NotifyInfo> notifyInfoConverter =
+      NotifyInfoProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.ChangeInput toProto(ChangeInput changeInput) {
+    Entities.ChangeInput.Builder builder = Entities.ChangeInput.newBuilder();
+    if (changeInput.project != null) {
+      builder.setProject(changeInput.project);
+    }
+    if (changeInput.branch != null) {
+      builder.setBranch(changeInput.branch);
+    }
+    if (changeInput.subject != null) {
+      builder.setSubject(changeInput.subject);
+    }
+    if (changeInput.topic != null) {
+      builder.setTopic(changeInput.topic);
+    }
+    if (changeInput.status != null) {
+      builder.setStatus(Entities.ChangeStatus.forNumber(changeInput.status.getValue()));
+    }
+    if (changeInput.isPrivate != null) {
+      builder.setIsPrivate(changeInput.isPrivate);
+    }
+    if (changeInput.workInProgress != null) {
+      builder.setWorkInProgress(changeInput.workInProgress);
+    }
+    if (changeInput.baseChange != null) {
+      builder.setBaseChange(changeInput.baseChange);
+    }
+    if (changeInput.baseCommit != null) {
+      builder.setBaseCommit(changeInput.baseCommit);
+    }
+    if (changeInput.newBranch != null) {
+      builder.setNewBranch(changeInput.newBranch);
+    }
+    if (changeInput.validationOptions != null) {
+      builder.putAllValidationOptions(changeInput.validationOptions);
+    }
+    if (changeInput.customKeyedValues != null) {
+      builder.putAllCustomKeyedValues(changeInput.customKeyedValues);
+    }
+    if (changeInput.merge != null) {
+      builder.setMerge(mergeInputConverter.toProto(changeInput.merge));
+    }
+    if (changeInput.patch != null) {
+      builder.setPatch(applyPatchInputConverter.toProto(changeInput.patch));
+    }
+    if (changeInput.author != null) {
+      builder.setAuthor(accountInputConverter.toProto(changeInput.author));
+    }
+    builder.setNotify(Entities.NotifyHandling.forNumber(changeInput.notify.getValue()));
+
+    List<ListChangesOption> responseFormatOptions = changeInput.responseFormatOptions;
+    if (responseFormatOptions != null) {
+      for (ListChangesOption option : responseFormatOptions) {
+        builder.addResponseFormatOptions(Entities.ListChangesOption.forNumber(option.getValue()));
+      }
+    }
+
+    if (changeInput.notifyDetails != null) {
+      Map<RecipientType, NotifyInfo> notifyDetails = changeInput.notifyDetails;
+      for (Map.Entry<RecipientType, NotifyInfo> entry : notifyDetails.entrySet()) {
+        Entities.RecipientType recipientType =
+            Entities.RecipientType.forNumber(entry.getKey().getValue());
+        builder.putNotifyDetails(
+            recipientType.name(), notifyInfoConverter.toProto(entry.getValue()));
+      }
+    }
+    return builder.build();
+  }
+
+  @Override
+  public ChangeInput fromProto(Entities.ChangeInput proto) {
+    ChangeInput changeInput =
+        new ChangeInput(proto.getProject(), proto.getBranch(), proto.getSubject());
+    if (proto.hasTopic()) {
+      changeInput.topic = proto.getTopic();
+    }
+    if (proto.hasStatus()) {
+      changeInput.status = ChangeStatus.valueOf(proto.getStatus().name());
+    }
+    if (proto.hasIsPrivate()) {
+      changeInput.isPrivate = proto.getIsPrivate();
+    }
+    if (proto.hasWorkInProgress()) {
+      changeInput.workInProgress = proto.getWorkInProgress();
+    }
+    if (proto.hasBaseChange()) {
+      changeInput.baseChange = proto.getBaseChange();
+    }
+    if (proto.hasBaseCommit()) {
+      changeInput.baseCommit = proto.getBaseCommit();
+    }
+    if (proto.hasNewBranch()) {
+      changeInput.newBranch = proto.getNewBranch();
+    }
+    if (proto.getValidationOptionsCount() > 0) {
+      changeInput.validationOptions = proto.getValidationOptionsMap();
+    }
+    if (proto.getCustomKeyedValuesCount() > 0) {
+      changeInput.customKeyedValues = proto.getCustomKeyedValuesMap();
+    }
+    if (proto.hasMerge()) {
+      changeInput.merge = mergeInputConverter.fromProto(proto.getMerge());
+    }
+    if (proto.hasPatch()) {
+      changeInput.patch = applyPatchInputConverter.fromProto(proto.getPatch());
+    }
+    if (proto.hasAuthor()) {
+      changeInput.author = accountInputConverter.fromProto(proto.getAuthor());
+    }
+    if (proto.getResponseFormatOptionsCount() > 0) {
+      changeInput.responseFormatOptions = new ArrayList<ListChangesOption>();
+      for (Entities.ListChangesOption option : proto.getResponseFormatOptionsList()) {
+        changeInput.responseFormatOptions.add(ListChangesOption.valueOf(option.name()));
+      }
+    }
+
+    changeInput.notify = NotifyHandling.valueOf(proto.getNotify().name());
+
+    if (proto.getNotifyDetailsCount() > 0) {
+      changeInput.notifyDetails = new HashMap<RecipientType, NotifyInfo>();
+      for (Map.Entry<String, Entities.NotifyInfo> entry : proto.getNotifyDetailsMap().entrySet()) {
+        changeInput.notifyDetails.put(
+            RecipientType.valueOf(entry.getKey()), notifyInfoConverter.fromProto(entry.getValue()));
+      }
+    }
+
+    return changeInput;
+  }
+
+  @Override
+  public Parser<Entities.ChangeInput> getParser() {
+    return Entities.ChangeInput.parser();
+  }
+}
diff --git a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 3b772d0..93cacaf 100644
--- a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -79,6 +79,10 @@
     if (cherryPickOf != null) {
       builder.setCherryPickOf(patchSetIdConverter.toProto(cherryPickOf));
     }
+    String serverId = change.getServerId();
+    if (serverId != null) {
+      builder.setServerId(serverId);
+    }
     return builder.build();
   }
 
@@ -119,6 +123,9 @@
     if (proto.hasCherryPickOf()) {
       change.setCherryPickOf(patchSetIdConverter.fromProto(proto.getCherryPickOf()));
     }
+    if (proto.hasServerId()) {
+      change.setServerId(proto.getServerId());
+    }
     return change;
   }
 
diff --git a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
new file mode 100644
index 0000000..6e8c907
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
@@ -0,0 +1,145 @@
+// 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.entities.converter;
+
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Comment.Range;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.HumanComment.InFilePosition;
+import com.google.gerrit.proto.Entities.HumanComment.InFilePosition.Side;
+import com.google.protobuf.Parser;
+import java.time.Instant;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Proto converter between {@link HumanComment} and {@link
+ * com.google.gerrit.proto.Entities.HumanComment}.
+ */
+@Immutable
+public enum HumanCommentProtoConverter
+    implements ProtoConverter<Entities.HumanComment, HumanComment> {
+  INSTANCE;
+
+  private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Entities.ObjectId, ObjectId> objectIdConverter =
+      ObjectIdProtoConverter.INSTANCE;
+
+  @Override
+  public Entities.HumanComment toProto(HumanComment val) {
+
+    Entities.HumanComment.Builder res =
+        Entities.HumanComment.newBuilder()
+            .setPatchsetId(val.key.patchSetId)
+            .setAccountId(accountIdConverter.toProto(val.author.getId()))
+            .setCommentUuid(val.key.uuid)
+            .setCommentText(val.message)
+            .setUnresolved(val.unresolved)
+            .setWrittenOnMillis(val.writtenOn.toInstant().toEpochMilli())
+            .setServerId(val.serverId);
+    if (!val.key.filename.equals(PATCHSET_LEVEL)) {
+      InFilePosition.Builder inFilePos =
+          InFilePosition.newBuilder()
+              .setFilePath(val.key.filename)
+              .setSide(val.side <= 0 ? Side.PARENT : Side.REVISION);
+      if (val.range != null) {
+        inFilePos.setPositionRange(
+            InFilePosition.Range.newBuilder()
+                .setStartLine(val.range.startLine)
+                .setStartChar(val.range.startChar)
+                .setEndLine(val.range.endLine)
+                .setEndChar(val.range.endChar));
+      }
+      if (val.lineNbr != 0) {
+        inFilePos.setLineNumber(val.lineNbr);
+      }
+      res.setInFilePosition(inFilePos);
+    }
+
+    if (val.parentUuid != null) {
+      res.setParentCommentUuid(val.parentUuid);
+    }
+    if (val.tag != null) {
+      res.setTag(val.tag);
+    }
+    if (val.realAuthor != null) {
+      res.setRealAuthor(accountIdConverter.toProto(val.realAuthor.getId()));
+    }
+    if (val.getCommitId() != null) {
+      res.setDestCommitId(objectIdConverter.toProto(val.getCommitId()));
+    }
+
+    return res.build();
+  }
+
+  @Override
+  public HumanComment fromProto(Entities.HumanComment proto) {
+    Optional<InFilePosition> optInFilePosition =
+        proto.hasInFilePosition() ? Optional.of(proto.getInFilePosition()) : Optional.empty();
+    Comment.Key key =
+        new Comment.Key(
+            proto.getCommentUuid(),
+            optInFilePosition.isPresent() ? optInFilePosition.get().getFilePath() : PATCHSET_LEVEL,
+            proto.getPatchsetId());
+    HumanComment res =
+        new HumanComment(
+            key,
+            accountIdConverter.fromProto(proto.getAccountId()),
+            Instant.ofEpochMilli(proto.getWrittenOnMillis()),
+            optInFilePosition.isPresent()
+                ? (short) optInFilePosition.get().getSide().getNumber()
+                : Side.REVISION_VALUE,
+            proto.getCommentText(),
+            proto.getServerId(),
+            proto.getUnresolved());
+
+    res.parentUuid = proto.hasParentCommentUuid() ? proto.getParentCommentUuid() : null;
+    res.tag = proto.hasTag() ? proto.getTag() : null;
+    if (proto.hasRealAuthor()) {
+      res.realAuthor = new Comment.Identity(accountIdConverter.fromProto(proto.getRealAuthor()));
+    }
+    if (proto.hasDestCommitId()) {
+      res.setCommitId(objectIdConverter.fromProto(proto.getDestCommitId()));
+    }
+
+    optInFilePosition.ifPresent(
+        inFilePosition -> {
+          if (inFilePosition.hasPositionRange()) {
+            var range = inFilePosition.getPositionRange();
+            res.range =
+                new Range(
+                    range.getStartLine(),
+                    range.getStartChar(),
+                    range.getEndLine(),
+                    range.getEndChar());
+          }
+          if (inFilePosition.hasLineNumber()) {
+            res.lineNbr = inFilePosition.getLineNumber();
+          }
+        });
+    return res;
+  }
+
+  @Override
+  public Parser<Entities.HumanComment> getParser() {
+    return Entities.HumanComment.parser();
+  }
+}
diff --git a/java/com/google/gerrit/entities/converter/MergeInputProtoConverter.java b/java/com/google/gerrit/entities/converter/MergeInputProtoConverter.java
new file mode 100644
index 0000000..11f78a4
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/MergeInputProtoConverter.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.gerrit.entities.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+
+/**
+ * Proto converter between {@link MergeInput} and {@link
+ * com.google.gerrit.proto.Entities.MergeInput}.
+ */
+@Immutable
+public enum MergeInputProtoConverter implements ProtoConverter<Entities.MergeInput, MergeInput> {
+  INSTANCE;
+
+  @Override
+  public Entities.MergeInput toProto(MergeInput mergeInput) {
+    Entities.MergeInput.Builder builder = Entities.MergeInput.newBuilder();
+    if (mergeInput.source != null) {
+      builder.setSource(mergeInput.source);
+    }
+    if (mergeInput.sourceBranch != null) {
+      builder.setSourceBranch(mergeInput.sourceBranch);
+    }
+    if (mergeInput.strategy != null) {
+      builder.setStrategy(mergeInput.strategy);
+    }
+    builder.setAllowConflicts(mergeInput.allowConflicts);
+    return builder.build();
+  }
+
+  @Override
+  public MergeInput fromProto(Entities.MergeInput proto) {
+    MergeInput mergeInput = new MergeInput();
+    if (proto.hasSource()) {
+      mergeInput.source = proto.getSource();
+    }
+    if (proto.hasSourceBranch()) {
+      mergeInput.sourceBranch = proto.getSourceBranch();
+    }
+    if (proto.hasStrategy()) {
+      mergeInput.strategy = proto.getStrategy();
+    }
+    if (proto.hasAllowConflicts()) {
+      mergeInput.allowConflicts = proto.getAllowConflicts();
+    }
+    return mergeInput;
+  }
+
+  @Override
+  public Parser<Entities.MergeInput> getParser() {
+    return Entities.MergeInput.parser();
+  }
+}
diff --git a/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java b/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
new file mode 100644
index 0000000..201dd78
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.gerrit.entities.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.proto.Entities;
+import com.google.protobuf.Parser;
+
+/**
+ * Proto converter between {@link NotifyInfo} and {@link
+ * com.google.gerrit.proto.Entities.NotifyInfo}.
+ */
+@Immutable
+public enum NotifyInfoProtoConverter implements ProtoConverter<Entities.NotifyInfo, NotifyInfo> {
+  INSTANCE;
+
+  @Override
+  public Entities.NotifyInfo toProto(NotifyInfo notifyInfo) {
+    Entities.NotifyInfo.Builder builder = Entities.NotifyInfo.newBuilder();
+    if (notifyInfo.accounts != null) {
+      builder.addAllAccounts(notifyInfo.accounts);
+    }
+    return builder.build();
+  }
+
+  @Override
+  public NotifyInfo fromProto(Entities.NotifyInfo proto) {
+    return new NotifyInfo(proto.getAccountsList());
+  }
+
+  @Override
+  public Parser<Entities.NotifyInfo> getParser() {
+    return Entities.NotifyInfo.parser();
+  }
+}
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index a3b4abf..196deca 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
 import java.time.Instant;
-import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 
 @Immutable
@@ -45,7 +44,7 @@
             .setRealUploaderAccountId(accountIdConverter.toProto(patchSet.realUploader()))
             .setCreatedOn(patchSet.createdOn().toEpochMilli());
     patchSet.branch().ifPresent(builder::setBranch);
-    List<String> groups = patchSet.groups();
+    ImmutableList<String> groups = patchSet.groups();
     if (!groups.isEmpty()) {
       builder.setGroups(PatchSet.joinGroups(groups));
     }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/entities/converter/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/entities/converter/package-info.java
index 0709b86..51ea401 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/entities/converter/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.entities.converter;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/entities/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/entities/package-info.java
index 0709b86..f1cde1b 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/entities/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.entities;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/exceptions/BUILD b/java/com/google/gerrit/exceptions/BUILD
index 873b659..b31d12e 100644
--- a/java/com/google/gerrit/exceptions/BUILD
+++ b/java/com/google/gerrit/exceptions/BUILD
@@ -7,5 +7,6 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/exceptions/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/exceptions/package-info.java
index 0709b86..ce52b4a 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/exceptions/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.exceptions;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/annotations/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/annotations/package-info.java
index 0709b86..f338b5b 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/annotations/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.annotations;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/api/access/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/api/access/package-info.java
index 0709b86..65e2d75 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/api/access/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.api.access;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index 0d019aa..e40f82e 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.accounts;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -44,18 +45,22 @@
 
   GeneralPreferencesInfo getPreferences() throws RestApiException;
 
+  @CanIgnoreReturnValue
   GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in) throws RestApiException;
 
   DiffPreferencesInfo getDiffPreferences() throws RestApiException;
 
+  @CanIgnoreReturnValue
   DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in) throws RestApiException;
 
   EditPreferencesInfo getEditPreferences() throws RestApiException;
 
+  @CanIgnoreReturnValue
   EditPreferencesInfo setEditPreferences(EditPreferencesInfo in) throws RestApiException;
 
   List<ProjectWatchInfo> getWatchedProjects() throws RestApiException;
 
+  @CanIgnoreReturnValue
   List<ProjectWatchInfo> setWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException;
 
   void deleteWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException;
@@ -72,6 +77,7 @@
 
   void deleteEmail(String email) throws RestApiException;
 
+  @CanIgnoreReturnValue
   EmailApi createEmail(EmailInput emailInput) throws RestApiException;
 
   EmailApi email(String email) throws RestApiException;
@@ -82,12 +88,14 @@
 
   List<SshKeyInfo> listSshKeys() throws RestApiException;
 
+  @CanIgnoreReturnValue
   SshKeyInfo addSshKey(String key) throws RestApiException;
 
   void deleteSshKey(int seq) throws RestApiException;
 
   Map<String, GpgKeyInfo> listGpgKeys() throws RestApiException;
 
+  @CanIgnoreReturnValue
   Map<String, GpgKeyInfo> putGpgKeys(List<String> add, List<String> remove) throws RestApiException;
 
   GpgKeyApi gpgKey(String id) throws RestApiException;
@@ -102,6 +110,7 @@
 
   void deleteExternalIds(List<String> externalIds) throws RestApiException;
 
+  @CanIgnoreReturnValue
   List<DeletedDraftCommentInfo> deleteDraftComments(DeleteDraftCommentsInput input)
       throws RestApiException;
 
@@ -122,6 +131,7 @@
    * @param httpPassword the new password, {@code null} to remove the password.
    * @return the new password, {@code null} if the password was removed.
    */
+  @CanIgnoreReturnValue
   String setHttpPassword(String httpPassword) throws RestApiException;
 
   void delete() throws RestApiException;
diff --git a/java/com/google/gerrit/extensions/api/accounts/Accounts.java b/java/com/google/gerrit/extensions/api/accounts/Accounts.java
index 285b385..ad0d385 100644
--- a/java/com/google/gerrit/extensions/api/accounts/Accounts.java
+++ b/java/com/google/gerrit/extensions/api/accounts/Accounts.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.accounts;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -53,9 +54,11 @@
   AccountApi self() throws RestApiException;
 
   /** Create a new account with the given username and default options. */
+  @CanIgnoreReturnValue
   AccountApi create(String username) throws RestApiException;
 
   /** Create a new account. */
+  @CanIgnoreReturnValue
   AccountApi create(AccountInput input) throws RestApiException;
 
   /**
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/api/accounts/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/api/accounts/package-info.java
index 0709b86..5bd477a 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/api/accounts/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.api.accounts;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index d8fd727..1c83bc2 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -25,6 +26,7 @@
 import com.google.gerrit.extensions.common.ChangeInfoDifference;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.CommitMessageInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
@@ -130,31 +132,29 @@
     setReadyForReview(null);
   }
 
-  /**
-   * Create a new change that reverts this change.
-   *
-   * @see Changes#id(int)
-   */
+  /** Create a new change that reverts this change. */
+  @CanIgnoreReturnValue
   default ChangeApi revert() throws RestApiException {
     return revert(new RevertInput());
   }
 
-  /**
-   * Create a new change that reverts this change.
-   *
-   * @see Changes#id(int)
-   */
+  /** Create a new change that reverts this change. */
+  @CanIgnoreReturnValue
   ChangeApi revert(RevertInput in) throws RestApiException;
 
+  @CanIgnoreReturnValue
   default RevertSubmissionInfo revertSubmission() throws RestApiException {
     return revertSubmission(new RevertInput());
   }
 
+  @CanIgnoreReturnValue
   RevertSubmissionInfo revertSubmission(RevertInput in) throws RestApiException;
 
   /** Create a merge patch set for the change. */
+  @CanIgnoreReturnValue
   ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException;
 
+  @CanIgnoreReturnValue
   ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException;
 
   default List<ChangeInfo> submittedTogether() throws RestApiException {
@@ -187,6 +187,7 @@
    * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
    *     chain
    */
+  @CanIgnoreReturnValue
   default Response<RebaseChainInfo> rebaseChain() throws RestApiException {
     return rebaseChain(new RebaseInput());
   }
@@ -197,6 +198,7 @@
    * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
    *     chain
    */
+  @CanIgnoreReturnValue
   Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException;
 
   /** Deletes a change. */
@@ -208,12 +210,14 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
+  @CanIgnoreReturnValue
   default ReviewerResult addReviewer(String reviewer) throws RestApiException {
     ReviewerInput in = new ReviewerInput();
     in.reviewer = reviewer;
     return addReviewer(in);
   }
 
+  @CanIgnoreReturnValue
   ReviewerResult addReviewer(ReviewerInput in) throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers() throws RestApiException;
@@ -318,6 +322,8 @@
    */
   ChangeEditApi edit() throws RestApiException;
 
+  CommitMessageInfo getMessage() throws RestApiException;
+
   /** Create a new patch set with a new commit message. */
   default void setMessage(String message) throws RestApiException {
     CommitMessageInput in = new CommitMessageInput();
@@ -356,6 +362,7 @@
   AttentionSetApi attention(String id) throws RestApiException;
 
   /** Adds a user to the attention set. */
+  @CanIgnoreReturnValue
   AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException;
 
   /**
@@ -470,9 +477,11 @@
   void index() throws RestApiException;
 
   /** Check if this change is a pure revert of the change stored in revertOf. */
+  @CanIgnoreReturnValue
   PureRevertInfo pureRevert() throws RestApiException;
 
   /** Check if this change is a pure revert of claimedOriginal (SHA1 in 40 digit hex). */
+  @CanIgnoreReturnValue
   PureRevertInfo pureRevert(String claimedOriginal) throws RestApiException;
 
   /**
@@ -711,6 +720,11 @@
     }
 
     @Override
+    public CommitMessageInfo getMessage() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void setMessage(CommitMessageInput in) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
index a26068a..2fd8a07 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
@@ -196,6 +196,18 @@
   void modifyCommitMessage(String newCommitMessage) throws RestApiException;
 
   /**
+   * Updates the author/committer of the change edit. If the change edit doesn't exist, it will be
+   * created based on the current patch set of the change.
+   *
+   * @param name the name of the author/committer
+   * @param email the email of the author/committer
+   * @param type the type of the identity being edited
+   * @throws RestApiException if the author/committer identity couldn't be updated
+   */
+  void modifyIdentity(String name, String email, ChangeEditIdentityType type)
+      throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -269,5 +281,11 @@
     public void modifyCommitMessage(String newCommitMessage) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void modifyIdentity(String name, String email, ChangeEditIdentityType type)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/api/changes/ChangeEditIdentityType.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/api/changes/ChangeEditIdentityType.java
index 0709b86..77d3f2e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeEditIdentityType.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+package com.google.gerrit.extensions.api.changes;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+public enum ChangeEditIdentityType {
+  AUTHOR,
+  COMMITTER
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/Changes.java b/java/com/google/gerrit/extensions/api/changes/Changes.java
index ea2a158..5e3d08c 100644
--- a/java/com/google/gerrit/extensions/api/changes/Changes.java
+++ b/java/com/google/gerrit/extensions/api/changes/Changes.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ListMultimap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -73,6 +74,7 @@
    */
   ChangeApi id(String project, int id) throws RestApiException;
 
+  @CanIgnoreReturnValue
   ChangeApi create(ChangeInput in) throws RestApiException;
 
   ChangeInfo createAsInfo(ChangeInput in) throws RestApiException;
diff --git a/java/com/google/gerrit/extensions/api/changes/CommentApi.java b/java/com/google/gerrit/extensions/api/changes/CommentApi.java
index 889175e..9b5e1da 100644
--- a/java/com/google/gerrit/extensions/api/changes/CommentApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/CommentApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -30,6 +31,7 @@
    *
    * @return the comment with its message updated.
    */
+  @CanIgnoreReturnValue
   CommentInfo delete(DeleteCommentInput input) throws RestApiException;
 
   /**
diff --git a/java/com/google/gerrit/extensions/api/changes/DraftApi.java b/java/com/google/gerrit/extensions/api/changes/DraftApi.java
index fa663a5..50816b7 100644
--- a/java/com/google/gerrit/extensions/api/changes/DraftApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/DraftApi.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
 public interface DraftApi extends CommentApi {
+  @CanIgnoreReturnValue
   CommentInfo update(DraftInput in) throws RestApiException;
 
   void delete() throws RestApiException;
diff --git a/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java b/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
index 98ef31c..f8a769c 100644
--- a/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
+++ b/java/com/google/gerrit/extensions/api/changes/NotifyHandling.java
@@ -15,8 +15,18 @@
 package com.google.gerrit.extensions.api.changes;
 
 public enum NotifyHandling {
-  NONE,
-  OWNER,
-  OWNER_REVIEWERS,
-  ALL
+  NONE(0),
+  OWNER(1),
+  OWNER_REVIEWERS(2),
+  ALL(3);
+
+  private final int value;
+
+  NotifyHandling(int v) {
+    this.value = v;
+  }
+
+  public int getValue() {
+    return value;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RecipientType.java b/java/com/google/gerrit/extensions/api/changes/RecipientType.java
index 3ddc597..91b2706 100644
--- a/java/com/google/gerrit/extensions/api/changes/RecipientType.java
+++ b/java/com/google/gerrit/extensions/api/changes/RecipientType.java
@@ -15,7 +15,17 @@
 package com.google.gerrit.extensions.api.changes;
 
 public enum RecipientType {
-  TO,
-  CC,
-  BCC
+  TO(0),
+  CC(1),
+  BCC(2);
+
+  private final int value;
+
+  RecipientType(int v) {
+    this.value = v;
+  }
+
+  public int getValue() {
+    return value;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index 98807cb..2584448 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -22,7 +22,6 @@
 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;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -39,7 +38,7 @@
 
   public Map<String, Short> labels;
   public Map<String, List<CommentInput>> comments;
-  public Map<String, List<RobotCommentInput>> robotComments;
+  @Deprecated public Map<String, List<RobotCommentInput>> robotComments;
 
   /**
    * How to process draft comments already in the database that were not also described in this
@@ -115,12 +114,12 @@
     public Boolean unresolved;
   }
 
+  @Deprecated
   public static class RobotCommentInput extends Comment {
     public String robotId;
     public String robotRunId;
     public String url;
     public Map<String, String> properties;
-    public List<FixSuggestionInfo> fixSuggestions;
   }
 
   @CanIgnoreReturnValue
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 73fc170..69cf25d 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.common.collect.ListMultimap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ArchiveFormat;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -44,24 +45,30 @@
 
   void description(String description) throws RestApiException;
 
+  @CanIgnoreReturnValue
   ReviewResult review(ReviewInput in) throws RestApiException;
 
+  @CanIgnoreReturnValue
   default ChangeInfo submit() throws RestApiException {
     SubmitInput in = new SubmitInput();
     return submit(in);
   }
 
+  @CanIgnoreReturnValue
   ChangeInfo submit(SubmitInput in) throws RestApiException;
 
+  @CanIgnoreReturnValue
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
 
   ChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException;
 
+  @CanIgnoreReturnValue
   default ChangeApi rebase() throws RestApiException {
     RebaseInput in = new RebaseInput();
     return rebase(in);
   }
 
+  @CanIgnoreReturnValue
   ChangeApi rebase(RebaseInput in) throws RestApiException;
 
   ChangeInfo rebaseAsInfo(RebaseInput in) throws RestApiException;
@@ -117,6 +124,7 @@
    * @param fixId the ID of the fix which should be applied
    * @throws RestApiException if the fix couldn't be applied
    */
+  @CanIgnoreReturnValue
   EditInfo applyFix(String fixId) throws RestApiException;
 
   /**
@@ -126,6 +134,7 @@
    * @param applyProvidedFixInput The fix(es) to apply to a new change edit.
    * @throws RestApiException if the fix couldn't be applied.
    */
+  @CanIgnoreReturnValue
   EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException;
 
   Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException;
@@ -133,6 +142,7 @@
   Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
       throws RestApiException;
 
+  @CanIgnoreReturnValue
   DraftApi createDraft(DraftInput in) throws RestApiException;
 
   DraftApi draft(String id) throws RestApiException;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/api/changes/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/api/changes/package-info.java
index 0709b86..f548212 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/api/changes/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.api.changes;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/api/config/ExperimentApi.java b/java/com/google/gerrit/extensions/api/config/ExperimentApi.java
new file mode 100644
index 0000000..fb30884
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/config/ExperimentApi.java
@@ -0,0 +1,30 @@
+// Copyright (C) 20124 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.config;
+
+import com.google.gerrit.extensions.common.ExperimentInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface ExperimentApi {
+  ExperimentInfo get() throws RestApiException;
+
+  class NotImplemented implements ExperimentApi {
+    @Override
+    public ExperimentInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/config/Server.java b/java/com/google/gerrit/extensions/api/config/Server.java
index 8b69ded..26806d1 100644
--- a/java/com/google/gerrit/extensions/api/config/Server.java
+++ b/java/com/google/gerrit/extensions/api/config/Server.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.extensions.api.config;
 
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.common.ExperimentInfo;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -31,20 +34,42 @@
 
   GeneralPreferencesInfo getDefaultPreferences() throws RestApiException;
 
+  @CanIgnoreReturnValue
   GeneralPreferencesInfo setDefaultPreferences(GeneralPreferencesInfo in) throws RestApiException;
 
   DiffPreferencesInfo getDefaultDiffPreferences() throws RestApiException;
 
+  @CanIgnoreReturnValue
   DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in) throws RestApiException;
 
   EditPreferencesInfo getDefaultEditPreferences() throws RestApiException;
 
+  @CanIgnoreReturnValue
   EditPreferencesInfo setDefaultEditPreferences(EditPreferencesInfo in) throws RestApiException;
 
   ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException;
 
   List<TopMenu.MenuEntry> topMenus() throws RestApiException;
 
+  ExperimentApi experiment(String name) throws RestApiException;
+
+  ListExperimentsRequest listExperiments() throws RestApiException;
+
+  abstract class ListExperimentsRequest {
+    private boolean enabledOnly;
+
+    public abstract ImmutableMap<String, ExperimentInfo> get() throws RestApiException;
+
+    public ListExperimentsRequest enabledOnly() {
+      enabledOnly = true;
+      return this;
+    }
+
+    public boolean getEnabledOnly() {
+      return enabledOnly;
+    }
+  }
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -102,5 +127,15 @@
     public List<TopMenu.MenuEntry> topMenus() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public ExperimentApi experiment(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ListExperimentsRequest listExperiments() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/api/config/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/api/config/package-info.java
index 0709b86..2ee371c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/api/config/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.api.config;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/api/groups/Groups.java b/java/com/google/gerrit/extensions/api/groups/Groups.java
index 1a46930..8d53af0 100644
--- a/java/com/google/gerrit/extensions/api/groups/Groups.java
+++ b/java/com/google/gerrit/extensions/api/groups/Groups.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.groups;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -42,9 +43,11 @@
   GroupApi id(String id) throws RestApiException;
 
   /** Create a new group with the given name and default options. */
+  @CanIgnoreReturnValue
   GroupApi create(String name) throws RestApiException;
 
   /** Create a new group. */
+  @CanIgnoreReturnValue
   GroupApi create(GroupInput input) throws RestApiException;
 
   /** Returns new request for listing groups. */
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/api/groups/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/api/groups/package-info.java
index 0709b86..3322f5c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/api/groups/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.api.groups;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/api/lfs/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/api/lfs/package-info.java
index 0709b86..2246f8d 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/api/lfs/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.api.lfs;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/api/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/api/package-info.java
index 0709b86..b7ad7da 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/api/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.api;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/api/plugins/Plugins.java b/java/com/google/gerrit/extensions/api/plugins/Plugins.java
index 6c2d6db..fed8507 100644
--- a/java/com/google/gerrit/extensions/api/plugins/Plugins.java
+++ b/java/com/google/gerrit/extensions/api/plugins/Plugins.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.plugins;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -29,9 +30,11 @@
   PluginApi name(String name) throws RestApiException;
 
   @Deprecated
+  @CanIgnoreReturnValue
   PluginApi install(String name, com.google.gerrit.extensions.common.InstallPluginInput input)
       throws RestApiException;
 
+  @CanIgnoreReturnValue
   PluginApi install(String name, InstallPluginInput input) throws RestApiException;
 
   abstract class ListRequest {
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/api/plugins/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/api/plugins/package-info.java
index 0709b86..c5324d6 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/api/plugins/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.api.plugins;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/api/projects/BranchApi.java b/java/com/google/gerrit/extensions/api/projects/BranchApi.java
index 90f1f3f..a410205 100644
--- a/java/com/google/gerrit/extensions/api/projects/BranchApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/BranchApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.api.changes.ChangeApi.SuggestedReviewersRequest;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -21,6 +22,7 @@
 import java.util.List;
 
 public interface BranchApi {
+  @CanIgnoreReturnValue
   BranchApi create(BranchInput in) throws RestApiException;
 
   BranchInfo get() throws RestApiException;
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigValue.java b/java/com/google/gerrit/extensions/api/projects/ConfigValue.java
index 5d6d2b0..2f60628 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigValue.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigValue.java
@@ -17,6 +17,13 @@
 import java.util.List;
 
 public class ConfigValue {
+
+  public ConfigValue() {}
+
+  public ConfigValue(String value) {
+    this.value = value;
+  }
+
   public String value;
   public List<String> values;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/LabelApi.java b/java/com/google/gerrit/extensions/api/projects/LabelApi.java
index 975a57e..a5e23f2 100644
--- a/java/com/google/gerrit/extensions/api/projects/LabelApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/LabelApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
@@ -21,10 +22,12 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 
 public interface LabelApi {
+  @CanIgnoreReturnValue
   LabelApi create(LabelDefinitionInput input) throws RestApiException;
 
   LabelDefinitionInfo get() throws RestApiException;
 
+  @CanIgnoreReturnValue
   LabelDefinitionInfo update(LabelDefinitionInput input) throws RestApiException;
 
   default void delete() throws RestApiException {
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 370068e..0b1b6b0 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
@@ -21,6 +22,7 @@
 import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -43,6 +45,7 @@
 
   ProjectAccessInfo access() throws RestApiException;
 
+  @CanIgnoreReturnValue
   ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException;
 
   ChangeInfo accessChange(ProjectAccessInput p) throws RestApiException;
@@ -53,6 +56,7 @@
 
   ConfigInfo config() throws RestApiException;
 
+  @CanIgnoreReturnValue
   ConfigInfo config(ConfigInput in) throws RestApiException;
 
   Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
@@ -69,9 +73,11 @@
   abstract class ListRefsRequest<T extends RefInfo> {
     protected int limit;
     protected int start;
+    protected boolean descendingOrder;
     protected String substring;
     protected String regex;
     protected String nextPageToken;
+    protected ListTagSortOption sortBy = ListTagSortOption.REF;
 
     public abstract List<T> get() throws RestApiException;
 
@@ -85,6 +91,16 @@
       return this;
     }
 
+    public ListRefsRequest<T> withDescendingOrder(boolean descendingOrder) {
+      this.descendingOrder = descendingOrder;
+      return this;
+    }
+
+    public ListRefsRequest<T> withSortBy(ListTagSortOption sortBy) {
+      this.sortBy = sortBy;
+      return this;
+    }
+
     public ListRefsRequest<T> withNextPageToken(String token) {
       this.nextPageToken = token;
       return this;
@@ -108,6 +124,14 @@
       return start;
     }
 
+    public boolean getDescendingOrder() {
+      return descendingOrder;
+    }
+
+    public ListTagSortOption getSortBy() {
+      return sortBy;
+    }
+
     public String getNextPageToken() {
       return nextPageToken;
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/Projects.java b/java/com/google/gerrit/extensions/api/projects/Projects.java
index 34ca7d4..7c8ecca 100644
--- a/java/com/google/gerrit/extensions/api/projects/Projects.java
+++ b/java/com/google/gerrit/extensions/api/projects/Projects.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -46,6 +47,7 @@
    * @return API for accessing the newly-created project.
    * @throws RestApiException if an error occurred.
    */
+  @CanIgnoreReturnValue
   ProjectApi create(String name) throws RestApiException;
 
   /**
@@ -55,6 +57,7 @@
    * @return API for accessing the newly-created project.
    * @throws RestApiException if an error occurred.
    */
+  @CanIgnoreReturnValue
   ProjectApi create(ProjectInput in) throws RestApiException;
 
   ListRequest list();
diff --git a/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
index a6e79db..29765c0 100644
--- a/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -21,12 +22,14 @@
 
 public interface SubmitRequirementApi {
   /** Create a new submit requirement. */
+  @CanIgnoreReturnValue
   SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException;
 
   /** Get existing submit requirement. */
   SubmitRequirementInfo get() throws RestApiException;
 
   /** Update existing submit requirement. */
+  @CanIgnoreReturnValue
   SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException;
 
   /** Delete existing submit requirement. */
diff --git a/java/com/google/gerrit/extensions/api/projects/TagApi.java b/java/com/google/gerrit/extensions/api/projects/TagApi.java
index 39efeac..69c29df 100644
--- a/java/com/google/gerrit/extensions/api/projects/TagApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/TagApi.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 
 public interface TagApi {
+  @CanIgnoreReturnValue
   TagApi create(TagInput input) throws RestApiException;
 
   TagInfo get() throws RestApiException;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/api/projects/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/api/projects/package-info.java
index 0709b86..4fcfc28 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/api/projects/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.api.projects;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/auth/oauth/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/auth/oauth/package-info.java
index 0709b86..591ea67 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/auth/oauth/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.auth.oauth;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/client/ChangeStatus.java b/java/com/google/gerrit/extensions/client/ChangeStatus.java
index 83d5bd2..4111dc4 100644
--- a/java/com/google/gerrit/extensions/client/ChangeStatus.java
+++ b/java/com/google/gerrit/extensions/client/ChangeStatus.java
@@ -31,7 +31,7 @@
    *   <li>{@link #ABANDONED} - when the Abandon action is used.
    * </ul>
    */
-  NEW,
+  NEW(0),
 
   /**
    * Change is closed, and submitted to its destination branch.
@@ -39,7 +39,7 @@
    * <p>Once a change has been merged, it cannot be further modified by adding a replacement patch
    * set. Draft comments however may be published, supporting a post-submit review.
    */
-  MERGED,
+  MERGED(1),
 
   /**
    * Change is closed, but was not submitted to its destination branch.
@@ -54,5 +54,15 @@
    *   <li>{@link #NEW} - when the Restore action is used.
    * </ul>
    */
-  ABANDONED
+  ABANDONED(2);
+
+  private final int value;
+
+  ChangeStatus(int v) {
+    this.value = v;
+  }
+
+  public int getValue() {
+    return value;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index b8843d3..8a68236 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.Comparator;
+import java.util.List;
 import java.util.Objects;
 
 public abstract class Comment {
@@ -49,6 +51,8 @@
    */
   public String commitId;
 
+  public List<FixSuggestionInfo> fixSuggestions;
+
   // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
   // Instant
   @SuppressWarnings("JdkObsolete")
@@ -151,13 +155,15 @@
           && Objects.equals(inReplyTo, c.inReplyTo)
           && Objects.equals(updated, c.updated)
           && Objects.equals(message, c.message)
-          && Objects.equals(commitId, c.commitId);
+          && Objects.equals(commitId, c.commitId)
+          && Objects.equals(fixSuggestions, c.fixSuggestions);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(patchSet, id, path, side, parent, line, range, inReplyTo, updated, message);
+    return Objects.hash(
+        patchSet, id, path, side, parent, line, range, inReplyTo, updated, message, fixSuggestions);
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 5c48aaf..1ed9793 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -142,6 +142,7 @@
   public List<MenuItem> my;
   public List<String> changeTable;
   public Boolean allowBrowserNotifications;
+  public Boolean allowSuggestCodeWhileCommenting;
   /**
    * The sidebar section that the user prefers to have open on the diff page, or "NONE" if all
    * sidebars should be closed.
@@ -296,6 +297,7 @@
     p.workInProgressByDefault = false;
     p.allowBrowserNotifications = true;
     p.diffPageSidebar = "NONE";
+    p.allowSuggestCodeWhileCommenting = true;
     return p;
   }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/client/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/client/package-info.java
index 0709b86..75bbfda 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/client/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.client;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 69160e9..4a3b423 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -116,6 +116,7 @@
   public Collection<ReviewerUpdateInfo> reviewerUpdates;
   public Collection<ChangeMessageInfo> messages;
 
+  public Integer currentRevisionNumber;
   public String currentRevision;
   public Map<String, RevisionInfo> revisions;
   public Boolean _moreChanges;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
index 51c35dc..308daf0 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -75,12 +75,13 @@
   @SuppressWarnings("unchecked") // reflection is used to construct instances of T
   private static <T> T getAdded(T oldValue, T newValue) {
     if (newValue instanceof Collection) {
-      List<?> result = getAddedForCollection((Collection<?>) oldValue, (Collection<?>) newValue);
+      ImmutableList<?> result =
+          getAddedForCollection((Collection<?>) oldValue, (Collection<?>) newValue);
       return (T) result;
     }
 
     if (newValue instanceof Map) {
-      Map<?, ?> result = getAddedForMap((Map<?, ?>) oldValue, (Map<?, ?>) newValue);
+      ImmutableMap<?, ?> result = getAddedForMap((Map<?, ?>) oldValue, (Map<?, ?>) newValue);
       return (T) result;
     }
 
diff --git a/java/com/google/gerrit/extensions/common/CommentInfo.java b/java/com/google/gerrit/extensions/common/CommentInfo.java
index 35587a0..aee2552 100644
--- a/java/com/google/gerrit/extensions/common/CommentInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommentInfo.java
@@ -39,13 +39,14 @@
       CommentInfo ci = (CommentInfo) o;
       return Objects.equals(author, ci.author)
           && Objects.equals(tag, ci.tag)
-          && Objects.equals(unresolved, ci.unresolved);
+          && Objects.equals(unresolved, ci.unresolved)
+          && Objects.equals(fixSuggestions, ci.fixSuggestions);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(super.hashCode(), author, tag, unresolved);
+    return Objects.hash(super.hashCode(), author, tag, unresolved, fixSuggestions);
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/CommitMessageInfo.java b/java/com/google/gerrit/extensions/common/CommitMessageInfo.java
new file mode 100644
index 0000000..fdd7cc3
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/CommitMessageInfo.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.Map;
+
+/** Representation of a commit message used in the API. */
+public class CommitMessageInfo {
+  /**
+   * The subject of the change.
+   *
+   * <p>First line of the commit message.
+   */
+  public String subject;
+
+  /** Full commit message of the change. */
+  public String fullMessage;
+
+  /**
+   * The footers from the commit message.
+   *
+   * <p>Key-value pairs from the last paragraph of the commit message.
+   */
+  public Map<String, String> footers;
+}
diff --git a/java/com/google/gerrit/extensions/common/CommitMessageInput.java b/java/com/google/gerrit/extensions/common/CommitMessageInput.java
index 1e23cb4..0b27c6b 100644
--- a/java/com/google/gerrit/extensions/common/CommitMessageInput.java
+++ b/java/com/google/gerrit/extensions/common/CommitMessageInput.java
@@ -27,4 +27,6 @@
   @Nullable public NotifyHandling notify;
 
   public Map<RecipientType, NotifyInfo> notifyDetails;
+
+  public String committerEmail;
 }
diff --git a/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java b/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java
index 6f0b178..2c2b866 100644
--- a/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java
+++ b/java/com/google/gerrit/extensions/common/DownloadSchemeInfo.java
@@ -18,6 +18,7 @@
 
 public class DownloadSchemeInfo {
   public String url;
+  public String description;
   public Boolean isAuthRequired;
   public Boolean isAuthSupported;
   public Map<String, String> commands;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/common/ExperimentInfo.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/common/ExperimentInfo.java
index 0709b86..0cf5c9d 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/common/ExperimentInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2034 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,8 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+package com.google.gerrit.extensions.common;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+public class ExperimentInfo {
+  /** Whether the experiment is enabled. */
+  public Boolean enabled;
+}
diff --git a/java/com/google/gerrit/extensions/common/FixReplacementInfo.java b/java/com/google/gerrit/extensions/common/FixReplacementInfo.java
index 9e5890e..6df4fb9 100644
--- a/java/com/google/gerrit/extensions/common/FixReplacementInfo.java
+++ b/java/com/google/gerrit/extensions/common/FixReplacementInfo.java
@@ -15,9 +15,26 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.client.Comment;
+import java.util.Objects;
 
 public class FixReplacementInfo {
   public String path;
   public Comment.Range range;
   public String replacement;
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof FixReplacementInfo)) {
+      return false;
+    }
+    FixReplacementInfo fs = (FixReplacementInfo) o;
+    return Objects.equals(path, fs.path)
+        && Objects.equals(range, fs.range)
+        && Objects.equals(replacement, fs.replacement);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(path, range, replacement);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/FixSuggestionInfo.java b/java/com/google/gerrit/extensions/common/FixSuggestionInfo.java
index 7ba7fcc..50df366 100644
--- a/java/com/google/gerrit/extensions/common/FixSuggestionInfo.java
+++ b/java/com/google/gerrit/extensions/common/FixSuggestionInfo.java
@@ -15,9 +15,26 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 public class FixSuggestionInfo {
   public String fixId;
   public String description;
   public List<FixReplacementInfo> replacements;
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof FixSuggestionInfo)) {
+      return false;
+    }
+    FixSuggestionInfo fs = (FixSuggestionInfo) o;
+    return Objects.equals(fixId, fs.fixId)
+        && Objects.equals(description, fs.description)
+        && Objects.equals(replacements, fs.replacements);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(fixId, description, replacements);
+  }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/common/ListTagSortOption.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/common/ListTagSortOption.java
index 0709b86..2140924 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/common/ListTagSortOption.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2024 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,8 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+package com.google.gerrit.extensions.common;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+public enum ListTagSortOption {
+  REF,
+  CREATION_TIME,
+}
diff --git a/java/com/google/gerrit/extensions/common/RobotCommentInfo.java b/java/com/google/gerrit/extensions/common/RobotCommentInfo.java
index 8d8731f..780cab7 100644
--- a/java/com/google/gerrit/extensions/common/RobotCommentInfo.java
+++ b/java/com/google/gerrit/extensions/common/RobotCommentInfo.java
@@ -14,13 +14,12 @@
 
 package com.google.gerrit.extensions.common;
 
-import java.util.List;
 import java.util.Map;
 
+@Deprecated
 public class RobotCommentInfo extends CommentInfo {
   public String robotId;
   public String robotRunId;
   public String url;
   public Map<String, String> properties;
-  public List<FixSuggestionInfo> fixSuggestions;
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/common/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/common/package-info.java
index 0709b86..40f03b2 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/common/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.common;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/common/testing/BUILD b/java/com/google/gerrit/extensions/common/testing/BUILD
index 33cbb99..c126928 100644
--- a/java/com/google/gerrit/extensions/common/testing/BUILD
+++ b/java/com/google/gerrit/extensions/common/testing/BUILD
@@ -13,6 +13,7 @@
         "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/extensions/common/testing/CommentInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommentInfoSubject.java
index c34e439..049d6e4 100644
--- a/java/com/google/gerrit/extensions/common/testing/CommentInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/CommentInfoSubject.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertAbout;
 import static com.google.gerrit.extensions.common.testing.AccountInfoSubject.accounts;
 import static com.google.gerrit.extensions.common.testing.RangeSubject.ranges;
+import static com.google.gerrit.truth.ListSubject.elements;
 
 import com.google.common.truth.BooleanSubject;
 import com.google.common.truth.ComparableSubject;
@@ -26,6 +27,7 @@
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.truth.ListSubject;
 import java.sql.Timestamp;
 import java.util.List;
@@ -52,6 +54,12 @@
     this.commentInfo = commentInfo;
   }
 
+  public ListSubject<FixSuggestionInfoSubject, FixSuggestionInfo> fixSuggestions() {
+    return check("fixSuggestions")
+        .about(elements())
+        .thatCustom(commentInfo.fixSuggestions, FixSuggestionInfoSubject.fixSuggestions());
+  }
+
   public StringSubject uuid() {
     return check("id").that(commentInfo().id);
   }
@@ -116,4 +124,8 @@
     isNotNull();
     return commentInfo;
   }
+
+  public FixSuggestionInfoSubject onlyFixSuggestion() {
+    return fixSuggestions().onlyElement();
+  }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/common/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/common/testing/package-info.java
index 0709b86..869891a 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/common/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.common.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/conditions/BooleanCondition.java b/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
index 9c354fb..b1c1e93 100644
--- a/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
+++ b/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.extensions.conditions;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
-import java.util.Collections;
 
 /** Delayed evaluation of a boolean condition. */
 public abstract class BooleanCondition {
@@ -270,8 +270,8 @@
     }
 
     @Override
-    public <T> Iterable<T> children(Class<T> type) {
-      return Collections.emptyList();
+    public <T> ImmutableList<T> children(Class<T> type) {
+      return ImmutableList.of();
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/conditions/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/conditions/package-info.java
index 0709b86..8a6b726 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/conditions/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.conditions;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/config/DownloadScheme.java b/java/com/google/gerrit/extensions/config/DownloadScheme.java
index 15801d4..5dad525 100644
--- a/java/com/google/gerrit/extensions/config/DownloadScheme.java
+++ b/java/com/google/gerrit/extensions/config/DownloadScheme.java
@@ -37,4 +37,9 @@
 
   /** Returns whether the download scheme is hidden in the UI */
   public abstract boolean isHidden();
+
+  /** Returns an optional description of the scheme */
+  public String getDescription() {
+    return null;
+  }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/config/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/config/package-info.java
index 0709b86..2ddb32c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/config/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.config;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/events/GerritEvent.java b/java/com/google/gerrit/extensions/events/GerritEvent.java
index e43a981..abab852 100644
--- a/java/com/google/gerrit/extensions/events/GerritEvent.java
+++ b/java/com/google/gerrit/extensions/events/GerritEvent.java
@@ -19,4 +19,8 @@
 /** Base interface to be extended by Events. */
 public interface GerritEvent {
   NotifyHandling getNotify();
+
+  default String getInstanceId() {
+    return null;
+  }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/events/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/events/package-info.java
index 0709b86..e0a454c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/events/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.events;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/package-info.java
index 0709b86..f886030 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItem.java b/java/com/google/gerrit/extensions/registration/DynamicItem.java
index 3f848cb..4464af7 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicItem.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicItem.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.inject.Binder;
 import com.google.inject.Key;
@@ -159,6 +160,7 @@
    * @param pluginName the name of the plugin providing the item.
    * @return handle to remove the item at a later point in time.
    */
+  @CanIgnoreReturnValue
   public RegistrationHandle set(T item, String pluginName) {
     return set(Providers.of(item), pluginName);
   }
@@ -170,6 +172,7 @@
    * @param pluginName name of the source providing the implementation.
    * @return handle to remove the item at a later point in time.
    */
+  @CanIgnoreReturnValue
   public RegistrationHandle set(Provider<T> impl, String pluginName) {
     final Extension<T> item = new Extension<>(pluginName, impl);
     Extension<T> old = null;
@@ -197,6 +200,7 @@
    * @param pluginName the name of the plugin providing the item.
    * @return a handle that can remove this item later, or hot-swap the item.
    */
+  @CanIgnoreReturnValue
   public ReloadableRegistrationHandle<T> set(Key<T> key, Provider<T> impl, String pluginName) {
     final Extension<T> item = new Extension<>(pluginName, impl);
     Extension<T> old = null;
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
index 6dc8c6a..9925a66 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -20,6 +20,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.inject.Binder;
 import com.google.inject.Key;
@@ -249,6 +250,7 @@
    * @param item the item to add to the collection. Must not be null.
    * @return handle to remove the item at a later point in time.
    */
+  @CanIgnoreReturnValue
   public RegistrationHandle add(String pluginName, T item) {
     return add(pluginName, Providers.of(item));
   }
@@ -259,6 +261,7 @@
    * @param item the item to add to the collection. Must not be null.
    * @return handle to remove the item at a later point in time.
    */
+  @CanIgnoreReturnValue
   public RegistrationHandle add(String pluginName, Provider<T> item) {
     final AtomicReference<Extension<T>> ref =
         new AtomicReference<>(new Extension<>(pluginName, item));
@@ -281,6 +284,7 @@
    * @return a handle that can remove this item later, or hot-swap the item without it ever leaving
    *     the collection.
    */
+  @CanIgnoreReturnValue
   public ReloadableRegistrationHandle<T> add(String pluginName, Key<T> key, Provider<T> item) {
     AtomicReference<Extension<T>> ref = new AtomicReference<>(new Extension<>(pluginName, item));
     items.add(ref);
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
index 67fc068..994a8fb 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
@@ -16,6 +16,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.inject.Key;
@@ -51,6 +52,7 @@
    * @return a handle that can remove this item later, or hot-swap the item without it ever leaving
    *     the collection.
    */
+  @CanIgnoreReturnValue
   public ReloadableRegistrationHandle<T> put(String pluginName, Key<T> key, Provider<T> item) {
     requireNonNull(item);
     String exportName = ((Export) key.getAnnotation()).value();
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/registration/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/registration/package-info.java
index 0709b86..bcd5880 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/registration/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.registration;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/restapi/BinaryResult.java b/java/com/google/gerrit/extensions/restapi/BinaryResult.java
index 2ee376e..905c0db 100644
--- a/java/com/google/gerrit/extensions/restapi/BinaryResult.java
+++ b/java/com/google/gerrit/extensions/restapi/BinaryResult.java
@@ -16,6 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.io.ByteArrayOutputStream;
 import java.io.Closeable;
 import java.io.IOException;
@@ -73,6 +74,7 @@
   }
 
   /** Set the MIME type of the result, and return {@code this}. */
+  @CanIgnoreReturnValue
   public BinaryResult setContentType(String contentType) {
     this.contentType = contentType != null ? contentType : OCTET_STREAM;
     return this;
@@ -84,6 +86,7 @@
   }
 
   /** Set the character set used to encode text data and return {@code this}. */
+  @CanIgnoreReturnValue
   public BinaryResult setCharacterEncoding(Charset encoding) {
     characterEncoding = encoding;
     return this;
@@ -95,6 +98,7 @@
   }
 
   /** Set the attachment file name and return {@code this}. */
+  @CanIgnoreReturnValue
   public BinaryResult setAttachmentName(String attachmentName) {
     this.attachmentName = attachmentName;
     return this;
@@ -106,6 +110,7 @@
   }
 
   /** Set the content length of the result; -1 if not known. */
+  @CanIgnoreReturnValue
   public BinaryResult setContentLength(long len) {
     this.contentLength = len;
     return this;
@@ -117,6 +122,7 @@
   }
 
   /** Disable gzip compression for already compressed responses. */
+  @CanIgnoreReturnValue
   public BinaryResult disableGzip() {
     this.gzip = false;
     return this;
@@ -128,6 +134,7 @@
   }
 
   /** Wrap the binary data in base64 encoding. */
+  @CanIgnoreReturnValue
   public BinaryResult base64() {
     base64 = true;
     return this;
diff --git a/java/com/google/gerrit/extensions/restapi/Response.java b/java/com/google/gerrit/extensions/restapi/Response.java
index 3ebae8d..85dd643 100644
--- a/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/java/com/google/gerrit/extensions/restapi/Response.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.restapi;
 
 import com.google.common.collect.ImmutableMultimap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.util.concurrent.TimeUnit;
 
 /** Special return value to mean specific HTTP status codes in a REST API. */
@@ -90,6 +91,7 @@
 
   public abstract CacheControl caching();
 
+  @CanIgnoreReturnValue
   public abstract Response<T> caching(CacheControl c);
 
   @Override
diff --git a/java/com/google/gerrit/extensions/restapi/RestApiModule.java b/java/com/google/gerrit/extensions/restapi/RestApiModule.java
index 85bd5a1..6d0191e 100644
--- a/java/com/google/gerrit/extensions/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/extensions/restapi/RestApiModule.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.restapi;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -128,6 +129,7 @@
       this.binder = binder;
     }
 
+    @CanIgnoreReturnValue
     public <T extends RestReadView<P>> ScopedBindingBuilder to(Class<T> impl) {
       return binder.to(impl);
     }
@@ -136,11 +138,13 @@
       binder.toInstance(impl);
     }
 
+    @CanIgnoreReturnValue
     public <T extends RestReadView<P>> ScopedBindingBuilder toProvider(
         Class<? extends Provider<? extends T>> providerType) {
       return binder.toProvider(providerType);
     }
 
+    @CanIgnoreReturnValue
     public <T extends RestReadView<P>> ScopedBindingBuilder toProvider(
         Provider<? extends T> provider) {
       return binder.toProvider(provider);
@@ -154,6 +158,7 @@
       this.binder = binder;
     }
 
+    @CanIgnoreReturnValue
     public <T extends RestModifyView<P, ?>> ScopedBindingBuilder to(Class<T> impl) {
       return binder.to(impl);
     }
@@ -162,11 +167,13 @@
       binder.toInstance(impl);
     }
 
+    @CanIgnoreReturnValue
     public <T extends RestModifyView<P, ?>> ScopedBindingBuilder toProvider(
         Class<? extends Provider<? extends T>> providerType) {
       return binder.toProvider(providerType);
     }
 
+    @CanIgnoreReturnValue
     public <T extends RestModifyView<P, ?>> ScopedBindingBuilder toProvider(
         Provider<? extends T> provider) {
       return binder.toProvider(provider);
@@ -180,6 +187,7 @@
       this.binder = binder;
     }
 
+    @CanIgnoreReturnValue
     public <P extends RestResource, T extends RestCollectionModifyView<P, C, ?>>
         ScopedBindingBuilder to(Class<T> impl) {
       return binder.to(impl);
@@ -190,11 +198,13 @@
       binder.toInstance(impl);
     }
 
+    @CanIgnoreReturnValue
     public <P extends RestResource, T extends RestCollectionModifyView<P, C, ?>>
         ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
       return binder.toProvider(providerType);
     }
 
+    @CanIgnoreReturnValue
     public <P extends RestResource, T extends RestCollectionModifyView<P, C, ?>>
         ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
       return binder.toProvider(provider);
@@ -208,6 +218,7 @@
       this.binder = binder;
     }
 
+    @CanIgnoreReturnValue
     public <P extends RestResource, T extends RestCollectionCreateView<P, C, ?>>
         ScopedBindingBuilder to(Class<T> impl) {
       return binder.to(impl);
@@ -218,11 +229,13 @@
       binder.toInstance(impl);
     }
 
+    @CanIgnoreReturnValue
     public <P extends RestResource, T extends RestCollectionCreateView<P, C, ?>>
         ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
       return binder.toProvider(providerType);
     }
 
+    @CanIgnoreReturnValue
     public <P extends RestResource, T extends RestCollectionCreateView<P, C, ?>>
         ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
       return binder.toProvider(provider);
@@ -236,6 +249,7 @@
       this.binder = binder;
     }
 
+    @CanIgnoreReturnValue
     public <P extends RestResource, T extends RestCollectionDeleteMissingView<P, C, ?>>
         ScopedBindingBuilder to(Class<T> impl) {
       return binder.to(impl);
@@ -246,11 +260,13 @@
       binder.toInstance(impl);
     }
 
+    @CanIgnoreReturnValue
     public <P extends RestResource, T extends RestCollectionDeleteMissingView<P, C, ?>>
         ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
       return binder.toProvider(providerType);
     }
 
+    @CanIgnoreReturnValue
     public <P extends RestResource, T extends RestCollectionDeleteMissingView<P, C, ?>>
         ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
       return binder.toProvider(provider);
@@ -264,6 +280,7 @@
       this.binder = binder;
     }
 
+    @CanIgnoreReturnValue
     public <C extends RestResource, T extends ChildCollection<P, C>> ScopedBindingBuilder to(
         Class<T> impl) {
       return binder.to(impl);
@@ -273,11 +290,13 @@
       binder.toInstance(impl);
     }
 
+    @CanIgnoreReturnValue
     public <C extends RestResource, T extends ChildCollection<P, C>>
         ScopedBindingBuilder toProvider(Class<? extends Provider<? extends T>> providerType) {
       return binder.toProvider(providerType);
     }
 
+    @CanIgnoreReturnValue
     public <C extends RestResource, T extends ChildCollection<P, C>>
         ScopedBindingBuilder toProvider(Provider<? extends T> provider) {
       return binder.toProvider(provider);
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/restapi/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/restapi/package-info.java
index 0709b86..f3762e5 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/restapi/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.restapi;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/restapi/testing/BUILD b/java/com/google/gerrit/extensions/restapi/testing/BUILD
index da11ce8..a86edb8 100644
--- a/java/com/google/gerrit/extensions/restapi/testing/BUILD
+++ b/java/com/google/gerrit/extensions/restapi/testing/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/truth",
+        "//lib/errorprone:annotations",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/restapi/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/restapi/testing/package-info.java
index 0709b86..74e31e7 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/restapi/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.restapi.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/systemstatus/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/systemstatus/package-info.java
index 0709b86..ed9a8cc 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/systemstatus/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.systemstatus;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/validators/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/validators/package-info.java
index 0709b86..076519a 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/validators/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.validators;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/extensions/webui/UiAction.java b/java/com/google/gerrit/extensions/webui/UiAction.java
index 9da0642..6c1d01d 100644
--- a/java/com/google/gerrit/extensions/webui/UiAction.java
+++ b/java/com/google/gerrit/extensions/webui/UiAction.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.webui;
 
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.RestResource;
@@ -68,6 +69,7 @@
     }
 
     /** Set the label to appear on the button to activate this action. */
+    @CanIgnoreReturnValue
     public Description setLabel(String label) {
       this.label = label;
       return this;
@@ -78,6 +80,7 @@
     }
 
     /** Set the tool-tip text to appear when the mouse hovers on the button. */
+    @CanIgnoreReturnValue
     public Description setTitle(String title) {
       this.title = title;
       return this;
@@ -95,6 +98,7 @@
      * Set if the action's button is visible on screen for the current client. If not visible the
      * action description may not be sent to the client.
      */
+    @CanIgnoreReturnValue
     public Description setVisible(boolean visible) {
       return setVisible(BooleanCondition.valueOf(visible));
     }
@@ -103,6 +107,7 @@
      * Set if the action's button is visible on screen for the current client. If not visible the
      * action description may not be sent to the client.
      */
+    @CanIgnoreReturnValue
     public Description setVisible(BooleanCondition visible) {
       this.visible = visible;
       return this;
@@ -117,11 +122,13 @@
     }
 
     /** Set if the button should be invokable (true), or greyed out (false). */
+    @CanIgnoreReturnValue
     public Description setEnabled(boolean enabled) {
       return setEnabled(BooleanCondition.valueOf(enabled));
     }
 
     /** Set if the button should be invokable (true), or greyed out (false). */
+    @CanIgnoreReturnValue
     public Description setEnabled(BooleanCondition enabled) {
       this.enabled = enabled;
       return this;
@@ -135,6 +142,7 @@
       return ImmutableList.copyOf(enabledOptions);
     }
 
+    @CanIgnoreReturnValue
     public Description setOption(String optionName, boolean enabled) {
       if (enabled) {
         enabledOptions.add(optionName);
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/extensions/webui/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/extensions/webui/package-info.java
index 0709b86..bc520f8 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/extensions/webui/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.extensions.webui;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/git/BUILD b/java/com/google/gerrit/git/BUILD
index 98dacfa..3cd2be1 100644
--- a/java/com/google/gerrit/git/BUILD
+++ b/java/com/google/gerrit/git/BUILD
@@ -11,5 +11,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/errorprone:annotations",
+        "//lib/flogger:api",
+        "//lib/log:log4j",
     ],
 )
diff --git a/java/com/google/gerrit/git/RefUpdateUtil.java b/java/com/google/gerrit/git/RefUpdateUtil.java
index c2db073..d0f738f 100644
--- a/java/com/google/gerrit/git/RefUpdateUtil.java
+++ b/java/com/google/gerrit/git/RefUpdateUtil.java
@@ -14,12 +14,18 @@
 
 package com.google.gerrit.git;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.flogger.FluentLogger;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -27,6 +33,8 @@
 
 /** Static utilities for working with JGit's ref update APIs. */
 public class RefUpdateUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   /**
    * Execute a batch ref update, throwing a checked exception if not all updates succeeded.
    *
@@ -54,10 +62,36 @@
    * @throws IOException if any result was not {@code OK}.
    */
   public static void executeChecked(BatchRefUpdate bru, RevWalk rw) throws IOException {
+    logger.atFine().log(
+        "Executing ref updates: %s\n",
+        Joiner.on("\n")
+            .join(
+                bru.getCommands().stream()
+                    .map(
+                        cmd ->
+                            String.format(
+                                "%s (new tree ID: %s)",
+                                cmd, getNewTreeId(rw, cmd).map(ObjectId::name).orElse("n/a")))
+                    .collect(toImmutableList())));
     bru.execute(rw, NullProgressMonitor.INSTANCE);
     checkResults(bru);
   }
 
+  private static Optional<ObjectId> getNewTreeId(RevWalk revWalk, ReceiveCommand cmd) {
+    if (ReceiveCommand.Type.DELETE.equals(cmd.getType())) {
+      // Ref deletions do not have a new tree.
+      return Optional.empty();
+    }
+
+    try {
+      return Optional.of(revWalk.parseCommit(cmd.getNewId()).getTree());
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log(
+          "Failed parsing new commit %s for ref update: %s", cmd.getNewId().name(), cmd);
+      return Optional.empty();
+    }
+  }
+
   /**
    * Check results of all commands in the update batch, reducing to a single exception if there was
    * a failure.
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/git/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/git/package-info.java
index 0709b86..8c4d4b1 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/git/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.git;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/git/testing/BUILD b/java/com/google/gerrit/git/testing/BUILD
index fda9aff..63bcfba 100644
--- a/java/com/google/gerrit/git/testing/BUILD
+++ b/java/com/google/gerrit/git/testing/BUILD
@@ -10,6 +10,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
     ],
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/git/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/git/testing/package-info.java
index 0709b86..7ed8c4d 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/git/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.git.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index 3958821..fb24f13 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -19,6 +19,7 @@
         "//lib/auto:auto-factory",
         "//lib/bouncycastle:bcpg-neverlink",
         "//lib/bouncycastle:bcprov-neverlink",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/gpg/Fingerprint.java b/java/com/google/gerrit/gpg/Fingerprint.java
index c12ff8b..a0212ca 100644
--- a/java/com/google/gerrit/gpg/Fingerprint.java
+++ b/java/com/google/gerrit/gpg/Fingerprint.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
@@ -53,6 +54,7 @@
     return Collections.unmodifiableMap(result);
   }
 
+  @CanIgnoreReturnValue
   private static byte[] checkLength(byte[] fp) {
     checkArgument(fp.length == 20, "fingerprint must be 20 bytes, got %s", fp.length);
     return fp;
diff --git a/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index fff4045..3940cc9 100644
--- a/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
@@ -129,6 +130,7 @@
    * user. (Other keys checked in the course of verifying the web of trust are checked against the
    * set of identities in the database belonging to the same user as the key.)
    */
+  @CanIgnoreReturnValue
   public GerritPublicKeyChecker setExpectedUser(IdentifiedUser expectedUser) {
     this.expectedUser = expectedUser;
     return this;
diff --git a/java/com/google/gerrit/gpg/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
index 946fee3..2f8f7e9 100644
--- a/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -30,6 +30,7 @@
 import static org.bouncycastle.openpgp.PGPSignature.KEY_REVOCATION;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
 import java.io.IOException;
@@ -77,6 +78,7 @@
    *     construct a map, see {@link Fingerprint#byId(Iterable)}.
    * @return a reference to this object.
    */
+  @CanIgnoreReturnValue
   public PublicKeyChecker enableTrust(int maxTrustDepth, Map<Long, Fingerprint> trusted) {
     if (maxTrustDepth <= 0) {
       throw new IllegalArgumentException("maxTrustDepth must be positive, got: " + maxTrustDepth);
@@ -90,12 +92,14 @@
   }
 
   /** Disable web-of-trust checks. */
+  @CanIgnoreReturnValue
   public PublicKeyChecker disableTrust() {
     trusted = null;
     return this;
   }
 
   /** Set the public key store for reading keys referenced in signatures. */
+  @CanIgnoreReturnValue
   public PublicKeyChecker setStore(PublicKeyStore store) {
     if (store == null) {
       throw new IllegalArgumentException("PublicKeyStore is required");
@@ -112,6 +116,7 @@
    * @param effectiveTime effective time.
    * @return a reference to this object.
    */
+  @CanIgnoreReturnValue
   public PublicKeyChecker setEffectiveTime(Instant effectiveTime) {
     this.effectiveTime = effectiveTime;
     return this;
diff --git a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index 2a05f35..713db48 100644
--- a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -55,7 +55,8 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      delete.apply(rsrc, new Input());
+      @SuppressWarnings("unused")
+      var unused = delete.apply(rsrc, new Input());
     } catch (RestApiException e) {
       throw e;
     } catch (PGPException | IOException | ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/gpg/api/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/gpg/api/package-info.java
index 0709b86..524dc1c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/gpg/api/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.gpg.api;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/gpg/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/gpg/package-info.java
index 0709b86..6e56395 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/gpg/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.gpg;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 886e4dd..e514e92 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -25,8 +25,10 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
@@ -131,12 +133,12 @@
       throws RestApiException, PGPException, IOException, ConfigInvalidException {
     GpgKeys.checkVisible(self, rsrc);
 
-    Collection<ExternalId> existingExtIds =
+    ImmutableSet<ExternalId> existingExtIds =
         externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
     try (PublicKeyStore store = storeProvider.get()) {
-      Map<ExternalId, Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
-      Collection<Fingerprint> fingerprintsToRemove = toRemove.values();
-      List<PGPPublicKeyRing> newKeys = readKeysToAdd(input, fingerprintsToRemove);
+      ImmutableMap<ExternalId, Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
+      ImmutableCollection<Fingerprint> fingerprintsToRemove = toRemove.values();
+      ImmutableList<PGPPublicKeyRing> newKeys = readKeysToAdd(input, fingerprintsToRemove);
       List<ExternalId> newExtIds = new ArrayList<>(existingExtIds.size());
 
       for (PGPPublicKeyRing keyRing : newKeys) {
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/gpg/server/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/gpg/server/package-info.java
index 0709b86..5379c44 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/gpg/server/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.gpg.server;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/gpg/testing/BUILD b/java/com/google/gerrit/gpg/testing/BUILD
index dc39071..d55360b 100644
--- a/java/com/google/gerrit/gpg/testing/BUILD
+++ b/java/com/google/gerrit/gpg/testing/BUILD
@@ -10,5 +10,6 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib/bouncycastle:bcpg-neverlink",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/gpg/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/gpg/testing/package-info.java
index 0709b86..d9606c3 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/gpg/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.gpg.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/httpd/AllRequestFilter.java b/java/com/google/gerrit/httpd/AllRequestFilter.java
index 1c3cafe..0422655 100644
--- a/java/com/google/gerrit/httpd/AllRequestFilter.java
+++ b/java/com/google/gerrit/httpd/AllRequestFilter.java
@@ -70,7 +70,7 @@
      * Initializes a filter if needed
      *
      * @param filter The filter that should get initialized
-     * @return {@code true} iff filter is now initialized
+     * @return {@code true} if filter is now initialized
      * @throws ServletException if filter itself fails to init
      */
     private synchronized boolean initFilterIfNeeded(AllRequestFilter filter)
@@ -150,7 +150,8 @@
       filterConfig = config;
 
       for (AllRequestFilter f : filters) {
-        initFilterIfNeeded(f);
+        @SuppressWarnings("unused")
+        var unused = initFilterIfNeeded(f);
       }
     }
 
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index e513a72..1537655 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -19,7 +19,6 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -259,7 +258,8 @@
     return commandName.toString();
   }
 
-  private static ListMultimap<String, String> extractParameters(HttpServletRequest request) {
+  private static ImmutableListMultimap<String, String> extractParameters(
+      HttpServletRequest request) {
     if (request.getQueryString() == null) {
       return ImmutableListMultimap.of();
     }
diff --git a/java/com/google/gerrit/httpd/RequestContextFilter.java b/java/com/google/gerrit/httpd/RequestContextFilter.java
index effbac0..9428c5e 100644
--- a/java/com/google/gerrit/httpd/RequestContextFilter.java
+++ b/java/com/google/gerrit/httpd/RequestContextFilter.java
@@ -63,7 +63,8 @@
     try {
       chain.doFilter(request, response);
     } finally {
-      local.setContext(old);
+      @SuppressWarnings("unused")
+      var unused = local.setContext(old);
     }
   }
 }
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index aad6b57..7a100c7 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -43,7 +43,7 @@
 import org.eclipse.jgit.lib.Constants;
 
 class UrlModule extends ServletModule {
-  private AuthConfig authConfig;
+  private final AuthConfig authConfig;
 
   UrlModule(AuthConfig authConfig) {
     this.authConfig = authConfig;
@@ -86,7 +86,7 @@
     serveRegex("^/(?:a/)?tools/(.*)$").with(ToolServlet.class);
 
     // Serve auth check. Mainly used by PolyGerrit for checking if a user is still logged in.
-    serveRegex("^/(?:a/)?auth-check$").with(AuthorizationCheckServlet.class);
+    serveRegex("^/(?:a/)?auth-check(\\.svg)?$").with(AuthorizationCheckServlet.class);
 
     // Bind servlets for REST root collections.
     // The '/plugins/' root collection is already handled by HttpPluginServlet
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/auth/become/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/auth/become/package-info.java
index 0709b86..6887673 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/auth/become/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.auth.become;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
index 59a7379..82cef6b 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
@@ -63,7 +63,7 @@
       throw new ServletException(
           "Couldn't get the attribute javax.servlet.request.X509Certificate from the request");
     }
-    String name = certs[0].getSubjectDN().getName();
+    String name = certs[0].getSubjectX500Principal().getName();
     Matcher m = REGEX_USERID.matcher(name);
     String userName;
     if (m.find()) {
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/auth/container/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/auth/container/package-info.java
index 0709b86..ca1c89f 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/auth/container/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.auth.container;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/auth/ldap/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/auth/ldap/package-info.java
index 0709b86..2a81cc9 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/auth/ldap/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.auth.ldap;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/httpd/auth/oauth/BUILD b/java/com/google/gerrit/httpd/auth/oauth/BUILD
index 2bc65de4..3ced4ab 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/BUILD
+++ b/java/com/google/gerrit/httpd/auth/oauth/BUILD
@@ -17,6 +17,7 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:servlet-api",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-servlet",
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
index 935762f..453dc0b 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
@@ -102,7 +102,9 @@
         service = findService(provider);
       }
       oauthSession.setServiceProvider(service);
-      oauthSession.login(httpRequest, httpResponse, service);
+
+      @SuppressWarnings("unused")
+      var unused = oauthSession.login(httpRequest, httpResponse, service);
     } else {
       chain.doFilter(httpRequest, response);
     }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/auth/oauth/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/auth/oauth/package-info.java
index 0709b86..4f8c601 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.auth.oauth;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/httpd/auth/openid/BUILD b/java/com/google/gerrit/httpd/auth/openid/BUILD
index 29841aa..7afb8ac 100644
--- a/java/com/google/gerrit/httpd/auth/openid/BUILD
+++ b/java/com/google/gerrit/httpd/auth/openid/BUILD
@@ -17,6 +17,7 @@
         "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib:servlet-api",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-servlet",
diff --git a/java/com/google/gerrit/httpd/auth/openid/LoginForm.java b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
index e7057ad..4ed9078 100644
--- a/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
+++ b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
@@ -174,7 +174,9 @@
       if ((isGerritLogin(req) || oauthSession.isOAuthFinal(req))) {
         oauthSession.setServiceProvider(oauthProvider);
         oauthSession.setLinkMode(link);
-        oauthSession.login(req, res, oauthProvider);
+
+        @SuppressWarnings("unused")
+        var unused = oauthSession.login(req, res, oauthProvider);
       }
     }
   }
diff --git a/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java b/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
index 3d9c819..043b7a1 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
@@ -72,7 +72,9 @@
         throw new IllegalStateException("service is unknown");
       }
       oauthSession.setServiceProvider(service);
-      oauthSession.login(httpRequest, httpResponse, service);
+
+      @SuppressWarnings("unused")
+      var unused = oauthSession.login(httpRequest, httpResponse, service);
     } else {
       chain.doFilter(httpRequest, response);
     }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/auth/openid/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/auth/openid/package-info.java
index 0709b86..5e597da 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/auth/openid/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.auth.openid;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/httpd/auth/restapi/BUILD b/java/com/google/gerrit/httpd/auth/restapi/BUILD
index 9ab51c5..a85fd5e 100644
--- a/java/com/google/gerrit/httpd/auth/restapi/BUILD
+++ b/java/com/google/gerrit/httpd/auth/restapi/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
     ],
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/auth/restapi/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/auth/restapi/package-info.java
index 0709b86..a56dbde 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/auth/restapi/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.auth.restapi;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index d6718ca..d3de8e0 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -35,6 +35,8 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterators;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.entities.Project;
@@ -75,7 +77,6 @@
 import java.net.URISyntaxException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -771,8 +772,8 @@
   }
 
   @SuppressWarnings("JdkObsolete")
-  private static Iterable<String> getHeaderNames(HttpServletRequest req) {
-    return Collections.list(req.getHeaderNames());
+  private static ImmutableList<String> getHeaderNames(HttpServletRequest req) {
+    return ImmutableList.copyOf(Iterators.forEnumeration(req.getHeaderNames()));
   }
 
   /** private utility class that manages the Environment passed to exec. */
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/gitweb/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/gitweb/package-info.java
index 0709b86..614388e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/gitweb/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.gitweb;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index 990b5d7..e6fc53c 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -31,6 +31,7 @@
         "//lib:jgit",
         "//lib:jgit-ssh-apache",
         "//lib:servlet-api",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-servlet",
diff --git a/java/com/google/gerrit/httpd/init/SiteInitializer.java b/java/com/google/gerrit/httpd/init/SiteInitializer.java
index 04a0ddd..f9f93e1 100644
--- a/java/com/google/gerrit/httpd/init/SiteInitializer.java
+++ b/java/com/google/gerrit/httpd/init/SiteInitializer.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.pgm.init.BaseInit;
 import com.google.gerrit.pgm.init.PluginsDistribution;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.List;
 
 public final class SiteInitializer {
@@ -45,24 +44,29 @@
   public void init() {
     try {
       if (sitePath != null) {
-        Path site = Paths.get(sitePath);
+        Path site = Path.of(sitePath);
         logger.atInfo().log("Initializing site at %s", site.toRealPath().normalize());
-        new BaseInit(site, false, pluginsDistribution, pluginsToInstall).run();
+
+        @SuppressWarnings("unused")
+        var unused = new BaseInit(site, false, pluginsDistribution, pluginsToInstall).run();
+
         return;
       }
 
       String path = System.getProperty(GERRIT_SITE_PATH);
       Path site = null;
       if (!Strings.isNullOrEmpty(path)) {
-        site = Paths.get(path);
+        site = Path.of(path);
       }
 
       if (site == null && initPath != null) {
-        site = Paths.get(initPath);
+        site = Path.of(initPath);
       }
       if (site != null) {
         logger.atInfo().log("Initializing site at %s", site.toRealPath().normalize());
-        new BaseInit(site, false, pluginsDistribution, pluginsToInstall).run();
+
+        @SuppressWarnings("unused")
+        var unused = new BaseInit(site, false, pluginsDistribution, pluginsToInstall).run();
       }
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Site init failed");
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index e1abcb1..fa67034 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -45,7 +45,7 @@
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
-import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
+import com.google.gerrit.pgm.util.LogFileManager.LogFileManagerModule;
 import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
@@ -132,7 +132,6 @@
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -176,7 +175,7 @@
     if (manager == null) {
       String path = System.getProperty(GERRIT_SITE_PATH);
       if (path != null) {
-        sitePath = Paths.get(path);
+        sitePath = Path.of(path);
       } else {
         throw new ProvisionException(GERRIT_SITE_PATH + " must be defined");
       }
@@ -303,7 +302,7 @@
   private Injector createSysInjector() {
     final List<Module> modules = new ArrayList<>();
     modules.add(new DropWizardMetricMaker.RestModule());
-    modules.add(new LogFileCompressorModule());
+    modules.add(new LogFileManagerModule());
     modules.add(new EventBrokerModule());
     modules.add(new JdbcAccountPatchReviewStoreModule(config));
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
@@ -311,7 +310,10 @@
     modules.add(new SysExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
+
     modules.add(cfgInjector.getInstance(AccountCacheImpl.AccountCacheModule.class));
+    modules.add(cfgInjector.getInstance(AccountCacheImpl.AccountCacheBindingModule.class));
+
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new ProjectQueryBuilderModule());
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/init/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/init/package-info.java
index 0709b86..8ca6c9f 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/init/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.init;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/package-info.java
index 0709b86..e0bb8e0 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java b/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
index 9ab2d72..3d0a139 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpAutoRegisterModuleGenerator.java
@@ -30,6 +30,7 @@
 import java.util.Map;
 import javax.servlet.http.HttpServlet;
 
+@SuppressWarnings("MutableGuiceModule")
 class HttpAutoRegisterModuleGenerator extends ServletModule implements ModuleGenerator {
   private final Map<String, Class<HttpServlet>> serve = new HashMap<>();
   private final ListMultimap<TypeLiteral<?>, Class<?>> listeners = LinkedListMultimap.create();
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/plugins/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/plugins/package-info.java
index 0709b86..1149405 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/plugins/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.plugins;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
index e3e96df..1e043b1 100644
--- a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
+++ b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.httpd.raw;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
@@ -44,8 +46,18 @@
   protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
     CacheHeaders.setNotCacheable(res);
     if (user.get().isIdentifiedUser()) {
-      res.setContentLength(0);
-      res.setStatus(HttpServletResponse.SC_NO_CONTENT);
+      if (req.getRequestURI().endsWith(".svg")) {
+        String responseToClient =
+            "<?xml version=\"1.0\" encoding=\"UTF-8\"?><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1\" height=\"1\"/>";
+        res.setContentType("image/svg+xml");
+        res.setCharacterEncoding(UTF_8.name());
+        res.setStatus(HttpServletResponse.SC_OK);
+        res.getWriter().write(responseToClient);
+        res.getWriter().flush();
+      } else {
+        res.setContentLength(0);
+        res.setStatus(HttpServletResponse.SC_NO_CONTENT);
+      }
     } else {
       res.setStatus(HttpServletResponse.SC_FORBIDDEN);
     }
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index fb28d30..77cbe5b 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -146,7 +146,7 @@
   }
 
   /** Returns all static parameters of {@code index.html}. */
-  static Map<String, Object> staticTemplateData(
+  static ImmutableMap<String, Object> staticTemplateData(
       String canonicalURL,
       String cdnPath,
       String faviconPath,
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index 4c42e79..cc11638 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -99,6 +99,7 @@
           ListChangesOption.DETAILED_LABELS,
           ListChangesOption.DOWNLOAD_COMMANDS,
           ListChangesOption.MESSAGES,
+          ListChangesOption.REVIEWER_UPDATES,
           ListChangesOption.SUBMITTABLE,
           ListChangesOption.WEB_LINKS,
           ListChangesOption.SKIP_DIFFSTAT,
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index d7909b2..b00294f 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -19,6 +19,7 @@
 import static java.nio.file.Files.exists;
 import static java.nio.file.Files.isReadable;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
@@ -62,7 +63,7 @@
 
 public class StaticModule extends ServletModule {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-  public static final String CHANGE_NUMBER_URI_REGEX = "^(?:/c)?/([1-9][0-9]*)/?$";
+  public static final String CHANGE_NUMBER_URI_REGEX = "^(?:/c)?/([1-9][0-9]*)/?.*";
   private static final Pattern CHANGE_NUMBER_URI_PATTERN = Pattern.compile(CHANGE_NUMBER_URI_REGEX);
 
   /**
@@ -87,19 +88,17 @@
   private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
 
   private final GerritOptions options;
-  private Paths paths;
+  private final Paths paths;
 
   @Inject
   public StaticModule(GerritOptions options) {
     this.options = options;
+    this.paths = new Paths(options);
   }
 
   @Provides
   @Singleton
   private Paths getPaths() {
-    if (paths == null) {
-      paths = new Paths(options);
-    }
     return paths;
   }
 
@@ -263,8 +262,7 @@
           // root directory
           warFs = null;
           unpackedWar =
-              java.nio.file.Paths.get(
-                  launcherLoadedFrom.getParentFile().getParentFile().getParentFile().toURI());
+              Path.of(launcherLoadedFrom.getParentFile().getParentFile().getParentFile().toURI());
           sourceRoot = null;
           development = false;
           return;
@@ -354,7 +352,7 @@
   }
 
   @Singleton
-  private static class PolyGerritFilter implements Filter {
+  protected static class PolyGerritFilter implements Filter {
     private final HttpServlet polyGerritIndex;
     private final PolyGerritUiServlet polygerritUI;
 
@@ -405,7 +403,8 @@
       return matchPath(POLYGERRIT_ASSET_PATHS, path);
     }
 
-    private static boolean isPolyGerritIndex(String path) {
+    @VisibleForTesting
+    protected static boolean isPolyGerritIndex(String path) {
       return !isChangeNumberUri(path) && matchPath(POLYGERRIT_INDEX_PATHS, path);
     }
 
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/raw/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/raw/package-info.java
index 0709b86..6505062 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/raw/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.raw;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/resources/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/resources/package-info.java
index 0709b86..94d35f6 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/resources/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.resources;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 9b53c17..6951398 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -51,6 +51,7 @@
 import com.google.common.io.CountingOutputStream;
 import com.google.common.math.IntMath;
 import com.google.common.net.HttpHeaders;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Project;
@@ -520,6 +521,7 @@
                       (RestReadView<RestResource>) viewData.view,
                       rsrc);
             } else if (viewData.view instanceof RestModifyView<?, ?>) {
+              @SuppressWarnings("unchecked")
               RestModifyView<RestResource, Object> m =
                   (RestModifyView<RestResource, Object>) viewData.view;
 
@@ -535,6 +537,7 @@
                 }
               }
             } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
+              @SuppressWarnings("unchecked")
               RestCollectionCreateView<RestResource, RestResource, Object> m =
                   (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
 
@@ -549,6 +552,7 @@
                 }
               }
             } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
+              @SuppressWarnings("unchecked")
               RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
                   (RestCollectionDeleteMissingView<RestResource, RestResource, Object>)
                       viewData.view;
@@ -564,6 +568,7 @@
                 }
               }
             } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
+              @SuppressWarnings("unchecked")
               RestCollectionModifyView<RestResource, RestResource, Object> m =
                   (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
 
@@ -1306,6 +1311,7 @@
    * @param result the object that should be formatted as JSON
    * @return the length of the response
    */
+  @CanIgnoreReturnValue
   public static long replyJson(
       @Nullable HttpServletRequest req,
       HttpServletResponse res,
@@ -1397,6 +1403,7 @@
   }
 
   @SuppressWarnings("resource")
+  @CanIgnoreReturnValue
   static long replyBinaryResult(
       @Nullable HttpServletRequest req, HttpServletResponse res, BinaryResult bin)
       throws IOException {
@@ -1698,9 +1705,21 @@
     if (rootCollection instanceof ProjectsCollection) {
       requestInfo.project(Project.nameKey(resourceId));
     } else if (rootCollection instanceof ChangesCollection) {
-      Optional<ChangeNotes> changeNotes = globals.changeFinder.findOne(resourceId);
-      if (changeNotes.isPresent()) {
-        requestInfo.project(changeNotes.get().getProjectName());
+      try {
+        Optional<ChangeNotes> changeNotes =
+            globals
+                .retryHelper
+                .action(
+                    ActionType.INDEX_QUERY,
+                    "find-change",
+                    () -> globals.changeFinder.findOne(resourceId))
+                .call();
+        if (changeNotes.isPresent()) {
+          requestInfo.project(changeNotes.get().getProjectName());
+        }
+      } catch (Exception e) {
+        logger.atWarning().withCause(e).log(
+            "failed looking up change %s to populate project in request info", resourceId);
       }
     }
     return requestInfo.build();
@@ -1828,6 +1847,7 @@
     return uri;
   }
 
+  @CanIgnoreReturnValue
   public static long replyError(
       HttpServletRequest req,
       HttpServletResponse res,
@@ -1838,6 +1858,7 @@
     return replyError(req, res, statusCode, msg, CacheControl.NONE, err);
   }
 
+  @CanIgnoreReturnValue
   public static long replyError(
       HttpServletRequest req,
       HttpServletResponse res,
@@ -1866,6 +1887,7 @@
    * @param text the text reply
    * @return the length of the response
    */
+  @CanIgnoreReturnValue
   static long replyText(
       @Nullable HttpServletRequest req, HttpServletResponse res, boolean allowTracing, String text)
       throws IOException {
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/restapi/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/restapi/package-info.java
index 0709b86..b38188f 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/restapi/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.restapi;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/httpd/template/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/httpd/template/package-info.java
index 0709b86..374cc91 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/httpd/template/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.httpd.template;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index 3ed76ba..ec530c1 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import java.io.IOException;
 import java.util.Optional;
 
 /**
@@ -169,4 +170,15 @@
   default Optional<Matchable<V>> getIndexFilter() {
     return Optional.empty();
   }
+
+  /**
+   * Creates a snapshot of the index.
+   *
+   * @param id an ID used for the snapshot.
+   * @return {@code true} if the snapshot was successful.
+   * @throws IOException if writing the snapshot to disk fails.
+   */
+  default boolean snapshot(String id) throws IOException {
+    return false;
+  }
 }
diff --git a/java/com/google/gerrit/index/IndexCollection.java b/java/com/google/gerrit/index/IndexCollection.java
index c61e6c7..66a9fba 100644
--- a/java/com/google/gerrit/index/IndexCollection.java
+++ b/java/com/google/gerrit/index/IndexCollection.java
@@ -16,6 +16,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Lists;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import java.util.Collection;
 import java.util.Collections;
@@ -53,6 +54,7 @@
     return Collections.unmodifiableCollection(writeIndexes);
   }
 
+  @CanIgnoreReturnValue
   public synchronized I addWriteIndex(I index) {
     int version = index.getSchema().getVersion();
     for (int i = 0; i < writeIndexes.size(); i++) {
diff --git a/java/com/google/gerrit/index/IndexDefinition.java b/java/com/google/gerrit/index/IndexDefinition.java
index f283bf1..cbcb34e 100644
--- a/java/com/google/gerrit/index/IndexDefinition.java
+++ b/java/com/google/gerrit/index/IndexDefinition.java
@@ -67,7 +67,11 @@
   }
 
   @Nullable
-  public final SiteIndexer<K, V, I> getSiteIndexer() {
+  public SiteIndexer<K, V, I> getSiteIndexer() {
+    return siteIndexer;
+  }
+
+  public SiteIndexer<K, V, I> getSiteIndexer(boolean reuseExistingDocuments) {
     return siteIndexer;
   }
 }
diff --git a/java/com/google/gerrit/index/IndexedField.java b/java/com/google/gerrit/index/IndexedField.java
index 94943d6..d1aaff6 100644
--- a/java/com/google/gerrit/index/IndexedField.java
+++ b/java/com/google/gerrit/index/IndexedField.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.reflect.TypeToken;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.exceptions.StorageException;
@@ -211,6 +212,7 @@
       return IndexedField.this;
     }
 
+    @CanIgnoreReturnValue
     private String checkName(String name) {
       CharMatcher m = CharMatcher.anyOf("abcdefghijklmnopqrstuvwxyz0123456789_");
       checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
@@ -350,6 +352,7 @@
       return this.getter(getter).fieldSetter(Optional.empty()).build();
     }
 
+    @CanIgnoreReturnValue
     private static String checkName(String name) {
       String allowedCharacters = "abcdefghijklmnopqrstuvwxyz0123456789_";
       CharMatcher m =
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index ab10d9e..dcd7591 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -26,6 +26,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
@@ -48,11 +49,13 @@
 
     private Optional<Integer> version = Optional.empty();
 
+    @CanIgnoreReturnValue
     public Builder<T> version(int version) {
       this.version = Optional.of(version);
       return this;
     }
 
+    @CanIgnoreReturnValue
     public Builder<T> add(Schema<T> schema) {
       this.indexedFields.addAll(schema.getIndexFields().values());
       this.searchFields.addAll(schema.getSchemaFields().values());
@@ -63,10 +66,12 @@
     }
 
     @SafeVarargs
+    @CanIgnoreReturnValue
     public final Builder<T> addSearchSpecs(IndexedField<T, ?>.SearchSpec... searchSpecs) {
       return addSearchSpecs(ImmutableList.copyOf(searchSpecs));
     }
 
+    @CanIgnoreReturnValue
     public Builder<T> addSearchSpecs(ImmutableList<IndexedField<T, ?>.SearchSpec> searchSpecs) {
       for (IndexedField<T, ?>.SearchSpec searchSpec : searchSpecs) {
         checkArgument(
@@ -80,22 +85,26 @@
     }
 
     @SafeVarargs
+    @CanIgnoreReturnValue
     public final Builder<T> addIndexedFields(IndexedField<T, ?>... fields) {
       return addIndexedFields(ImmutableList.copyOf(fields));
     }
 
+    @CanIgnoreReturnValue
     public Builder<T> addIndexedFields(ImmutableList<IndexedField<T, ?>> indexedFields) {
       this.indexedFields.addAll(indexedFields);
       return this;
     }
 
     @SafeVarargs
+    @CanIgnoreReturnValue
     public final Builder<T> remove(IndexedField<T, ?>.SearchSpec... searchSpecs) {
       this.searchFields.removeAll(Arrays.asList(searchSpecs));
       return this;
     }
 
     @SafeVarargs
+    @CanIgnoreReturnValue
     public final Builder<T> remove(IndexedField<T, ?>... indexedFields) {
       for (IndexedField<T, ?> field : indexedFields) {
         ImmutableMap<String, ? extends IndexedField<T, ?>.SearchSpec> searchSpecs =
@@ -134,6 +143,7 @@
     }
   }
 
+  @CanIgnoreReturnValue
   private static <T> SchemaField<T, ?> checkSame(SchemaField<T, ?> f1, SchemaField<T, ?> f2) {
     checkState(f1 == f2, "Mismatched %s fields: %s != %s", f1.getName(), f1, f2);
     return f1;
diff --git a/java/com/google/gerrit/index/SiteIndexer.java b/java/com/google/gerrit/index/SiteIndexer.java
index 32b4b21..bfb4407 100644
--- a/java/com/google/gerrit/index/SiteIndexer.java
+++ b/java/com/google/gerrit/index/SiteIndexer.java
@@ -76,6 +76,16 @@
   /** Indexes all entities for the provided index. */
   public abstract Result indexAll(I index);
 
+  /**
+   * Indexes all entities for the provided index.
+   *
+   * <p>NOTE: This method does not implement the 'notifyListeners' logic which is effectively
+   * ignored and all listeners are always notified.
+   */
+  public Result indexAll(I index, @SuppressWarnings("unused") boolean notifyListeners) {
+    return indexAll(index);
+  }
+
   protected final void addErrorListener(
       ListenableFuture<?> future, String desc, ProgressMonitor progress, AtomicBoolean ok) {
     future.addListener(
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/index/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/index/package-info.java
index 0709b86..9c652fe 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/index/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.index;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/index/project/BUILD b/java/com/google/gerrit/index/project/BUILD
index b423f84..b029513 100644
--- a/java/com/google/gerrit/index/project/BUILD
+++ b/java/com/google/gerrit/index/project/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//lib:guava",
+        "//lib/errorprone:annotations",
         "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 6cd43db..3bccb0d 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -81,6 +81,9 @@
           .addSearchSpecs(ProjectField.PREFIX_NAME_SPEC)
           .build();
 
+  // Upgrade Lucene to 9.x requires reindexing.
+  static final Schema<ProjectData> V9 = schema(V8);
+
   /**
    * Name of the project index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/index/project/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/index/project/package-info.java
index 0709b86..8965ed5 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/index/project/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.index.project;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/index/query/FilteredSource.java b/java/com/google/gerrit/index/query/FilteredSource.java
index 8269793..95e6435 100644
--- a/java/com/google/gerrit/index/query/FilteredSource.java
+++ b/java/com/google/gerrit/index/query/FilteredSource.java
@@ -110,6 +110,11 @@
     return cardinality;
   }
 
+  /**
+   * Whether this data source matches with the given object.
+   *
+   * @param object object to be matched
+   */
   protected boolean match(T object) {
     return true;
   }
diff --git a/java/com/google/gerrit/index/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
index d610dbf..24ce0fe 100644
--- a/java/com/google/gerrit/index/query/InternalQuery.java
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.Index;
@@ -61,22 +62,26 @@
     return (Q) this;
   }
 
+  @CanIgnoreReturnValue
   final Q setStart(int start) {
     queryProcessor.setStart(start);
     return self();
   }
 
+  @CanIgnoreReturnValue
   public final Q setLimit(int n) {
-    queryProcessor.setUserProvidedLimit(n);
+    queryProcessor.setUserProvidedLimit(n, /* applyDefaultLimit */ false);
     return self();
   }
 
+  @CanIgnoreReturnValue
   public final Q enforceVisibility(boolean enforce) {
     queryProcessor.enforceVisibility(enforce);
     return self();
   }
 
   @SafeVarargs
+  @CanIgnoreReturnValue
   public final Q setRequestedFields(SchemaField<T, ?>... fields) {
     checkArgument(fields.length > 0, "requested field list is empty");
     queryProcessor.setRequestedFields(
@@ -84,6 +89,7 @@
     return self();
   }
 
+  @CanIgnoreReturnValue
   public final Q noFields() {
     queryProcessor.setRequestedFields(ImmutableSet.of());
     return self();
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index f49cecb..cfb9f33 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -40,7 +40,6 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
-import com.google.gerrit.server.logging.CallerFinder;
 import com.google.gerrit.server.logging.Metadata;
 import java.util.ArrayList;
 import java.util.List;
@@ -83,7 +82,6 @@
   private final IndexRewriter<T> rewriter;
   private final String limitField;
   private final IntSupplier userQueryLimit;
-  private final CallerFinder callerFinder;
 
   // This class is not generally thread-safe, but programmer error may result in it being shared
   // across threads. At least ensure the bit for checking if it's been used is threadsafe.
@@ -113,15 +111,9 @@
     this.limitField = limitField;
     this.userQueryLimit = userQueryLimit;
     this.used = new AtomicBoolean(false);
-    this.callerFinder =
-        CallerFinder.builder()
-            .addTarget(InternalQuery.class)
-            .addTarget(QueryProcessor.class)
-            .matchSubClasses(true)
-            .skip(1)
-            .build();
   }
 
+  @CanIgnoreReturnValue
   public QueryProcessor<T> setStart(int n) {
     start = n;
     return this;
@@ -141,11 +133,18 @@
    * @param enforce whether to enforce visibility.
    * @return this.
    */
+  @CanIgnoreReturnValue
   public QueryProcessor<T> enforceVisibility(boolean enforce) {
     enforceVisibility = enforce;
     return this;
   }
 
+  /** Convenience method for API backward compatibility. */
+  @CanIgnoreReturnValue
+  public QueryProcessor<T> setUserProvidedLimit(int n) {
+    return setUserProvidedLimit(n, true);
+  }
+
   /**
    * Set an end-user-provided limit on the number of results returned.
    *
@@ -154,13 +153,20 @@
    * account and choose the one that makes the most sense.
    *
    * @param n limit; zero or negative means no limit.
+   * @param applyDefaultLimit Should the default limit be applied, if n <= 0? For internal queries
+   *     this should be false. For API endpoints this should be true.
    * @return this.
    */
-  public QueryProcessor<T> setUserProvidedLimit(int n) {
+  @CanIgnoreReturnValue
+  public QueryProcessor<T> setUserProvidedLimit(int n, boolean applyDefaultLimit) {
     userProvidedLimit = n;
+    if (applyDefaultLimit && userProvidedLimit <= 0 && indexConfig.defaultLimit() > 0) {
+      userProvidedLimit = indexConfig.defaultLimit();
+    }
     return this;
   }
 
+  @CanIgnoreReturnValue
   public QueryProcessor<T> setNoLimit(boolean isNoLimit) {
     this.isNoLimit = isNoLimit;
     return this;
@@ -172,6 +178,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public QueryProcessor<T> setRequestedFields(Set<String> fields) {
     requestedFields = fields;
     return this;
@@ -226,9 +233,7 @@
       return disabledResults(queryStrings, queries);
     }
 
-    logger.atFine().log(
-        "Executing %d %s index queries for %s",
-        cnt, schemaDef.getName(), callerFinder.findCallerLazy());
+    logger.atFine().log("Executing %d %s index queries", cnt, schemaDef.getName());
     List<QueryResult<T>> out;
     try {
       // Parse and rewrite all queries.
@@ -433,8 +438,6 @@
     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);
@@ -465,8 +468,4 @@
   }
 
   protected abstract String formatForLogging(T t);
-
-  protected abstract int getIndexSize();
-
-  protected abstract int getBatchSize();
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/index/query/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/index/query/package-info.java
index 0709b86..ab99f47 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/index/query/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.index.query;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/index/query/testing/BUILD b/java/com/google/gerrit/index/query/testing/BUILD
index 1785f49..1e61988 100644
--- a/java/com/google/gerrit/index/query/testing/BUILD
+++ b/java/com/google/gerrit/index/query/testing/BUILD
@@ -12,6 +12,7 @@
         "//antlr3:query_parser",
         "//lib:guava",
         "//lib/antlr:java-runtime",
+        "//lib/errorprone:annotations",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/index/query/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/index/query/testing/package-info.java
index 0709b86..20604ad 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/index/query/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.index.query.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index 60402ba..570290e 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -135,7 +135,7 @@
 
   @Override
   public DataSource<V> getSource(Predicate<V> p, QueryOptions opts) {
-    List<V> results;
+    ImmutableList<V> results;
     synchronized (indexedDocuments) {
       Stream<V> valueStream =
           indexedDocuments.values().stream()
@@ -292,7 +292,10 @@
                   Integer.valueOf((String) doc.get(ChangeField.NUMERIC_ID_STR_SPEC.getName()))));
       for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
         boolean isProtoField = SchemaFieldDefs.isProtoField(field);
-        field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName()), isProtoField));
+
+        @SuppressWarnings("unused")
+        var unused =
+            field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName()), isProtoField));
       }
       return cd;
     }
diff --git a/java/com/google/gerrit/index/testing/BUILD b/java/com/google/gerrit/index/testing/BUILD
index 44bf70d..c9f1994 100644
--- a/java/com/google/gerrit/index/testing/BUILD
+++ b/java/com/google/gerrit/index/testing/BUILD
@@ -16,6 +16,7 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/index/testing/FakeIndexModule.java b/java/com/google/gerrit/index/testing/FakeIndexModule.java
index 126ff10..b4d5315 100644
--- a/java/com/google/gerrit/index/testing/FakeIndexModule.java
+++ b/java/com/google/gerrit/index/testing/FakeIndexModule.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
-import java.util.Map;
 
 /** Module to bind {@link FakeIndexModule}. */
 public class FakeIndexModule extends AbstractIndexModule {
@@ -30,15 +29,16 @@
   }
 
   public static FakeIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads, boolean secondary) {
+      ImmutableMap<String, Integer> versions, int threads, boolean secondary) {
     return new FakeIndexModule(versions, threads, secondary);
   }
 
   public static FakeIndexModule latestVersion(boolean secondary) {
-    return new FakeIndexModule(null, -1 /* direct executor */, secondary);
+    return new FakeIndexModule(/* singleVersions= */ null, -1 /* direct executor */, secondary);
   }
 
-  private FakeIndexModule(Map<String, Integer> singleVersions, int threads, boolean secondary) {
+  private FakeIndexModule(
+      ImmutableMap<String, Integer> singleVersions, int threads, boolean secondary) {
     super(singleVersions, threads, secondary);
   }
 
diff --git a/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java b/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
index 5044e38..40d51fd 100644
--- a/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
+++ b/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
@@ -40,7 +40,12 @@
       SitePaths sitePaths,
       PluginSetContext<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs) {
-    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
+    super(
+        sitePaths,
+        listeners,
+        defs,
+        VersionManager.getOnlineUpgrade(cfg),
+        cfg.getBoolean("index", "reuseExistingDocuments", false));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/index/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/index/testing/package-info.java
index 0709b86..0f6e5b2 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/index/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.index.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/jgit/BUILD b/java/com/google/gerrit/jgit/BUILD
index 1041f1f..04f2220 100644
--- a/java/com/google/gerrit/jgit/BUILD
+++ b/java/com/google/gerrit/jgit/BUILD
@@ -9,5 +9,6 @@
     deps = [
         "//lib:gson",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/jgit/diff/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/jgit/diff/package-info.java
index 0709b86..ba04b82 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/jgit/diff/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.jgit.diff;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/json/BUILD b/java/com/google/gerrit/json/BUILD
index 7b2fe2f..601a7b4 100644
--- a/java/com/google/gerrit/json/BUILD
+++ b/java/com/google/gerrit/json/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/entities",
         "//lib:gson",
         "//lib:guava",
+        "//lib/errorprone:annotations",
         "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/json/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/json/package-info.java
index 0709b86..000b667 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/json/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.json;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/launcher/BUILD b/java/com/google/gerrit/launcher/BUILD
index 15fa0ce..309d1dd 100644
--- a/java/com/google/gerrit/launcher/BUILD
+++ b/java/com/google/gerrit/launcher/BUILD
@@ -6,4 +6,7 @@
     name = "launcher",
     srcs = ["GerritLauncher.java"],
     visibility = ["//visibility:public"],
+    deps = [
+        "//lib/errorprone:annotations",
+    ],
 )
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index 07a071a..989c82b 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -36,7 +36,6 @@
 import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.security.CodeSource;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -706,7 +705,7 @@
     Path dir;
     String sourceRoot = System.getProperty("sourceRoot");
     if (sourceRoot != null) {
-      dir = Paths.get(sourceRoot);
+      dir = Path.of(sourceRoot);
       if (!Files.exists(dir)) {
         throw new FileNotFoundException("source root not found: " + dir);
       }
@@ -729,7 +728,7 @@
       }
 
       // Pop up to the top-level source folder by looking for WORKSPACE.
-      dir = Paths.get(u.getPath());
+      dir = Path.of(u.getPath());
       while (!Files.isRegularFile(dir.resolve("WORKSPACE"))) {
         Path parent = dir.getParent();
         if (parent == null) {
@@ -756,7 +755,7 @@
       Path rootPath = resolveInSourceRoot(".").normalize();
 
       Properties properties = loadBuildProperties(rootPath.resolve(".bazel_path"));
-      Path outputBase = Paths.get(properties.getProperty("output_base"));
+      Path outputBase = Path.of(properties.getProperty("output_base"));
 
       Path runtimeClasspath =
           rootPath.resolve("bazel-bin/tools/eclipse/main_classpath_collect.runtime_classpath");
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/launcher/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/launcher/package-info.java
index 0709b86..b30810c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/launcher/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.launcher;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/lifecycle/BUILD b/java/com/google/gerrit/lifecycle/BUILD
index a3f3d81..fa3c2a3 100644
--- a/java/com/google/gerrit/lifecycle/BUILD
+++ b/java/com/google/gerrit/lifecycle/BUILD
@@ -7,6 +7,7 @@
     deps = [
         "//java/com/google/gerrit/extensions:api",
         "//lib:guava",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
     ],
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/lifecycle/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/lifecycle/package-info.java
index 0709b86..753cc8b 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/lifecycle/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.lifecycle;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 938cd67..e00c394 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -25,6 +25,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.io.Files;
 import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.AbstractFuture;
 import com.google.common.util.concurrent.Futures;
@@ -54,6 +55,8 @@
 import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import com.google.protobuf.MessageLite;
 import java.io.IOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Path;
 import java.sql.Timestamp;
 import java.util.Set;
 import java.util.concurrent.Callable;
@@ -74,8 +77,10 @@
 import org.apache.lucene.document.StoredField;
 import org.apache.lucene.document.StringField;
 import org.apache.lucene.document.TextField;
+import org.apache.lucene.index.IndexCommit;
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.index.SnapshotDeletionPolicy;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.search.ControlledRealTimeReopenThread;
 import org.apache.lucene.search.IndexSearcher;
@@ -88,6 +93,7 @@
 import org.apache.lucene.search.TopFieldDocs;
 import org.apache.lucene.store.AlreadyClosedException;
 import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
 
 /** Basic Lucene index implementation. */
 public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
@@ -134,6 +140,9 @@
     String index = Joiner.on('_').skipNulls().join(name, subIndex);
     long commitPeriod = writerConfig.getCommitWithinMs();
 
+    writerConfig.setIndexDeletionPolicy(
+        new SnapshotDeletionPolicy(writerConfig.getIndexDeletionPolicy()));
+
     if (commitPeriod < 0) {
       writer = new AutoCommitWriter(dir, writerConfig.getLuceneConfig());
     } else if (commitPeriod == 0) {
@@ -516,6 +525,34 @@
     return schema;
   }
 
+  @Override
+  public boolean snapshot(String id) throws IOException {
+    SnapshotDeletionPolicy snapshooter =
+        (SnapshotDeletionPolicy) writer.getConfig().getIndexDeletionPolicy();
+
+    IndexCommit commit = snapshooter.snapshot();
+    try {
+      Path sourceDir = canonical(((FSDirectory) commit.getDirectory()).getDirectory());
+      Path indexDir = canonical(sitePaths.index_dir);
+      Path targetDir =
+          indexDir.resolve("snapshots").resolve(id).resolve(indexDir.relativize(sourceDir));
+      if (targetDir.toFile().exists()) {
+        throw new FileAlreadyExistsException(targetDir.toString());
+      }
+      targetDir.toFile().mkdirs();
+      for (String file : commit.getFileNames()) {
+        Files.copy(sourceDir.resolve(file).toFile(), targetDir.resolve(file).toFile());
+      }
+    } finally {
+      snapshooter.release(commit);
+    }
+    return true;
+  }
+
+  private static Path canonical(Path p) throws IOException {
+    return p.toFile().getCanonicalFile().toPath();
+  }
+
   protected class LuceneQuerySource implements DataSource<V> {
     private final QueryOptions opts;
     private final Query query;
diff --git a/java/com/google/gerrit/lucene/BUILD b/java/com/google/gerrit/lucene/BUILD
index 879e706..6584bfd 100644
--- a/java/com/google/gerrit/lucene/BUILD
+++ b/java/com/google/gerrit/lucene/BUILD
@@ -10,7 +10,8 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//lib:guava",
-        "//lib/lucene:lucene-core-and-backward-codecs",
+        "//lib/errorprone:annotations",
+        "//lib/lucene:lucene-core",
     ],
 )
 
@@ -35,11 +36,12 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/lucene:lucene-analyzers-common",
-        "//lib/lucene:lucene-core-and-backward-codecs",
+        "//lib/lucene:lucene-core",
         "//lib/lucene:lucene-misc",
     ],
 )
diff --git a/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java b/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
index f6b2f0e..bec63bd 100644
--- a/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
+++ b/java/com/google/gerrit/lucene/GerritIndexWriterConfig.java
@@ -22,6 +22,7 @@
 import org.apache.lucene.analysis.CharArraySet;
 import org.apache.lucene.analysis.standard.StandardAnalyzer;
 import org.apache.lucene.index.ConcurrentMergeScheduler;
+import org.apache.lucene.index.IndexDeletionPolicy;
 import org.apache.lucene.index.IndexWriterConfig;
 import org.apache.lucene.index.IndexWriterConfig.OpenMode;
 import org.eclipse.jgit.lib.Config;
@@ -77,6 +78,14 @@
     }
   }
 
+  void setIndexDeletionPolicy(IndexDeletionPolicy indexDeletionPolicy) {
+    luceneConfig.setIndexDeletionPolicy(indexDeletionPolicy);
+  }
+
+  IndexDeletionPolicy getIndexDeletionPolicy() {
+    return luceneConfig.getIndexDeletionPolicy();
+  }
+
   CustomMappingAnalyzer getAnalyzer() {
     return analyzer;
   }
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 3f2a5ae..ffd25ba 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGENUM_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
@@ -284,6 +285,11 @@
     openIndex.markReady(ready);
   }
 
+  @Override
+  public boolean snapshot(String id) throws IOException {
+    return openIndex.snapshot(id) && closedIndex.snapshot(id);
+  }
+
   private Sort getSort() {
     return new Sort(
         new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
@@ -417,13 +423,16 @@
               TopFieldDocs subIndexHits =
                   searchers[i].searchAfter(
                       searchAfter, query, maxRemainingHits, sort, /* doDocScores= */ false);
+              assignShardIndexValues(subIndexHits, i);
               searchAfterHitsCount += subIndexHits.scoreDocs.length;
               hits.add(subIndexHits);
               searchAfterBySubIndex.put(
                   subIndex, Iterables.getLast(Arrays.asList(subIndexHits.scoreDocs), searchAfter));
             }
           } else {
-            hits.add(searchers[i].search(query, queryLimit, sort));
+            TopFieldDocs subIndexHits = searchers[i].search(query, queryLimit, sort);
+            assignShardIndexValues(subIndexHits, i);
+            hits.add(subIndexHits);
           }
         }
         TopDocs docs = TopDocs.merge(sort, queryLimit, hits.stream().toArray(TopFieldDocs[]::new));
@@ -447,6 +456,25 @@
       }
     }
 
+    /*
+     * Assign shard index values to the score documents.
+     *
+     * <p>TopDocs.merge()'s API has been changed to stop allowing passing in a parameter to
+     * indicate if it should set shard indices for hits as they are seen during the merge
+     * process. This is done to simplify the API to be more dynamic in terms of passing in
+     * custom tie breakers. If shard indices are to be used for tie breaking docs with equal
+     * scores during TopDocs.merge(), then it is mandatory that the input ScoreDocs have their
+     * shard indices set to valid values prior to calling merge().
+     *
+     * @param doc document
+     * @param shard index
+     */
+    private void assignShardIndexValues(TopFieldDocs doc, int shard) {
+      for (int docID = 0; docID < doc.scoreDocs.length; docID++) {
+        doc.scoreDocs[docID].shardIndex = shard;
+      }
+    }
+
     /**
      * Returns null for the first page or when pagination type is not {@link
      * PaginationType#SEARCH_AFTER search-after}, otherwise returns the last doc from previous
@@ -501,7 +529,11 @@
         ImmutableList.Builder<ChangeData> result =
             ImmutableList.builderWithExpectedSize(docs.size());
         for (Document doc : docs) {
-          result.add(toChangeData(fields(doc, fields), fields, NUMERIC_ID_STR_SPEC.getName()));
+          String fieldName =
+              doc.getField(CHANGENUM_SPEC.getName()) != null
+                  ? CHANGENUM_SPEC.getName()
+                  : NUMERIC_ID_STR_SPEC.getName();
+          result.add(toChangeData(fields(doc, fields), fields, fieldName));
         }
         return result.build();
       } catch (InterruptedException e) {
@@ -562,7 +594,8 @@
 
     for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
       if (fields.contains(field.getName())) {
-        field.setIfPossible(cd, new LuceneStoredValue(doc.get(field.getName())));
+        @SuppressWarnings("unused")
+        var unused = field.setIfPossible(cd, new LuceneStoredValue(doc.get(field.getName())));
       }
     }
     return cd;
diff --git a/java/com/google/gerrit/lucene/LuceneIndexModule.java b/java/com/google/gerrit/lucene/LuceneIndexModule.java
index 3aa9c6e..89b83a4 100644
--- a/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.options.AutoFlush;
-import java.util.Map;
 import org.apache.lucene.search.BooleanQuery;
 import org.eclipse.jgit.lib.Config;
 
@@ -41,17 +40,17 @@
 
   @VisibleForTesting
   public static LuceneIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads, boolean slave) {
+      ImmutableMap<String, Integer> versions, int threads, boolean slave) {
     return new LuceneIndexModule(versions, threads, slave, AutoFlush.ENABLED);
   }
 
   public static LuceneIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads, boolean slave, AutoFlush autoFlush) {
+      ImmutableMap<String, Integer> versions, int threads, boolean slave, AutoFlush autoFlush) {
     return new LuceneIndexModule(versions, threads, slave, autoFlush);
   }
 
   public static LuceneIndexModule latestVersion(boolean slave, AutoFlush autoFlush) {
-    return new LuceneIndexModule(null, 0, slave, autoFlush);
+    return new LuceneIndexModule(/* singleVersions= */ null, 0, slave, autoFlush);
   }
 
   static boolean isInMemoryTest(Config cfg) {
@@ -59,7 +58,10 @@
   }
 
   private LuceneIndexModule(
-      Map<String, Integer> singleVersions, int threads, boolean slave, AutoFlush autoFlush) {
+      ImmutableMap<String, Integer> singleVersions,
+      int threads,
+      boolean slave,
+      AutoFlush autoFlush) {
     super(singleVersions, threads, slave);
     this.autoFlush = autoFlush;
   }
diff --git a/java/com/google/gerrit/lucene/LuceneVersionManager.java b/java/com/google/gerrit/lucene/LuceneVersionManager.java
index f3ba73d..265d3e0 100644
--- a/java/com/google/gerrit/lucene/LuceneVersionManager.java
+++ b/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -49,7 +49,12 @@
       SitePaths sitePaths,
       PluginSetContext<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs) {
-    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
+    super(
+        sitePaths,
+        listeners,
+        defs,
+        VersionManager.getOnlineUpgrade(cfg),
+        cfg.getBoolean("index", "reuseExistingDocuments", false));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/lucene/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/lucene/package-info.java
index 0709b86..fb5f8ec 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/lucene/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.lucene;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/mail/BUILD b/java/com/google/gerrit/mail/BUILD
index 0fe6c43..8830a66 100644
--- a/java/com/google/gerrit/mail/BUILD
+++ b/java/com/google/gerrit/mail/BUILD
@@ -10,6 +10,7 @@
         "//lib:guava",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/jsoup",
         "//lib/mime4j:core",
diff --git a/java/com/google/gerrit/mail/MailMessage.java b/java/com/google/gerrit/mail/MailMessage.java
index 2ce6cbb..b2385a7 100644
--- a/java/com/google/gerrit/mail/MailMessage.java
+++ b/java/com/google/gerrit/mail/MailMessage.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Address;
 import java.time.Instant;
@@ -72,6 +73,7 @@
 
     public abstract ImmutableList.Builder<Address> toBuilder();
 
+    @CanIgnoreReturnValue
     public Builder addTo(Address val) {
       toBuilder().add(val);
       return this;
@@ -79,6 +81,7 @@
 
     public abstract ImmutableList.Builder<Address> ccBuilder();
 
+    @CanIgnoreReturnValue
     public Builder addCc(Address val) {
       ccBuilder().add(val);
       return this;
@@ -88,6 +91,7 @@
 
     public abstract ImmutableList.Builder<String> additionalHeadersBuilder();
 
+    @CanIgnoreReturnValue
     public Builder addAdditionalHeader(String val) {
       additionalHeadersBuilder().add(val);
       return this;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/mail/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/mail/package-info.java
index 0709b86..5db0133 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/mail/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.mail;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/metrics/Description.java b/java/com/google/gerrit/metrics/Description.java
index f5963af..d4d3bb3 100644
--- a/java/com/google/gerrit/metrics/Description.java
+++ b/java/com/google/gerrit/metrics/Description.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
@@ -73,6 +74,7 @@
    * @param unitName name of the unit, e.g. "requests", "seconds", etc.
    * @return this
    */
+  @CanIgnoreReturnValue
   public Description setUnit(String unitName) {
     annotations.put(UNIT, unitName);
     return this;
@@ -84,6 +86,7 @@
    *
    * @return this
    */
+  @CanIgnoreReturnValue
   public Description setConstant() {
     annotations.put(CONSTANT, TRUE_VALUE);
     return this;
@@ -95,6 +98,7 @@
    *
    * @return this
    */
+  @CanIgnoreReturnValue
   public Description setRate() {
     annotations.put(RATE, TRUE_VALUE);
     return this;
@@ -106,6 +110,7 @@
    *
    * @return this
    */
+  @CanIgnoreReturnValue
   public Description setGauge() {
     annotations.put(GAUGE, TRUE_VALUE);
     return this;
@@ -117,6 +122,7 @@
    *
    * @return this
    */
+  @CanIgnoreReturnValue
   public Description setCumulative() {
     annotations.put(CUMULATIVE, TRUE_VALUE);
     return this;
@@ -128,6 +134,7 @@
    * @param ordering field ordering
    * @return this
    */
+  @CanIgnoreReturnValue
   public Description setFieldOrdering(FieldOrdering ordering) {
     annotations.put(FIELD_ORDERING, ordering.name());
     return this;
diff --git a/java/com/google/gerrit/metrics/MetricMaker.java b/java/com/google/gerrit/metrics/MetricMaker.java
index 3f9bab1..00f0792 100644
--- a/java/com/google/gerrit/metrics/MetricMaker.java
+++ b/java/com/google/gerrit/metrics/MetricMaker.java
@@ -140,15 +140,18 @@
    * @param trigger trigger to connect
    * @return registration handle
    */
+  @CanIgnoreReturnValue
   public RegistrationHandle newTrigger(CallbackMetric<?> metric1, Runnable trigger) {
     return newTrigger(ImmutableSet.of(metric1), trigger);
   }
 
+  @CanIgnoreReturnValue
   public RegistrationHandle newTrigger(
       CallbackMetric<?> metric1, CallbackMetric<?> metric2, Runnable trigger) {
     return newTrigger(ImmutableSet.of(metric1, metric2), trigger);
   }
 
+  @CanIgnoreReturnValue
   public RegistrationHandle newTrigger(
       CallbackMetric<?> metric1,
       CallbackMetric<?> metric2,
@@ -157,6 +160,7 @@
     return newTrigger(ImmutableSet.of(metric1, metric2, metric3), trigger);
   }
 
+  @CanIgnoreReturnValue
   public abstract RegistrationHandle newTrigger(Set<CallbackMetric<?>> metrics, Runnable trigger);
 
   /**
diff --git a/java/com/google/gerrit/metrics/Timer0.java b/java/com/google/gerrit/metrics/Timer0.java
index 72ebc67..d59595a 100644
--- a/java/com/google/gerrit/metrics/Timer0.java
+++ b/java/com/google/gerrit/metrics/Timer0.java
@@ -49,8 +49,6 @@
     }
   }
 
-  private boolean suppressLogging;
-
   protected final String name;
 
   public Timer0(String name) {
@@ -76,22 +74,14 @@
   public final void record(long value, TimeUnit unit) {
     long durationMs = unit.toMillis(value);
 
-    if (!suppressLogging) {
-      LoggingContext.getInstance()
-          .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs));
-      logger.atFinest().log("%s took %dms", name, durationMs);
-    }
+    LoggingContext.getInstance()
+        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs));
+    logger.atFinest().log("%s took %dms", name, durationMs);
 
     doRecord(value, unit);
     RequestStateContext.abortIfCancelled();
   }
 
-  /** Suppress logging (debug log and performance log) when values are recorded. */
-  public final Timer0 suppressLogging() {
-    this.suppressLogging = true;
-    return this;
-  }
-
   /**
    * Record a value in the distribution.
    *
diff --git a/java/com/google/gerrit/metrics/TimerContext.java b/java/com/google/gerrit/metrics/TimerContext.java
index a3754c5..0e01de0 100644
--- a/java/com/google/gerrit/metrics/TimerContext.java
+++ b/java/com/google/gerrit/metrics/TimerContext.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.metrics;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+
 abstract class TimerContext implements AutoCloseable {
   private final long startNanos;
   private boolean stopped;
@@ -40,6 +42,7 @@
    * @return the elapsed time in nanoseconds.
    * @throws IllegalStateException if the timer is already stopped.
    */
+  @CanIgnoreReturnValue
   public long stop() {
     if (!stopped) {
       stopped = true;
diff --git a/java/com/google/gerrit/metrics/dropwizard/BUILD b/java/com/google/gerrit/metrics/dropwizard/BUILD
index dbb8f5e..130eb8b 100644
--- a/java/com/google/gerrit/metrics/dropwizard/BUILD
+++ b/java/com/google/gerrit/metrics/dropwizard/BUILD
@@ -12,6 +12,7 @@
         "//lib:args4j",
         "//lib:guava",
         "//lib/dropwizard:dropwizard-core",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
     ],
diff --git a/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java b/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
index da9ec70..fdfe129 100644
--- a/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
+++ b/java/com/google/gerrit/metrics/dropwizard/BucketedCallback.java
@@ -19,6 +19,7 @@
 import com.codahale.metrics.MetricRegistry;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Maps;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import java.util.Iterator;
@@ -85,14 +86,17 @@
     }
   }
 
+  @CanIgnoreReturnValue
   ValueGauge getOrCreate(Object f1, Object f2) {
     return getOrCreate(ImmutableList.of(f1, f2));
   }
 
+  @CanIgnoreReturnValue
   ValueGauge getOrCreate(Object f1, Object f2, Object f3) {
     return getOrCreate(ImmutableList.of(f1, f2, f3));
   }
 
+  @CanIgnoreReturnValue
   ValueGauge getOrCreate(Object key) {
     ValueGauge c = cells.get(key);
     if (c != null) {
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/metrics/dropwizard/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/metrics/dropwizard/package-info.java
index 0709b86..e91c94d 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/metrics/dropwizard/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.metrics.dropwizard;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/metrics/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/metrics/package-info.java
index 0709b86..7d691d9 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/metrics/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.metrics;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/metrics/proc/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/metrics/proc/package-info.java
index 0709b86..0136afd 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/metrics/proc/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.metrics.proc;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 8523e8a..0967130 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -51,6 +51,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:lang3",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java b/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
index 00e8fa4..9c74f78 100644
--- a/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
+++ b/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
@@ -35,7 +36,6 @@
 import com.google.inject.Key;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 import java.io.IOException;
-import java.util.Collection;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ProgressMonitor;
@@ -108,7 +108,7 @@
       return 0;
     }
 
-    Collection<ExternalId> todo = externalIds.all();
+    ImmutableSet<ExternalId> todo = externalIds.all();
     monitor.beginTask("Converting external ID note names", todo.size());
 
     manager.start();
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 6230136..198eeaa 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -52,7 +52,7 @@
 import com.google.gerrit.pgm.http.jetty.JettyModule;
 import com.google.gerrit.pgm.http.jetty.ProjectQoSFilter.ProjectQoSFilterModule;
 import com.google.gerrit.pgm.util.ErrorLogFile;
-import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
+import com.google.gerrit.pgm.util.LogFileManager.LogFileManagerModule;
 import com.google.gerrit.pgm.util.RuntimeShutdown;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.DefaultRefLogIdentityProvider;
@@ -103,6 +103,8 @@
 import com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule;
 import com.google.gerrit.server.mail.send.SmtpEmailSender.SmtpEmailSenderModule;
 import com.google.gerrit.server.mime.MimeUtil2Module;
+import com.google.gerrit.server.notedb.NoteDbDraftCommentsModule;
+import com.google.gerrit.server.notedb.NoteDbStarredChangesModule;
 import com.google.gerrit.server.notedb.RepoSequence.RepoSequenceModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
@@ -272,7 +274,8 @@
     }
     if (doInit) {
       try {
-        new Init(getSitePath()).run();
+        @SuppressWarnings("unused")
+        var unused = new Init(getSitePath()).run();
       } catch (Exception e) {
         throw die("Init failed", e);
       }
@@ -447,7 +450,7 @@
     final List<Module> modules = new ArrayList<>();
     modules.add(NoteDbSchemaVersionCheck.module());
     modules.add(new DropWizardMetricMaker.RestModule());
-    modules.add(new LogFileCompressorModule());
+    modules.add(new LogFileManagerModule());
 
     // Index module shutdown must happen before work queue shutdown, otherwise
     // work queue can get stuck waiting on index futures that will never return.
@@ -466,11 +469,15 @@
     modules.add(new SysExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
+
     modules.add(cfgInjector.getInstance(AccountCacheImpl.AccountCacheModule.class));
+    modules.add(cfgInjector.getInstance(AccountCacheImpl.AccountCacheBindingModule.class));
 
     modules.add(new AccountNoteDbWriteStorageModule());
     modules.add(new AccountNoteDbReadStorageModule());
     modules.add(new RepoSequenceModule());
+    modules.add(new NoteDbDraftCommentsModule());
+    modules.add(new NoteDbStarredChangesModule());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new ProjectQueryBuilderModule());
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index c05bff5..d3e9988 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -143,7 +143,7 @@
 
   @Override
   protected void afterInit(SiteRun run) throws Exception {
-    List<SchemaDefinitions<?>> schemaDefs =
+    ImmutableList<SchemaDefinitions<?>> schemaDefs =
         ImmutableList.of(
             AccountSchemaDefinitions.INSTANCE,
             ChangeSchemaDefinitions.INSTANCE,
@@ -302,7 +302,9 @@
         .message(String.format("Init complete, reindexing %s with:", String.join(",", indices)));
     getConsoleUI().message(" reindex " + reindexArgs.stream().collect(joining(" ")));
     Reindex reindexPgm = new Reindex();
-    reindexPgm.main(reindexArgs.stream().toArray(String[]::new));
+
+    @SuppressWarnings("unused")
+    var unused = reindexPgm.main(reindexArgs.stream().toArray(String[]::new));
   }
 
   private static boolean nullOrEmpty(List<?> list) {
diff --git a/java/com/google/gerrit/pgm/JythonShell.java b/java/com/google/gerrit/pgm/JythonShell.java
index d85bdc0..dcd89dc 100644
--- a/java/com/google/gerrit/pgm/JythonShell.java
+++ b/java/com/google/gerrit/pgm/JythonShell.java
@@ -70,12 +70,14 @@
     pyObject = findClass("org.python.core.PyObject");
     pySystemState = findClass("org.python.core.PySystemState");
 
-    runMethod(
-        pySystemState,
-        pySystemState,
-        "initialize",
-        new Class<?>[] {Properties.class, Properties.class},
-        new Object[] {null, env});
+    @SuppressWarnings("unused")
+    var unused =
+        runMethod(
+            pySystemState,
+            pySystemState,
+            "initialize",
+            new Class<?>[] {Properties.class, Properties.class},
+            new Object[] {null, env});
 
     try {
       shell = console.getConstructor(new Class<?>[] {}).newInstance();
@@ -125,10 +127,12 @@
   }
 
   protected void printInjectedVariable(String id) {
-    runInterpreter(
-        "exec",
-        new Class<?>[] {String.class},
-        new Object[] {"print '\"%s\" is \"%s\"' % (\"" + id + "\", " + id + ")"});
+    @SuppressWarnings("unused")
+    var unused =
+        runInterpreter(
+            "exec",
+            new Class<?>[] {String.class},
+            new Object[] {"print '\"%s\" is \"%s\"' % (\"" + id + "\", " + id + ")"});
   }
 
   public void run() {
@@ -136,19 +140,26 @@
       printInjectedVariable(key);
     }
     reload();
-    runInterpreter(
-        "interact",
-        new Class<?>[] {String.class, pyObject},
-        new Object[] {
-          getDefaultBanner()
-              + " running for Gerrit "
-              + com.google.gerrit.common.Version.getVersion(),
-          null,
-        });
+
+    @SuppressWarnings("unused")
+    var unused =
+        runInterpreter(
+            "interact",
+            new Class<?>[] {String.class, pyObject},
+            new Object[] {
+              getDefaultBanner()
+                  + " running for Gerrit "
+                  + com.google.gerrit.common.Version.getVersion(),
+              null,
+            });
   }
 
   public void set(String key, Object content) {
-    runInterpreter("set", new Class<?>[] {String.class, Object.class}, new Object[] {key, content});
+    @SuppressWarnings("unused")
+    var unused =
+        runInterpreter(
+            "set", new Class<?>[] {String.class, Object.class}, new Object[] {key, content});
+
     injectedVariables.add(key);
   }
 
@@ -181,12 +192,14 @@
     try {
       File script = new File(parent, p);
       if (script.canExecute()) {
-        runMethod0(
-            console,
-            shell,
-            "execfile",
-            new Class<?>[] {String.class},
-            new Object[] {script.getAbsolutePath()});
+        @SuppressWarnings("unused")
+        var unused =
+            runMethod0(
+                console,
+                shell,
+                "execfile",
+                new Class<?>[] {String.class},
+                new Object[] {script.getAbsolutePath()});
       } else {
         logger.atInfo().log(
             "User initialization file %s is not found or not executable", script.getAbsolutePath());
@@ -200,12 +213,14 @@
 
   protected void execStream(InputStream in, String p) {
     try {
-      runMethod0(
-          console,
-          shell,
-          "execfile",
-          new Class<?>[] {InputStream.class, String.class},
-          new Object[] {in, p});
+      @SuppressWarnings("unused")
+      var unused =
+          runMethod0(
+              console,
+              shell,
+              "execfile",
+              new Class<?>[] {InputStream.class, String.class},
+              new Object[] {in, p});
     } catch (InvocationTargetException e) {
       logger.atSevere().withCause(e).log("Exception occurred while loading %s", p);
     }
diff --git a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index 6967fb1..db8cc16 100644
--- a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
@@ -35,7 +36,6 @@
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Locale;
 import java.util.Optional;
 import org.apache.commons.lang3.StringUtils;
@@ -78,7 +78,7 @@
             })
         .injectMembers(this);
 
-    Collection<ExternalId> todo = externalIds.all();
+    ImmutableSet<ExternalId> todo = externalIds.all();
     monitor.beginTask("Converting local usernames", todo.size());
 
     try (Repository repo = repoManager.openRepository(allUsersName)) {
diff --git a/java/com/google/gerrit/pgm/Ls.java b/java/com/google/gerrit/pgm/Ls.java
index 4b48148..b705b3f 100644
--- a/java/com/google/gerrit/pgm/Ls.java
+++ b/java/com/google/gerrit/pgm/Ls.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.pgm.util.AbstractProgram;
 import java.io.IOException;
@@ -49,7 +50,7 @@
     return 0;
   }
 
-  private static Iterable<? extends ZipEntry> entriesOf(ZipFile zipFile) {
+  private static ImmutableList<? extends ZipEntry> entriesOf(ZipFile zipFile) {
     return zipFile.stream().collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index a2e780d..e800d17 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -18,6 +18,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Die;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -44,6 +45,8 @@
 import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.index.options.BuildBloomFilter;
 import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
+import com.google.gerrit.server.notedb.NoteDbDraftCommentsModule;
+import com.google.gerrit.server.notedb.NoteDbStarredChangesModule;
 import com.google.gerrit.server.notedb.RepoSequence.RepoSequenceModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.util.ReplicaUtil;
@@ -59,9 +62,7 @@
 import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.concurrent.TimeUnit;
@@ -97,6 +98,8 @@
   @Option(name = "--build-bloom-filter", usage = "Build bloom filter for H2 disk caches.")
   private boolean buildBloomFilter;
 
+  private Boolean reuseExistingDocumentsOption;
+
   private Injector dbInjector;
   private Injector sysInjector;
   private Injector cfgInjector;
@@ -105,6 +108,11 @@
   @Inject private Collection<IndexDefinition<?, ?, ?>> indexDefs;
   @Inject private DynamicMap<Cache<?, ?>> cacheMap;
 
+  @Option(name = "--reuse", usage = "Reindex only when existing index entry is stale")
+  public void setReuseExistingDocuments(boolean value) {
+    reuseExistingDocumentsOption = value;
+  }
+
   @Override
   public int run() throws Exception {
     mustHaveValidSite();
@@ -173,10 +181,10 @@
   }
 
   private Injector createSysInjector() {
-    Map<String, Integer> versions = new HashMap<>();
-    if (changesVersion != null) {
-      versions.put(ChangeSchemaDefinitions.INSTANCE.getName(), changesVersion);
-    }
+    ImmutableMap<String, Integer> versions =
+        changesVersion != null
+            ? ImmutableMap.of(ChangeSchemaDefinitions.INSTANCE.getName(), changesVersion)
+            : ImmutableMap.of();
     boolean replica = ReplicaUtil.isReplica(globalConfig);
     List<Module> modules = new ArrayList<>();
     modules.add(new WorkQueueModule());
@@ -194,7 +202,7 @@
         Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModule");
         Method m =
             clazz.getMethod(
-                "singleVersionWithExplicitVersions", Map.class, int.class, boolean.class);
+                "singleVersionWithExplicitVersions", ImmutableMap.class, int.class, boolean.class);
         indexModule = (Module) m.invoke(null, versions, threads, replica);
       } catch (NoSuchMethodException
           | ClassNotFoundException
@@ -230,6 +238,8 @@
     modules.add(new AccountNoteDbWriteStorageModule());
     modules.add(new AccountNoteDbReadStorageModule());
     modules.add(new RepoSequenceModule());
+    modules.add(new NoteDbDraftCommentsModule());
+    modules.add(new NoteDbStarredChangesModule());
 
     return dbInjector.createChildInjector(
         ModuleOverloader.override(
@@ -255,9 +265,16 @@
     requireNonNull(
         index, () -> String.format("no active search index configured for %s", def.getName()));
     index.markReady(false);
-    index.deleteAll();
+    boolean reuseExistingDocuments =
+        reuseExistingDocumentsOption != null
+            ? reuseExistingDocumentsOption
+            : globalConfig.getBoolean("index", null, "reuseExistingDocuments", false);
 
-    SiteIndexer<K, V, I> siteIndexer = def.getSiteIndexer();
+    if (!reuseExistingDocuments) {
+      index.deleteAll();
+    }
+
+    SiteIndexer<K, V, I> siteIndexer = def.getSiteIndexer(reuseExistingDocuments);
     siteIndexer.setProgressOut(System.err);
     siteIndexer.setVerboseOut(verbose ? System.out : NullOutputStream.INSTANCE);
     SiteIndexer.Result result = siteIndexer.indexAll(index);
diff --git a/java/com/google/gerrit/pgm/SwitchSecureStore.java b/java/com/google/gerrit/pgm/SwitchSecureStore.java
index 063fcdb..3cd0c47 100644
--- a/java/com/google/gerrit/pgm/SwitchSecureStore.java
+++ b/java/com/google/gerrit/pgm/SwitchSecureStore.java
@@ -31,7 +31,6 @@
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.Arrays;
 import java.util.List;
 import java.util.jar.JarFile;
@@ -63,7 +62,7 @@
   @Override
   public int run() throws Exception {
     SitePaths sitePaths = new SitePaths(getSitePath());
-    Path newSecureStorePath = Paths.get(newSecureStoreLib);
+    Path newSecureStorePath = Path.of(newSecureStoreLib);
     if (!Files.exists(newSecureStorePath)) {
       logger.atSevere().log("File %s doesn't exist", newSecureStorePath.toAbsolutePath());
       return -1;
diff --git a/java/com/google/gerrit/pgm/WarDistribution.java b/java/com/google/gerrit/pgm/WarDistribution.java
index 013c850..37178b0 100644
--- a/java/com/google/gerrit/pgm/WarDistribution.java
+++ b/java/com/google/gerrit/pgm/WarDistribution.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.pgm.init.InitPlugins.JAR;
 import static com.google.gerrit.pgm.init.InitPlugins.PLUGIN_DIR;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.pgm.init.PluginsDistribution;
@@ -64,7 +65,7 @@
     throw new UnsupportedOperationException();
   }
 
-  private static Iterable<? extends ZipEntry> entriesOf(ZipFile zipFile) {
+  private static ImmutableList<? extends ZipEntry> entriesOf(ZipFile zipFile) {
     return zipFile.stream().collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/pgm/http/jetty/BUILD b/java/com/google/gerrit/pgm/http/jetty/BUILD
index cd188f5..e006c91 100644
--- a/java/com/google/gerrit/pgm/http/jetty/BUILD
+++ b/java/com/google/gerrit/pgm/http/jetty/BUILD
@@ -18,6 +18,7 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:servlet-api",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/pgm/http/jetty/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/pgm/http/jetty/package-info.java
index 0709b86..45a3b4f 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/pgm/http/jetty/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.pgm.http.jetty;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java b/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java
index e3e485f..5584eba 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java
@@ -110,6 +110,7 @@
         throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
       }
       account.setMetaId(id.name());
+      account.setUniqueTag(id.name());
     }
     return account.build();
   }
diff --git a/java/com/google/gerrit/pgm/init/BUILD b/java/com/google/gerrit/pgm/init/BUILD
index 0c0d937..73c3760 100644
--- a/java/com/google/gerrit/pgm/init/BUILD
+++ b/java/com/google/gerrit/pgm/init/BUILD
@@ -25,6 +25,7 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib/commons:validator",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index abaefb2..1f56512 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -62,7 +62,6 @@
 import java.nio.file.FileVisitResult;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.nio.file.SimpleFileVisitor;
 import java.nio.file.attribute.BasicFileAttributes;
 import java.util.ArrayList;
@@ -313,7 +312,7 @@
       return null;
     }
 
-    Path secureStoreLib = Paths.get(secureStore);
+    Path secureStoreLib = Path.of(secureStore);
     if (!Files.exists(secureStoreLib)) {
       throw new InvalidSecureStoreException(String.format("File %s doesn't exist", secureStore));
     }
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 3dce974..44ad96e 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -40,7 +40,6 @@
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
@@ -186,7 +185,7 @@
   @Nullable
   private AccountSshKey readSshKey(Account.Id id) throws IOException {
     String defaultPublicSshKeyFile = "";
-    Path defaultPublicSshKeyPath = Paths.get(System.getProperty("user.home"), ".ssh", "id_rsa.pub");
+    Path defaultPublicSshKeyPath = Path.of(System.getProperty("user.home"), ".ssh", "id_rsa.pub");
     if (Files.exists(defaultPublicSshKeyPath)) {
       defaultPublicSshKeyFile = defaultPublicSshKeyPath.toString();
     }
@@ -195,7 +194,7 @@
   }
 
   private AccountSshKey createSshKey(Account.Id id, String keyFile) throws IOException {
-    Path p = Paths.get(keyFile);
+    Path p = Path.of(keyFile);
     if (!Files.exists(p)) {
       throw new IOException(String.format("Cannot add public SSH key: %s is not a file", keyFile));
     }
diff --git a/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java b/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
index acde91f..34f6615 100644
--- a/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
+++ b/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.base.Strings;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
@@ -63,6 +64,7 @@
     keys = AuthorizedKeys.parse(accountId, readUTF8(AuthorizedKeys.FILE_NAME));
   }
 
+  @CanIgnoreReturnValue
   public AccountSshKey addKey(String pub) {
     checkState(keys != null, "SSH keys not loaded yet");
     int seq = keys.isEmpty() ? 1 : keys.size() + 1;
diff --git a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
index abd7d43..226f12b 100644
--- a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
+++ b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.pgm.init.api;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.config.AllProjectsConfigProvider;
@@ -58,6 +59,7 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public AllProjectsConfig load() throws IOException, ConfigInvalidException {
     super.load();
     return this;
diff --git a/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
index fabad49..dd29783 100644
--- a/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
@@ -44,7 +44,8 @@
   @Override
   public Status getRepositoryStatus(NameKey name) {
     try {
-      openRepository(name);
+      @SuppressWarnings("unused")
+      var unused = openRepository(name);
     } catch (RepositoryNotFoundException e) {
       return Status.NON_EXISTENT;
     } catch (IOException e) {
diff --git a/java/com/google/gerrit/pgm/init/api/Section.java b/java/com/google/gerrit/pgm/init/api/Section.java
index 5cc4b5d..720c1f8 100644
--- a/java/com/google/gerrit/pgm/init/api/Section.java
+++ b/java/com/google/gerrit/pgm/init/api/Section.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm.init.api;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.securestore.SecureStore;
@@ -100,10 +101,12 @@
     set(name, (String) null);
   }
 
+  @CanIgnoreReturnValue
   public String string(String title, String name, String dv) {
     return string(title, name, dv, false);
   }
 
+  @CanIgnoreReturnValue
   public String string(final String title, String name, String dv, boolean nullIfDefault) {
     final String ov = get(name);
     String nv = ui.readString(ov != null ? ov : dv, "%s", title);
@@ -120,11 +123,13 @@
     return site.resolve(string(title, name, defValue));
   }
 
+  @CanIgnoreReturnValue
   public <T extends Enum<?>, E extends EnumSet<? extends T>> T select(
       String title, String name, T defValue) {
     return select(title, name, defValue, false);
   }
 
+  @CanIgnoreReturnValue
   public <T extends Enum<?>, E extends EnumSet<? extends T>> T select(
       String title, String name, T defValue, boolean nullIfDefault) {
     @SuppressWarnings("rawtypes")
@@ -134,11 +139,13 @@
     return select(title, name, defValue, allowedValues, nullIfDefault);
   }
 
+  @CanIgnoreReturnValue
   public <T extends Enum<?>, E extends EnumSet<? extends T>> T select(
       String title, String name, T defValue, E allowedValues) {
     return select(title, name, defValue, allowedValues, false);
   }
 
+  @CanIgnoreReturnValue
   public <T extends Enum<?>, A extends EnumSet<? extends T>> T select(
       String title, String name, T defValue, A allowedValues, boolean nullIfDefault) {
     final boolean set = get(name) != null;
@@ -167,6 +174,7 @@
   }
 
   @Nullable
+  @CanIgnoreReturnValue
   public String password(String username, String password) {
     final String ov = getSecure(password);
 
@@ -196,6 +204,7 @@
     return nv;
   }
 
+  @CanIgnoreReturnValue
   public String passwordForKey(String prompt, String passwordKey) {
     String ov = getSecure(passwordKey);
     if (ov != null) {
diff --git a/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java b/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
index f779601..0a82c98 100644
--- a/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm.init.api;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.config.SitePaths;
@@ -54,6 +55,7 @@
     return ref;
   }
 
+  @CanIgnoreReturnValue
   public VersionedMetaDataOnInit load() throws IOException, ConfigInvalidException {
     File path = getPath();
     if (path != null) {
@@ -89,7 +91,9 @@
       commit.setCommitter(ident);
       commit.setMessage(msg);
 
-      onSave(commit);
+      if (!onSave(commit)) {
+        return;
+      }
 
       ObjectId res = newTree.writeTree(inserter);
       if (res.equals(srcTree)) {
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/pgm/init/api/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/pgm/init/api/package-info.java
index 0709b86..ef26020 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/pgm/init/api/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.pgm.init.api;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
index d1d0729..65e7493 100644
--- a/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
+++ b/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.pgm.init.index;
 
 import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.SchemaDefinitions;
@@ -40,12 +40,11 @@
 import com.google.inject.util.Providers;
 import java.util.Collection;
 import java.util.Map;
-import java.util.Set;
 
 public class IndexModuleOnInit extends AbstractModule {
   static final String INDEX_MANAGER = "IndexModuleOnInit/IndexManager";
 
-  private static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
+  private static final ImmutableList<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
       ImmutableList.of(AccountSchemaDefinitions.INSTANCE, GroupSchemaDefinitions.INSTANCE);
 
   @Override
@@ -83,10 +82,11 @@
   @Provides
   Collection<IndexDefinition<?, ?, ?>> getIndexDefinitions(
       AccountIndexDefinition accounts, GroupIndexDefinition groups) {
-    Collection<IndexDefinition<?, ?, ?>> result = ImmutableList.of(accounts, groups);
-    Set<String> expected =
+    ImmutableList<IndexDefinition<?, ?, ?>> result = ImmutableList.of(accounts, groups);
+    ImmutableSet<String> expected =
         FluentIterable.from(ALL_SCHEMA_DEFS).transform(SchemaDefinitions::getName).toSet();
-    Set<String> actual = FluentIterable.from(result).transform(IndexDefinition::getName).toSet();
+    ImmutableSet<String> actual =
+        FluentIterable.from(result).transform(IndexDefinition::getName).toSet();
     if (!expected.equals(actual)) {
       throw new ProvisionException(
           "need index definitions for all schemas: " + expected + " != " + actual);
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/pgm/init/index/lucene/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/pgm/init/index/lucene/package-info.java
index 0709b86..e72368b 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/pgm/init/index/lucene/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.pgm.init.index.lucene;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/pgm/init/index/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/pgm/init/index/package-info.java
index 0709b86..cb32880 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/pgm/init/index/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.pgm.init.index;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/pgm/init/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/pgm/init/package-info.java
index 0709b86..f4f4945 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/pgm/init/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.pgm.init;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/pgm/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/pgm/package-info.java
index 0709b86..3e7920f 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/pgm/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.pgm;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/pgm/rules/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/pgm/rules/package-info.java
index 0709b86..5ac4176 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/pgm/rules/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.pgm.rules;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/pgm/util/BUILD b/java/com/google/gerrit/pgm/util/BUILD
index 5b01c9c..ad4ce88 100644
--- a/java/com/google/gerrit/pgm/util/BUILD
+++ b/java/com/google/gerrit/pgm/util/BUILD
@@ -23,6 +23,7 @@
         "//lib:gson",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/log:log4j",
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 21ae8e1..f45f1be 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.ChangeDraftUpdate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.IdentifiedUser;
@@ -73,9 +72,9 @@
 import com.google.gerrit.server.git.ChangesByProjectCache;
 import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.notedb.ChangeDraftNotesUpdate;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
+import com.google.gerrit.server.patch.DiffOperationsForCommitValidation;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
@@ -114,7 +113,7 @@
 
 /** Module for programs that perform batch operations on a site. */
 public class BatchProgramModule extends FactoryModule {
-  private Injector parentInjector;
+  private final Injector parentInjector;
 
   public BatchProgramModule(Injector parentInjector) {
     this.parentInjector = parentInjector;
@@ -164,6 +163,7 @@
     bind(CurrentUser.class).to(InternalUser.class);
     factory(PatchSetInserter.Factory.class);
     factory(RebaseChangeOp.Factory.class);
+    factory(DiffOperationsForCommitValidation.Factory.class);
 
     bind(new TypeLiteral<ImmutableSet<GroupReference>>() {})
         .annotatedWith(AdministrateServerGroups.class)
@@ -185,6 +185,7 @@
     modules.add(new GroupModule());
     modules.add(new NoteDbModule());
     modules.add(AccountCacheImpl.module());
+    modules.add(AccountCacheImpl.bindingModule());
     modules.add(ConflictsCacheImpl.module());
     modules.add(DefaultPreferencesCacheImpl.module());
     modules.add(GroupCacheImpl.module());
@@ -204,7 +205,6 @@
     factory(DistinctVotersPredicate.Factory.class);
     factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(ProjectState.Factory.class);
-    bind(ChangeDraftUpdate.ChangeDraftUpdateFactory.class).to(ChangeDraftNotesUpdate.Factory.class);
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
diff --git a/java/com/google/gerrit/pgm/util/LogFileCompressor.java b/java/com/google/gerrit/pgm/util/LogFileCompressor.java
deleted file mode 100644
index 5e49312..0000000
--- a/java/com/google/gerrit/pgm/util/LogFileCompressor.java
+++ /dev/null
@@ -1,171 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.util;
-
-import static java.util.concurrent.TimeUnit.HOURS;
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.common.io.ByteStreams;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.nio.file.DirectoryStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.time.LocalDateTime;
-import java.time.ZoneId;
-import java.time.temporal.ChronoUnit;
-import java.util.concurrent.Future;
-import java.util.zip.GZIPOutputStream;
-import org.eclipse.jgit.lib.Config;
-
-/** Compresses the old error logs. */
-public class LogFileCompressor implements Runnable {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static class LogFileCompressorModule extends LifecycleModule {
-    @Override
-    protected void configure() {
-      listener().to(Lifecycle.class);
-    }
-  }
-
-  static class Lifecycle implements LifecycleListener {
-    private final WorkQueue queue;
-    private final LogFileCompressor compressor;
-    private final boolean enabled;
-
-    @Inject
-    Lifecycle(WorkQueue queue, LogFileCompressor compressor, @GerritServerConfig Config config) {
-      this.queue = queue;
-      this.compressor = compressor;
-      this.enabled = config.getBoolean("log", "compress", true);
-    }
-
-    @Override
-    public void start() {
-      if (!enabled) {
-        return;
-      }
-      // compress log once and then schedule compression every day at 11:00pm
-      queue.getDefaultQueue().execute(compressor);
-      ZoneId zone = ZoneId.systemDefault();
-      LocalDateTime now = LocalDateTime.now(zone);
-      long milliSecondsUntil11pm =
-          now.until(now.withHour(23).withMinute(0).withSecond(0).withNano(0), ChronoUnit.MILLIS);
-      @SuppressWarnings("unused")
-      Future<?> possiblyIgnoredError =
-          queue
-              .getDefaultQueue()
-              .scheduleAtFixedRate(
-                  compressor, milliSecondsUntil11pm, HOURS.toMillis(24), MILLISECONDS);
-    }
-
-    @Override
-    public void stop() {}
-  }
-
-  private final Path logs_dir;
-
-  @Inject
-  LogFileCompressor(SitePaths site) {
-    logs_dir = resolve(site.logs_dir);
-  }
-
-  private static Path resolve(Path p) {
-    try {
-      return p.toRealPath().normalize();
-    } catch (IOException e) {
-      return p.toAbsolutePath().normalize();
-    }
-  }
-
-  @Override
-  public void run() {
-    try {
-      if (!Files.isDirectory(logs_dir)) {
-        return;
-      }
-      try (DirectoryStream<Path> list = Files.newDirectoryStream(logs_dir)) {
-        for (Path entry : list) {
-          if (!isLive(entry) && !isCompressed(entry) && isLogFile(entry)) {
-            compress(entry);
-          }
-        }
-      } catch (IOException e) {
-        logger.atSevere().withCause(e).log("Error listing logs to compress in %s", logs_dir);
-      }
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Failed to compress log files: %s", e.getMessage());
-    }
-  }
-
-  private boolean isLive(Path entry) {
-    String name = entry.getFileName().toString();
-    return name.endsWith("_log")
-        || name.endsWith(".log")
-        || name.endsWith(".run")
-        || name.endsWith(".pid")
-        || name.endsWith(".json");
-  }
-
-  private boolean isCompressed(Path entry) {
-    String name = entry.getFileName().toString();
-    return name.endsWith(".gz") //
-        || name.endsWith(".zip") //
-        || name.endsWith(".bz2");
-  }
-
-  private boolean isLogFile(Path entry) {
-    return Files.isRegularFile(entry);
-  }
-
-  private void compress(Path src) {
-    Path dst = src.resolveSibling(src.getFileName() + ".gz");
-    Path tmp = src.resolveSibling(".tmp." + src.getFileName());
-    try {
-      try (InputStream in = Files.newInputStream(src);
-          OutputStream out = new GZIPOutputStream(Files.newOutputStream(tmp))) {
-        ByteStreams.copy(in, out);
-      }
-      tmp.toFile().setReadOnly();
-      try {
-        Files.move(tmp, dst);
-      } catch (IOException e) {
-        throw new IOException("Cannot rename " + tmp + " to " + dst, e);
-      }
-      Files.delete(src);
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Cannot compress %s", src);
-      try {
-        Files.deleteIfExists(tmp);
-      } catch (IOException e2) {
-        logger.atWarning().withCause(e2).log("Failed to delete temporary log file %s", tmp);
-      }
-    }
-  }
-
-  @Override
-  public String toString() {
-    return "Log File Compressor";
-  }
-}
diff --git a/java/com/google/gerrit/pgm/util/LogFileManager.java b/java/com/google/gerrit/pgm/util/LogFileManager.java
new file mode 100644
index 0000000..902f7d64
--- /dev/null
+++ b/java/com/google/gerrit/pgm/util/LogFileManager.java
@@ -0,0 +1,249 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.util;
+
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.io.ByteStreams;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.Optional;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.zip.GZIPOutputStream;
+import org.eclipse.jgit.lib.Config;
+
+/** Compresses and eventually deletes the old logs. */
+public class LogFileManager implements Runnable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final Pattern LOG_FILENAME_PATTERN =
+      Pattern.compile("^.+(?<date>\\d{4}-\\d{2}-\\d{2})(.gz)?");
+  protected final boolean compressionEnabled;
+  private final Duration timeToKeep;
+
+  public static class LogFileManagerModule extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(Lifecycle.class);
+    }
+  }
+
+  static class Lifecycle implements LifecycleListener {
+    private final WorkQueue queue;
+    private final LogFileManager manager;
+
+    @Inject
+    Lifecycle(WorkQueue queue, LogFileManager manager) {
+      this.queue = queue;
+      this.manager = manager;
+    }
+
+    @Override
+    public void start() {
+      if (!manager.compressionEnabled && manager.timeToKeep.isNegative()) {
+        return;
+      }
+      // compress log once and then schedule compression every day at 11:00pm
+      queue.getDefaultQueue().execute(manager);
+      ZoneId zone = ZoneId.systemDefault();
+      LocalDateTime now = LocalDateTime.now(zone);
+      long milliSecondsUntil11pm =
+          now.until(now.withHour(23).withMinute(0).withSecond(0).withNano(0), ChronoUnit.MILLIS);
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError =
+          queue
+              .getDefaultQueue()
+              .scheduleAtFixedRate(
+                  manager, milliSecondsUntil11pm, HOURS.toMillis(24), MILLISECONDS);
+    }
+
+    @Override
+    public void stop() {}
+  }
+
+  private final Path logs_dir;
+
+  @Inject
+  LogFileManager(SitePaths site, @GerritServerConfig Config config) {
+    this.logs_dir = resolve(site.logs_dir);
+    this.compressionEnabled = config.getBoolean("log", "compress", true);
+    this.timeToKeep = getTimeToKeep(config);
+  }
+
+  private Duration getTimeToKeep(Config config) {
+    try {
+      return Duration.ofDays(
+          ConfigUtil.getTimeUnit(config, "log", null, "timeToKeep", -1, TimeUnit.DAYS));
+    } catch (IllegalArgumentException e) {
+      logger.atWarning().withCause(e).log(
+          "Illegal duration value for log deletion. Disabling log deletion.");
+      return Duration.ofDays(-1L);
+    }
+  }
+
+  private static Path resolve(Path p) {
+    try {
+      return p.toRealPath().normalize();
+    } catch (IOException e) {
+      return p.toAbsolutePath().normalize();
+    }
+  }
+
+  @Override
+  public void run() {
+    logger.atInfo().log("Starting log file maintenance.");
+    try {
+      if (!Files.isDirectory(logs_dir)) {
+        return;
+      }
+      try (DirectoryStream<Path> list = Files.newDirectoryStream(logs_dir)) {
+        for (Path entry : list) {
+          if (isLive(entry) || !isLogFile(entry)) {
+            continue;
+          }
+          if (!timeToKeep.isNegative() && isExpired(entry)) {
+            if (delete(entry)) {
+              continue;
+            }
+          }
+          if (compressionEnabled && !isCompressed(entry)) {
+            compress(entry);
+          }
+        }
+      } catch (IOException e) {
+        logger.atSevere().withCause(e).log("Error listing logs to compress in %s", logs_dir);
+      }
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Failed to process log files: %s", e.getMessage());
+    }
+    logger.atInfo().log("Log file maintenance has finished.");
+  }
+
+  private boolean isLive(Path entry) {
+    String name = entry.getFileName().toString();
+    return name.endsWith("_log")
+        || name.endsWith(".log")
+        || name.endsWith(".run")
+        || name.endsWith(".pid")
+        || name.endsWith(".json");
+  }
+
+  private boolean isCompressed(Path entry) {
+    String name = entry.getFileName().toString();
+    return name.endsWith(".gz") //
+        || name.endsWith(".zip") //
+        || name.endsWith(".bz2");
+  }
+
+  private boolean isLogFile(Path entry) {
+    return Files.isRegularFile(entry);
+  }
+
+  @VisibleForTesting
+  boolean isExpired(Path entry) {
+    try {
+      FileTime creationTime = Files.readAttributes(entry, BasicFileAttributes.class).creationTime();
+
+      if (creationTime.toInstant().equals(Instant.EPOCH)) {
+        Optional<Instant> fileDate = getDateFromFilename(entry);
+        if (fileDate.isPresent()) {
+          return fileDate.get().isBefore(Instant.now().minus(timeToKeep));
+        }
+        return false;
+      }
+
+      return creationTime.toInstant().isBefore(Instant.now().minus(timeToKeep));
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Failed to get creation time of log file %s", entry);
+    }
+    return false;
+  }
+
+  @VisibleForTesting
+  Optional<Instant> getDateFromFilename(Path entry) {
+    Matcher filenameMatcher = LOG_FILENAME_PATTERN.matcher(entry.getFileName().toString());
+    if (filenameMatcher.matches()) {
+      String rotationDate = filenameMatcher.group("date");
+      if (rotationDate != null && !rotationDate.isBlank()) {
+        return Optional.of(Instant.parse(rotationDate + "T00:00:00.00Z"));
+      }
+    }
+    return Optional.empty();
+  }
+
+  private boolean delete(Path entry) {
+    try {
+      Files.deleteIfExists(entry);
+      logger.atInfo().log("Log file %s has been deleted.", entry);
+      return true;
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log("Failed to delete log file %s", entry);
+    }
+    return false;
+  }
+
+  private void compress(Path src) {
+    Path dst = src.resolveSibling(src.getFileName() + ".gz");
+    Path tmp = src.resolveSibling(".tmp." + src.getFileName());
+    try {
+      try (InputStream in = Files.newInputStream(src);
+          OutputStream out = new GZIPOutputStream(Files.newOutputStream(tmp))) {
+        ByteStreams.copy(in, out);
+      }
+      tmp.toFile().setReadOnly();
+      try {
+        Files.move(tmp, dst);
+      } catch (IOException e) {
+        throw new IOException("Cannot rename " + tmp + " to " + dst, e);
+      }
+      Files.delete(src);
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Cannot compress %s", src);
+      try {
+        Files.deleteIfExists(tmp);
+      } catch (IOException e2) {
+        logger.atWarning().withCause(e2).log("Failed to delete temporary log file %s", tmp);
+      }
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "Log File Manager";
+  }
+}
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index ff0b31e..aeaa1d6 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -42,7 +42,6 @@
 import com.google.inject.util.Providers;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.List;
 import org.kohsuke.args4j.Option;
@@ -53,10 +52,10 @@
       aliases = {"-d"},
       usage = "Local directory containing site data")
   void setSitePath(String path) {
-    sitePath = Paths.get(path).normalize();
+    sitePath = Path.of(path).normalize();
   }
 
-  private Path sitePath = Paths.get(".");
+  private Path sitePath = Path.of(".");
 
   protected SiteProgram() {}
 
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/pgm/util/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/pgm/util/package-info.java
index 0709b86..3bd1fd9 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/pgm/util/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.pgm.util;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/prettify/BUILD b/java/com/google/gerrit/prettify/BUILD
index a5c8b77..5b07849 100644
--- a/java/com/google/gerrit/prettify/BUILD
+++ b/java/com/google/gerrit/prettify/BUILD
@@ -10,5 +10,6 @@
         "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/prettify/common/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/prettify/common/package-info.java
index 0709b86..5f70f74 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/prettify/common/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.prettify.common;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/prettify/common/testing/BUILD b/java/com/google/gerrit/prettify/common/testing/BUILD
index 5057fdb..ecc28ff 100644
--- a/java/com/google/gerrit/prettify/common/testing/BUILD
+++ b/java/com/google/gerrit/prettify/common/testing/BUILD
@@ -9,6 +9,7 @@
     deps = [
         "//java/com/google/gerrit/prettify:server",
         "//lib:guava",
+        "//lib/errorprone:annotations",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/prettify/common/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/prettify/common/testing/package-info.java
index 0709b86..430ba69 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/prettify/common/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.prettify.common.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/proto/BUILD b/java/com/google/gerrit/proto/BUILD
index 98558c5..82af646 100644
--- a/java/com/google/gerrit/proto/BUILD
+++ b/java/com/google/gerrit/proto/BUILD
@@ -6,5 +6,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//lib:protobuf",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/proto/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/proto/package-info.java
index 0709b86..8c4522f 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/proto/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.proto;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/proto/testing/BUILD b/java/com/google/gerrit/proto/testing/BUILD
index 069bb46..17a2eaf 100644
--- a/java/com/google/gerrit/proto/testing/BUILD
+++ b/java/com/google/gerrit/proto/testing/BUILD
@@ -9,6 +9,7 @@
     deps = [
         "//lib:guava",
         "//lib/commons:lang3",
+        "//lib/errorprone:annotations",
         "//lib/guice",
         "//lib/truth",
     ],
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/proto/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/proto/testing/package-info.java
index 0709b86..6f19454 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/proto/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.proto.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 000f095..96d888a 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -105,7 +105,7 @@
         "//lib/jsoup",
         "//lib/log:log4j",
         "//lib/lucene:lucene-analyzers-common",
-        "//lib/lucene:lucene-core-and-backward-codecs",
+        "//lib/lucene:lucene-core",
         "//lib/lucene:lucene-queryparser",
         "//lib/mime4j:core",
         "//lib/mime4j:dom",
diff --git a/java/com/google/gerrit/server/ChangeDraftUpdate.java b/java/com/google/gerrit/server/ChangeDraftUpdate.java
index eb33fb5..a3b13e5 100644
--- a/java/com/google/gerrit/server/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/ChangeDraftUpdate.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import java.time.Instant;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /** An interface for updating draft comments. */
@@ -49,11 +50,7 @@
    * Marks a comment for deletion. Called when the comment is deleted because the user published it.
    *
    * <p>NOTE for implementers: The actual deletion of a published draft should only happen after the
-   * published comment is successfully updated. For more context, see {@link
-   * com.google.gerrit.server.notedb.NoteDbUpdateManager#execute(boolean)}.
-   *
-   * <p>TODO(nitzan) - add generalized support for the above sync issue. The implementation should
-   * support deletion of published drafts from multiple ChangeDraftUpdateFactory instances.
+   * published comment is successfully updated. Please use {@link ChangeDraftUpdateExecutor}.
    */
   void markDraftCommentAsPublished(HumanComment c);
 
@@ -67,4 +64,26 @@
    * comments storage and the drafts one.
    */
   void addAllDraftCommentsForDeletion(List<Comment> comments);
+
+  /** Whether all updates in this updater can run asynchronously. */
+  boolean canRunAsync();
+
+  /**
+   * A unique identifier for the draft, used by the storage system. For example, NoteDB's ref name.
+   */
+  String getStorageKey();
+
+  /**
+   * Converts this update to the given subtype if possible. Returns {@link Optional#empty()}
+   * otherwise.
+   */
+  default <UpdateT extends ChangeDraftUpdate> Optional<UpdateT> toOptionalChangeDraftUpdateSubtype(
+      Class<UpdateT> subtype) {
+    if (this.getClass().isAssignableFrom(subtype)) {
+      @SuppressWarnings("unchecked")
+      UpdateT update = (UpdateT) this;
+      return Optional.of(update);
+    }
+    return Optional.empty();
+  }
 }
diff --git a/java/com/google/gerrit/server/ChangeDraftUpdateExecutor.java b/java/com/google/gerrit/server/ChangeDraftUpdateExecutor.java
new file mode 100644
index 0000000..8c932fc
--- /dev/null
+++ b/java/com/google/gerrit/server/ChangeDraftUpdateExecutor.java
@@ -0,0 +1,129 @@
+// 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;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.update.BatchUpdateListener;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Optional;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.transport.PushCertificate;
+
+/**
+ * An interface for executing updates of multiple {@link ChangeDraftUpdate} instances.
+ *
+ * <p>Expected usage flow:
+ *
+ * <ol>
+ *   <li>Inject an instance of {@link AbstractFactory}.
+ *   <li>Create an instance of this interface using the factory.
+ *   <li>Call ({@link #queueAllDraftUpdates} or {@link #queueDeletionForChangeDrafts} for all
+ *       expected updates. The changes are marked to be executed either synchronously or
+ *       asynchronously, based on {@link #canRunAsync}.
+ *   <li>Call both {@link #executeAllSyncUpdates} and {@link #executeAllAsyncUpdates} methods.
+ *       Running these methods with no pending updates is a no-op.
+ * </ol>
+ */
+public interface ChangeDraftUpdateExecutor {
+  interface AbstractFactory {
+    // Guice cannot bind either:
+    // - A parameterized entity.
+    // - A factory creating an interface (rather than a class).
+    // To overcome this - we declare the create method in this non-parameterized interface, then
+    // extend it with a factory returning an actual class.
+    ChangeDraftUpdateExecutor create(CurrentUser currentUser);
+  }
+
+  interface Factory<T extends ChangeDraftUpdateExecutor> extends AbstractFactory {
+    @Override
+    T create(CurrentUser currentUser);
+  }
+
+  /**
+   * Queues all provided updates for later execution.
+   *
+   * <p>The updates are queued to either run synchronously just after change repositories updates,
+   * or to run asynchronously afterwards, based on {@link #canRunAsync}.
+   */
+  void queueAllDraftUpdates(ListMultimap<String, ChangeDraftUpdate> updates) throws IOException;
+
+  /**
+   * Extracts all drafts (of all authors) for the given change and queue their deletion.
+   *
+   * <p>See {@link #canRunAsync} for whether the deletions are scheduled as synchronous or
+   * asynchronous.
+   */
+  void queueDeletionForChangeDrafts(Change.Id id) throws IOException;
+
+  /**
+   * Execute all previously queued sync updates.
+   *
+   * <p>NOTE that {@link BatchUpdateListener#beforeUpdateRefs} events are not fired by this method.
+   * post-update events can be fired by the caller only for implementations that return a valid
+   * {@link BatchRefUpdate}.
+   *
+   * @param dryRun whether this is a dry run - i.e. no updates should be made
+   * @param refLogIdent user to log as the update creator
+   * @param refLogMessage message to put in the updates log
+   * @return the executed update, if supported by the implementing class
+   * @throws IOException in case of an update failure.
+   */
+  Optional<BatchRefUpdate> executeAllSyncUpdates(
+      boolean dryRun, @Nullable PersonIdent refLogIdent, @Nullable String refLogMessage)
+      throws IOException;
+
+  /**
+   * Execute all previously queued async updates.
+   *
+   * @param refLogIdent user to log as the update creator
+   * @param refLogMessage message to put in the updates log
+   * @param pushCert to use for the update
+   */
+  void executeAllAsyncUpdates(
+      @Nullable PersonIdent refLogIdent,
+      @Nullable String refLogMessage,
+      @Nullable PushCertificate pushCert);
+
+  /** Returns whether any updates are queued. */
+  boolean isEmpty();
+
+  /** Returns the given updates that match the provided type. */
+  default <UpdateT extends ChangeDraftUpdate> ListMultimap<String, UpdateT> filterTypedUpdates(
+      ListMultimap<String, ChangeDraftUpdate> updates, Class<UpdateT> updateType) {
+    ListMultimap<String, UpdateT> res = MultimapBuilder.hashKeys().arrayListValues().build();
+    for (String key : updates.keySet()) {
+      res.putAll(
+          key,
+          updates.get(key).stream()
+              .map(u -> u.toOptionalChangeDraftUpdateSubtype(updateType))
+              .filter(Optional::isPresent)
+              .map(Optional::get)
+              .collect(toImmutableList()));
+    }
+    return res;
+  }
+
+  /** Returns whether all provided updates can run asynchronously. */
+  default boolean canRunAsync(Collection<? extends ChangeDraftUpdate> updates) {
+    return updates.stream().allMatch(u -> u.canRunAsync());
+  }
+}
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 400da58..7e8271b 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.ChangeMessage;
@@ -83,6 +84,7 @@
    * @return message built from {@code messageTemplate}. Templates are replaced, so it might contain
    *     user identifiable information.
    */
+  @CanIgnoreReturnValue
   public String setChangeMessage(
       ChangeUpdate update, String messageTemplate, @Nullable String tag) {
     update.setChangeMessage(messageTemplate);
@@ -91,6 +93,7 @@
   }
 
   /** See {@link #setChangeMessage(ChangeUpdate, String, String)}. */
+  @CanIgnoreReturnValue
   public String setChangeMessage(ChangeContext ctx, String messageTemplate, @Nullable String tag) {
     return setChangeMessage(
         ctx.getUpdate(ctx.getChange().currentPatchSetId()), messageTemplate, tag);
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index dd86f88..5b78658 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -146,7 +146,8 @@
             Constants.encode("tree " + ObjectId.zeroId().name() + "\n\n" + newCommitMessage));
 
     // Check that the commit message without footers is not empty
-    CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
+    @SuppressWarnings("unused")
+    var unused = CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
 
     List<String> changeIdFooters = getChangeIdsFromFooter(revCommit);
     if (requireChangeId && changeIdFooters.isEmpty()) {
diff --git a/java/com/google/gerrit/server/CmdLineParserModule.java b/java/com/google/gerrit/server/CmdLineParserModule.java
index be6b4cd8..c523a29 100644
--- a/java/com/google/gerrit/server/CmdLineParserModule.java
+++ b/java/com/google/gerrit/server/CmdLineParserModule.java
@@ -18,12 +18,14 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.args4j.AccountGroupIdHandler;
 import com.google.gerrit.server.args4j.AccountGroupUUIDHandler;
 import com.google.gerrit.server.args4j.AccountIdHandler;
 import com.google.gerrit.server.args4j.ChangeIdHandler;
 import com.google.gerrit.server.args4j.InstantHandler;
+import com.google.gerrit.server.args4j.ListTagSortOptionHandler;
 import com.google.gerrit.server.args4j.ObjectIdHandler;
 import com.google.gerrit.server.args4j.PatchSetIdHandler;
 import com.google.gerrit.server.args4j.ProjectHandler;
@@ -54,6 +56,7 @@
     registerOptionHandler(PatchSet.Id.class, PatchSetIdHandler.class);
     registerOptionHandler(ProjectState.class, ProjectHandler.class);
     registerOptionHandler(SocketAddress.class, SocketAddressHandler.class);
+    registerOptionHandler(ListTagSortOption.class, ListTagSortOptionHandler.class);
   }
 
   private <T> void registerOptionHandler(Class<T> type, Class<? extends OptionHandler<T>> impl) {
diff --git a/java/com/google/gerrit/server/CommentVerifier.java b/java/com/google/gerrit/server/CommentVerifier.java
new file mode 100644
index 0000000..b6e4321
--- /dev/null
+++ b/java/com/google/gerrit/server/CommentVerifier.java
@@ -0,0 +1,52 @@
+// 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;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/** Verifier for {@link Comment} objects */
+public final class CommentVerifier {
+  public static void verify(
+      Comment c, Account.Id accountId, Account.Id realAccountId, PersonIdent authorIdent) {
+    checkArgument(c.getCommitId() != null, "commit ID required for comment: %s", c);
+    checkAccountId(accountId, authorIdent);
+    checkArgument(
+        c.author.getId().equals(accountId),
+        "The author for the following comment does not match the author of this CommentVerifier (%s): %s",
+        accountId,
+        c);
+    checkArgument(
+        c.getRealAuthor().getId().equals(realAccountId),
+        "The real author for the following comment does not match the real"
+            + " author of this CommentVerifier (%s): %s",
+        realAccountId,
+        c);
+  }
+
+  @CanIgnoreReturnValue
+  private static Account.Id checkAccountId(Account.Id accountId, PersonIdent authorIdent) {
+    checkState(
+        accountId != null,
+        "author identity for CommentVerifier is not from an IdentifiedUser: %s",
+        authorIdent.toExternalString());
+    return accountId;
+  }
+}
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index bddd86a..30b8747 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -21,23 +21,24 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -46,7 +47,6 @@
 import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.gerrit.server.query.change.ChangeNumberVirtualIdAlgorithm;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -59,7 +59,6 @@
 import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 
@@ -116,22 +115,16 @@
 
   private final DiffOperations diffOperations;
   private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsers;
   private final String serverId;
-  private final ChangeNumberVirtualIdAlgorithm virtualIdAlgorithm;
 
   @Inject
   CommentsUtil(
       DiffOperations diffOperations,
       GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      @GerritServerId String serverId,
-      @Nullable ChangeNumberVirtualIdAlgorithm virtualIdAlgorithm) {
+      @GerritServerId String serverId) {
     this.diffOperations = diffOperations;
     this.repoManager = repoManager;
-    this.allUsers = allUsers;
     this.serverId = serverId;
-    this.virtualIdAlgorithm = virtualIdAlgorithm;
   }
 
   public HumanComment newHumanComment(
@@ -143,7 +136,8 @@
       short side,
       String message,
       @Nullable Boolean unresolved,
-      @Nullable String parentUuid) {
+      @Nullable String parentUuid,
+      @Nullable List<FixSuggestion> fixSuggestions) {
     if (unresolved == null) {
       if (parentUuid == null) {
         // Default to false if comment is not descended from another.
@@ -168,6 +162,7 @@
             serverId,
             unresolved);
     c.parentUuid = parentUuid;
+    c.fixSuggestions = fixSuggestions;
     currentUser.updateRealAccountId(c::setRealAuthor);
     return c;
   }
@@ -220,13 +215,8 @@
     return robotCommentsByChange(notes).stream().filter(c -> c.key.uuid.equals(uuid)).findFirst();
   }
 
-  public List<HumanComment> publishedByChangeFile(ChangeNotes notes, String file) {
-    return commentsOnFile(notes.load().getHumanComments().values(), file);
-  }
-
   public List<HumanComment> publishedByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    return removeCommentsOnAncestorOfCommitMessage(
-        commentsOnPatchSet(notes.load().getHumanComments().values(), psId));
+    return commentsOnPatchSet(notes.load().getHumanComments().values(), psId);
   }
 
   public List<RobotComment> robotCommentsByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
@@ -309,29 +299,6 @@
         Optional.ofNullable(cm.getAuthor()).map(a -> a.get()),
         Optional.ofNullable(comment.author).map(a -> a._accountId));
   }
-  /**
-   * For the commit message the A side in a diff view is always empty when a comparison against an
-   * ancestor is done, so there can't be any comments on this ancestor. However earlier we showed
-   * the auto-merge commit message on side A when for a merge commit a comparison against the
-   * auto-merge was done. From that time there may still be comments on the auto-merge commit
-   * message and those we want to filter out.
-   */
-  private List<HumanComment> removeCommentsOnAncestorOfCommitMessage(List<HumanComment> list) {
-    return list.stream()
-        .filter(c -> c.side != 0 || !Patch.COMMIT_MSG.equals(c.key.filename))
-        .collect(toList());
-  }
-
-  public List<HumanComment> draftByPatchSetAuthor(
-      PatchSet.Id psId, Account.Id author, ChangeNotes notes) {
-    return commentsOnPatchSet(notes.load().getDraftComments(author, getVirtualId(notes)), psId);
-  }
-
-  public List<HumanComment> draftByChangeAuthor(ChangeNotes notes, Account.Id author) {
-    List<HumanComment> comments = new ArrayList<>();
-    comments.addAll(notes.getDraftComments(author, getVirtualId(notes)));
-    return sort(comments);
-  }
 
   public void putHumanComments(
       ChangeUpdate update, Comment.Status status, Iterable<HumanComment> comments) {
@@ -357,18 +324,6 @@
     update.deleteCommentByRewritingHistory(commentKey.uuid, newMessage);
   }
 
-  private static List<HumanComment> commentsOnFile(
-      Collection<HumanComment> allComments, String file) {
-    List<HumanComment> result = new ArrayList<>(allComments.size());
-    for (HumanComment c : allComments) {
-      String currentFilename = c.key.filename;
-      if (currentFilename.equals(file)) {
-        result.add(c);
-      }
-    }
-    return sort(result);
-  }
-
   private static <T extends Comment> List<T> commentsOnPatchSet(
       Collection<T> allComments, PatchSet.Id psId) {
     List<T> result = new ArrayList<>(allComments.size());
@@ -447,36 +402,39 @@
     }
   }
 
-  /**
-   * Get NoteDb draft refs for a change.
-   *
-   * <p>This is just a simple ref scan, so the results may potentially include refs for zombie draft
-   * comments. A zombie draft is one which has been published but the write to delete the draft ref
-   * from All-Users failed.
-   *
-   * @param changeId change ID.
-   * @return raw refs from All-Users repo.
-   */
-  public Collection<Ref> getDraftRefs(Change.Id changeId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getDraftRefs(repo, changeId);
-    } catch (IOException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  private Collection<Ref> getDraftRefs(Repository repo, Change.Id virtualId) throws IOException {
-    return repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(virtualId));
-  }
-
   public static <T extends Comment> List<T> sort(List<T> comments) {
     comments.sort(COMMENT_ORDER);
     return comments;
   }
 
-  private Change.Id getVirtualId(ChangeNotes notes) {
-    return virtualIdAlgorithm == null
-        ? notes.getChangeId()
-        : virtualIdAlgorithm.apply(notes.getServerId(), notes.getChangeId());
+  @Nullable
+  public static ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
+      List<FixSuggestionInfo> fixSuggestionInfos) {
+    if (fixSuggestionInfos == null) {
+      return null;
+    }
+
+    ImmutableList.Builder<FixSuggestion> fixSuggestions =
+        ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
+    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
+      fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
+    }
+    return fixSuggestions.build();
+  }
+
+  public static FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
+    List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
+    String fixId = ChangeUtil.messageUuid();
+    return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
+  }
+
+  public static List<FixReplacement> toFixReplacements(
+      List<FixReplacementInfo> fixReplacementInfos) {
+    return fixReplacementInfos.stream().map(CommentsUtil::toFixReplacement).collect(toList());
+  }
+
+  public static FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
+    Comment.Range range = new Comment.Range(fixReplacementInfo.range);
+    return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
   }
 }
diff --git a/java/com/google/gerrit/server/DeleteZombieComments.java b/java/com/google/gerrit/server/DeleteZombieComments.java
index 4532b04..cf15c95 100644
--- a/java/com/google/gerrit/server/DeleteZombieComments.java
+++ b/java/com/google/gerrit/server/DeleteZombieComments.java
@@ -209,7 +209,7 @@
             draftCommentsReader.getDraftsByChangeAndDraftAuthor(changeId, accountId);
         ChangeNotes notes = getChangeNotes(changeId);
         List<HumanComment> published = commentsUtil.publishedHumanCommentsByChange(notes);
-        Set<String> publishedIds = toUuid(published);
+        ImmutableSet<String> publishedIds = toUuid(published);
         ImmutableList<HumanComment> zombieDraftsForChangeAndAuthor =
             drafts.stream()
                 .filter(draft -> publishedIds.contains(draft.key.uuid))
@@ -284,7 +284,7 @@
   }
 
   /** Map the list of input comments to their UUIDs. */
-  private Set<String> toUuid(List<HumanComment> in) {
+  private ImmutableSet<String> toUuid(List<HumanComment> in) {
     return in.stream().map(c -> c.key.uuid).collect(toImmutableSet());
   }
 
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index de5f023..d6722cc 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -18,6 +18,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.HumanComment;
@@ -35,7 +36,6 @@
 import java.sql.Timestamp;
 import java.util.Collection;
 import java.util.HashSet;
-import java.util.Map;
 import java.util.Set;
 
 @Singleton
@@ -62,7 +62,7 @@
       return;
     }
 
-    Map<PatchSet.Id, PatchSet> patchSets =
+    ImmutableMap<PatchSet.Id, PatchSet> patchSets =
         psUtil.getAsMap(notes, draftComments.stream().map(d -> psId(notes, d)).collect(toSet()));
     Set<HumanComment> commentsToPublish = new HashSet<>();
     for (HumanComment draftComment : draftComments) {
diff --git a/java/com/google/gerrit/server/ReviewerStatusUpdate.java b/java/com/google/gerrit/server/ReviewerStatusUpdate.java
index 1e0aa43..5486359 100644
--- a/java/com/google/gerrit/server/ReviewerStatusUpdate.java
+++ b/java/com/google/gerrit/server/ReviewerStatusUpdate.java
@@ -16,22 +16,35 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import java.time.Instant;
+import java.util.Optional;
 
 /** Change to a reviewer's status. */
 @AutoValue
 public abstract class ReviewerStatusUpdate {
-  public static ReviewerStatusUpdate create(
+  public static ReviewerStatusUpdate createForReviewer(
       Instant ts, Account.Id updatedBy, Account.Id reviewer, ReviewerStateInternal state) {
-    return new AutoValue_ReviewerStatusUpdate(ts, updatedBy, reviewer, state);
+    return new AutoValue_ReviewerStatusUpdate(
+        ts, updatedBy, Optional.of(reviewer), Optional.empty(), state);
+  }
+
+  public static ReviewerStatusUpdate createForReviewerByEmail(
+      Instant ts, Account.Id updatedBy, Address reviewerByEmail, ReviewerStateInternal state) {
+    return new AutoValue_ReviewerStatusUpdate(
+        ts, updatedBy, Optional.empty(), Optional.of(reviewerByEmail), state);
   }
 
   public abstract Instant date();
 
   public abstract Account.Id updatedBy();
 
-  public abstract Account.Id reviewer();
+  /** Not set if a reviewer for which no Gerrit account exists is added by email. */
+  public abstract Optional<Account.Id> reviewer();
+
+  /** Only set for reviewers that have no Gerrit account and that have been added by email. */
+  public abstract Optional<Address> reviewerByEmail();
 
   public abstract ReviewerStateInternal state();
 }
diff --git a/java/com/google/gerrit/server/Sequence.java b/java/com/google/gerrit/server/Sequence.java
index 844b583..541ce25 100644
--- a/java/com/google/gerrit/server/Sequence.java
+++ b/java/com/google/gerrit/server/Sequence.java
@@ -76,7 +76,4 @@
    * #current()}.
    */
   void storeNew(int value);
-
-  /** Returns the batch size that was used to initialize the sequence. */
-  int getBatchSize();
 }
diff --git a/java/com/google/gerrit/server/Sequences.java b/java/com/google/gerrit/server/Sequences.java
index 431a1b2..ad7af45 100644
--- a/java/com/google/gerrit/server/Sequences.java
+++ b/java/com/google/gerrit/server/Sequences.java
@@ -93,18 +93,6 @@
     }
   }
 
-  public int changeBatchSize() {
-    return changeSeq.getBatchSize();
-  }
-
-  public int groupBatchSize() {
-    return groupSeq.getBatchSize();
-  }
-
-  public int accountBatchSize() {
-    return accountSeq.getBatchSize();
-  }
-
   public int currentChangeId() {
     return changeSeq.current();
   }
@@ -117,18 +105,6 @@
     return groupSeq.current();
   }
 
-  public int lastChangeId() {
-    return changeSeq.last();
-  }
-
-  public int lastGroupId() {
-    return groupSeq.last();
-  }
-
-  public int lastAccountId() {
-    return accountSeq.last();
-  }
-
   public void setChangeIdValue(int value) {
     changeSeq.storeNew(value);
   }
diff --git a/java/com/google/gerrit/server/StarredChangesReader.java b/java/com/google/gerrit/server/StarredChangesReader.java
index ddf0cd3..0386cb3 100644
--- a/java/com/google/gerrit/server/StarredChangesReader.java
+++ b/java/com/google/gerrit/server/StarredChangesReader.java
@@ -14,27 +14,60 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import java.util.List;
 import java.util.Set;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
+/** Interface for reading information about starred changes. */
 public interface StarredChangesReader {
+
+  /**
+   * Checks if a specific change is starred by a given user.
+   *
+   * @param accountId the {@code Account.Id}.
+   * @param changeId the {@code Change.Id}.
+   * @return {@code true} if the change is starred by the user, {@code false} otherwise.
+   */
   boolean isStarred(Account.Id accountId, Change.Id changeId);
 
   /**
-   * Returns a subset of change IDs among the input {@code changeIds} list that are starred by the
-   * {@code caller} user.
+   * Returns a subset of {@code Change.Id}s among the input {@code changeIds} list that are starred
+   * by the {@code caller} user.
+   *
+   * @param allUsersRepo 'All-Users' repository.
+   * @param changeIds the list of {@code Change.Id}s to check.
+   * @param caller the {@code Account.Id} to check starred changes by a user.
+   * @return a set of {@code Change.Id}s that are starred by the specified user.
    */
   Set<Change.Id> areStarred(Repository allUsersRepo, List<Change.Id> changeIds, Account.Id caller);
 
-  ImmutableMap<Account.Id, Ref> byChange(Change.Id changeId);
+  /**
+   * Retrieves a list of {@code Account.Id} which starred a {@code Change.Id}.
+   *
+   * @param changeId the {@code Change.Id}.
+   * @return an immutable list of {@code Account.Id}s for the specified change.
+   */
+  ImmutableList<Account.Id> byChange(Change.Id changeId);
 
+  /**
+   * Retrieves a set of {@code changeIds} starred by {@code Account.Id}.
+   *
+   * @param accountId the {@code Account.Id}.
+   * @return an immutable set of {@code Change.Id}s associated with the specified user account.
+   */
   ImmutableSet<Change.Id> byAccountId(Account.Id accountId);
 
+  /**
+   * Retrieves a set of {@code Change.Id}s associated with the specified user account, optionally
+   * skipping invalid changes.
+   *
+   * @param accountId the {@code Account.Id}.
+   * @param skipInvalidChanges {@code true} to skip invalid changes, {@code false} otherwise.
+   * @return an immutable set of {@code Change.Id}s associated with the specified user account.
+   */
   ImmutableSet<Change.Id> byAccountId(Account.Id accountId, boolean skipInvalidChanges);
 }
diff --git a/java/com/google/gerrit/server/StarredChangesWriter.java b/java/com/google/gerrit/server/StarredChangesWriter.java
index 6c14cc9..eafe7f7 100644
--- a/java/com/google/gerrit/server/StarredChangesWriter.java
+++ b/java/com/google/gerrit/server/StarredChangesWriter.java
@@ -18,9 +18,22 @@
 import com.google.gerrit.entities.Change;
 import java.io.IOException;
 
+/** Interface for writing information about starred changes. */
 public interface StarredChangesWriter {
+  /**
+   * Star the given change for a single {@code Account.Id}.
+   *
+   * @param changeId the {@code Change.Id}.
+   * @param accountId the {@code Account.Id}.
+   */
   void star(Account.Id accountId, Change.Id changeId);
 
+  /**
+   * Unstar the given change for a single {@code Account.Id}.
+   *
+   * @param changeId the {@code Change.Id}.
+   * @param accountId the {@code Account.Id}.
+   */
   void unstar(Account.Id accountId, Change.Id changeId);
 
   /**
diff --git a/java/com/google/gerrit/server/account/AbstractRealm.java b/java/com/google/gerrit/server/account/AbstractRealm.java
index 380001d..d229600 100644
--- a/java/com/google/gerrit/server/account/AbstractRealm.java
+++ b/java/com/google/gerrit/server/account/AbstractRealm.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.Inject;
-import java.util.Collection;
 import java.util.HashSet;
 import java.util.Set;
 
@@ -63,7 +63,7 @@
 
   @Override
   public Set<String> getEmailAddresses(IdentifiedUser user) {
-    Collection<ExternalId> ids = user.state().externalIds();
+    ImmutableSet<ExternalId> ids = user.state().externalIds();
     Set<String> emails = Sets.newHashSetWithExpectedSize(ids.size());
     for (ExternalId ext : ids) {
       if (!Strings.isNullOrEmpty(ext.email())) {
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index d7d4938..d269b71 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.account.AccountCacheImpl.AccountCacheModule.ACCOUNT_CACHE_MODULE;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
@@ -37,6 +38,7 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -51,8 +53,17 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
-/** Caches important (but small) account state to avoid database hits. */
-@Singleton
+/**
+ * Caches important (but small) account state to avoid database hits.
+ *
+ * <p>This class should be bounded as a Singleton. However, due to internal limitations in Google,
+ * it cannot be marked as a singleton. The common installation pattern should therefore be:
+ *
+ * <pre>{@code
+ * install(AccountCacheImpl.module());
+ * install(AccountCacheImpl.bindingModule());
+ * }</pre>
+ */
 public class AccountCacheImpl implements AccountCache {
   @ModuleImpl(name = ACCOUNT_CACHE_MODULE)
   public static class AccountCacheModule extends CacheModule {
@@ -65,9 +76,14 @@
           .keySerializer(CachedAccountDetails.Key.Serializer.INSTANCE)
           .valueSerializer(CachedAccountDetails.Serializer.INSTANCE)
           .loader(Loader.class);
+    }
+  }
 
-      bind(AccountCacheImpl.class);
-      bind(AccountCache.class).to(AccountCacheImpl.class);
+  public static class AccountCacheBindingModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(AccountCacheImpl.class).in(SINGLETON);
+      bind(AccountCache.class).to(AccountCacheImpl.class).in(SINGLETON);
     }
   }
 
@@ -79,6 +95,10 @@
     return new AccountCacheModule();
   }
 
+  public static Module bindingModule() {
+    return new AccountCacheBindingModule();
+  }
+
   private final ExternalIds externalIds;
   private final LoadingCache<CachedAccountDetails.Key, CachedAccountDetails> accountDetailsCache;
   private final GitRepositoryManager repoManager;
@@ -127,7 +147,9 @@
 
   @Override
   public Map<Account.Id, AccountState> get(Set<Account.Id> accountIds) {
-    try {
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Loading accounts", Metadata.builder().resourceCount(accountIds.size()).build())) {
       try (Repository allUsers = repoManager.openRepository(allUsersName)) {
         Set<CachedAccountDetails.Key> keys =
             Sets.newLinkedHashSetWithExpectedSize(accountIds.size());
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 2b0ba3f..8f611f0 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.RefNames;
@@ -185,6 +186,7 @@
     return loadedAccountProperties.map(AccountProperties::getAccount).get();
   }
 
+  @CanIgnoreReturnValue
   public AccountConfig setAccountDelta(AccountDelta accountDelta) {
     this.accountDelta = Optional.of(accountDelta);
     return this;
@@ -240,6 +242,7 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public RevCommit commit(MetaDataUpdate update) throws IOException {
     RevCommit c = super.commit(update);
     loadedAccountProperties.get().setMetaId(c);
diff --git a/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
index d97563a..a037046 100644
--- a/java/com/google/gerrit/server/account/AccountLimits.java
+++ b/java/com/google/gerrit/server/account/AccountLimits.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.PermissionRange;
@@ -133,7 +134,7 @@
   }
 
   private List<PermissionRule> getRules(String permissionName) {
-    List<PermissionRule> rules = capabilities.getPermission(permissionName);
+    ImmutableList<PermissionRule> rules = capabilities.getPermission(permissionName);
     GroupMembership groups = user.getEffectiveGroups();
 
     List<PermissionRule> mine = new ArrayList<>(rules.size());
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 18260a4..61012d7 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -26,6 +26,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
@@ -58,7 +59,6 @@
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -139,6 +139,7 @@
    *     cannot be located, is unable to be activated or deactivated, or is inactive, or cannot be
    *     added to the admin group (only for the first account).
    */
+  @CanIgnoreReturnValue
   public AuthResult authenticate(AuthRequest who) throws AccountException, IOException {
     try {
       who = realm.authenticate(who);
@@ -364,7 +365,8 @@
               + e.getDuplicateKey().get()
               + "\" to account "
               + newId
-              + "; external ID already in use.");
+              + "; external ID already in use.",
+          e);
     } finally {
       // If adding the account failed, it may be that it actually was the
       // first account. So we reset the 'check for first account'-guard, as
@@ -419,7 +421,7 @@
       return;
     }
 
-    Set<ExternalId> existingExtIdsWithEmail = externalIds.byEmail(email);
+    ImmutableSet<ExternalId> existingExtIdsWithEmail = externalIds.byEmail(email);
     if (existingExtIdsWithEmail.isEmpty()) {
       return;
     }
@@ -463,6 +465,7 @@
    * @throws AccountException the identity belongs to a different account, or it cannot be linked at
    *     this time.
    */
+  @CanIgnoreReturnValue
   public AuthResult link(Account.Id to, AuthRequest who)
       throws AccountException, IOException, ConfigInvalidException {
     Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
@@ -504,6 +507,7 @@
    * @throws AccountException the identity belongs to a different account, or it cannot be linked at
    *     this time.
    */
+  @CanIgnoreReturnValue
   public AuthResult updateLink(Account.Id to, AuthRequest who)
       throws AccountException, IOException, ConfigInvalidException {
     Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
@@ -518,7 +522,7 @@
             "Update External IDs on Update Link",
             to,
             (a, u) -> {
-              Set<ExternalId> filteredExtIdsByScheme =
+              ImmutableSet<ExternalId> filteredExtIdsByScheme =
                   a.externalIds().stream()
                       .filter(e -> e.key().isScheme(who.getExternalIdKey().scheme()))
                       .collect(toImmutableSet());
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java
index 5f56aa3..9cc20d4 100644
--- a/java/com/google/gerrit/server/account/AccountProperties.java
+++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -97,6 +97,7 @@
 
     accountBuilder.setStatus(get(accountConfig, KEY_STATUS));
     accountBuilder.setMetaId(metaId != null ? metaId.name() : null);
+    accountBuilder.setUniqueTag(accountBuilder.metaId());
     account = accountBuilder.build();
   }
 
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 65e9d9d..7aa25b6 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -396,7 +396,7 @@
       // TODO(dborowitz): This would probably work as a Searcher<Address>
       int lt = nameOrEmail.indexOf('<');
       int gt = nameOrEmail.indexOf('>');
-      Set<Account.Id> ids = emails.getAccountFor(nameOrEmail.substring(lt + 1, gt));
+      ImmutableSet<Account.Id> ids = emails.getAccountFor(nameOrEmail.substring(lt + 1, gt));
       ImmutableList<AccountState> allMatches = toAccountStates(ids).collect(toImmutableList());
       if (allMatches.isEmpty() || allMatches.size() == 1) {
         return allMatches.stream();
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index 8f2d66d..feea4ba 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -30,6 +30,7 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Optional;
+import java.util.StringJoiner;
 
 /**
  * Superset of all information related to an Account. This includes external IDs, project watches,
@@ -142,6 +143,21 @@
     return h.toString();
   }
 
+  public final String debugString() {
+    // Most of the fields might have a large representation. Using a multiline format to ease the
+    // reading.
+    return "AccountState[\n\t"
+        + new StringJoiner(",\n\t")
+            .add("account: " + account().debugString())
+            .add("externalIds: " + externalIds())
+            .add("userName: " + userName())
+            .add("projectWatches: " + projectWatches())
+            .add("generalPreferences: " + generalPreferences())
+            .add("diffPreferences: " + diffPreferences())
+            .add("editPreferences: " + editPreferences())
+        + "\n]";
+  }
+
   /** Gerrit's default preferences as stored in {@code preferences.config}. */
   public abstract Optional<CachedPreferences> defaultPreferences();
 
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 24e8ba5..5951a73 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -144,6 +144,7 @@
    * instead, i.e. the update does not depend on the current account state (which, for insertion,
    * would only contain the account ID).
    */
+  @CanIgnoreReturnValue
   public AccountState insert(
       String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
       throws IOException, ConfigInvalidException {
@@ -210,6 +211,7 @@
    * the error. Callers should be aware that a single "update of death" (or a set of updates that
    * together have this property) will always prevent the entire batch from being executed.
    */
+  @CanIgnoreReturnValue
   public ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
       throws IOException, ConfigInvalidException {
     checkArgument(
diff --git a/java/com/google/gerrit/server/account/CachedAccountDetails.java b/java/com/google/gerrit/server/account/CachedAccountDetails.java
index e167a23..e6e2735 100644
--- a/java/com/google/gerrit/server/account/CachedAccountDetails.java
+++ b/java/com/google/gerrit/server/account/CachedAccountDetails.java
@@ -114,7 +114,8 @@
               .setDisplayName(Strings.nullToEmpty(account.displayName()))
               .setPreferredEmail(Strings.nullToEmpty(account.preferredEmail()))
               .setStatus(Strings.nullToEmpty(account.status()))
-              .setMetaId(Strings.nullToEmpty(account.metaId()));
+              .setMetaId(Strings.nullToEmpty(account.metaId()))
+              .setUniqueTag(Strings.nullToEmpty(account.uniqueTag()));
       serialized.setAccount(accountProto);
 
       for (Map.Entry<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> watch :
@@ -145,7 +146,7 @@
     public CachedAccountDetails deserialize(byte[] in) {
       Cache.AccountDetailsProto proto =
           Protos.parseUnchecked(Cache.AccountDetailsProto.parser(), in);
-      Account account =
+      Account.Builder builder =
           Account.builder(
                   Account.id(proto.getAccount().getId()),
                   Instant.ofEpochMilli(proto.getAccount().getRegisteredOn()))
@@ -155,7 +156,11 @@
               .setInactive(proto.getAccount().getInactive())
               .setStatus(Strings.emptyToNull(proto.getAccount().getStatus()))
               .setMetaId(Strings.emptyToNull(proto.getAccount().getMetaId()))
-              .build();
+              .setUniqueTag(Strings.emptyToNull(proto.getAccount().getUniqueTag()));
+      if (Strings.isNullOrEmpty(builder.uniqueTag())) {
+        builder.setUniqueTag(builder.metaId());
+      }
+      Account account = builder.build();
 
       ImmutableMap.Builder<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
           projectWatches = ImmutableMap.builder();
diff --git a/java/com/google/gerrit/server/account/DefaultRealm.java b/java/com/google/gerrit/server/account/DefaultRealm.java
index cfffceb..9ac55fb 100644
--- a/java/com/google/gerrit/server/account/DefaultRealm.java
+++ b/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -16,6 +16,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
@@ -26,7 +27,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Set;
 
 @Singleton
 public class DefaultRealm extends AbstractRealm {
@@ -85,7 +85,7 @@
   public Account.Id lookup(String accountName) throws IOException {
     if (emailExpander.canExpand(accountName)) {
       try {
-        Set<Account.Id> c = emails.get().getAccountFor(emailExpander.expand(accountName));
+        ImmutableSet<Account.Id> c = emails.get().getAccountFor(emailExpander.expand(accountName));
         if (1 == c.size()) {
           return c.iterator().next();
         }
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 13385d0..38d95f6 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -30,8 +31,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Arrays;
-import java.util.List;
-import java.util.Set;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /** Class to access accounts by email. */
@@ -90,7 +89,7 @@
         MultimapBuilder.hashKeys(emails.length).hashSetValues(1).build();
     externalIds.byEmails(emails).entries().stream()
         .forEach(e -> result.put(e.getKey(), e.getValue().accountId()));
-    List<String> emailsToBackfill =
+    ImmutableList<String> emailsToBackfill =
         Arrays.stream(emails).filter(e -> !result.containsKey(e)).collect(toImmutableList());
     if (!emailsToBackfill.isEmpty()) {
       retryHelper
@@ -122,7 +121,7 @@
     // If only one account has access to this email address, select it
     // as the identity of the user.
     //
-    Set<Account.Id> a = getAccountFor(u.getEmail());
+    ImmutableSet<Account.Id> a = getAccountFor(u.getEmail());
     if (a.size() == 1) {
       u.setAccount(a.iterator().next());
     }
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index fac2fd5..aed73de 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -55,7 +55,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import org.bouncycastle.util.Strings;
 import org.eclipse.jgit.lib.ObjectId;
@@ -178,7 +177,7 @@
   @Override
   public Map<AccountGroup.UUID, InternalGroup> get(Collection<AccountGroup.UUID> groupUuids) {
     try {
-      Set<String> groupUuidsStringSet =
+      ImmutableSet<String> groupUuidsStringSet =
           groupUuids.stream().map(u -> u.get()).collect(toImmutableSet());
       return byUUID.getAll(groupUuidsStringSet).entrySet().stream()
           .filter(g -> g.getValue().isPresent())
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index fc6087b..914bdd2 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -38,10 +38,9 @@
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
-import com.google.gerrit.server.query.group.InternalGroupQuery;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Module;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
@@ -191,11 +190,11 @@
 
   static class GroupsWithMemberLoader
       extends CacheLoader<Account.Id, ImmutableSet<AccountGroup.UUID>> {
-    private final Provider<InternalGroupQuery> groupQueryProvider;
+    private final RetryHelper retryHelper;
 
     @Inject
-    GroupsWithMemberLoader(Provider<InternalGroupQuery> groupQueryProvider) {
-      this.groupQueryProvider = groupQueryProvider;
+    GroupsWithMemberLoader(RetryHelper retryHelper) {
+      this.retryHelper = retryHelper;
     }
 
     @Override
@@ -203,9 +202,14 @@
       try (TraceTimer timer =
           TraceContext.newTimer(
               "Loading groups with member", Metadata.builder().accountId(memberId.get()).build())) {
-        return groupQueryProvider.get().byMember(memberId).stream()
-            .map(InternalGroup::getGroupUUID)
-            .collect(toImmutableSet());
+        return retryHelper
+            .groupIndexQuery(
+                "loadGroupWithMember",
+                q ->
+                    q.byMember(memberId).stream()
+                        .map(InternalGroup::getGroupUUID)
+                        .collect(toImmutableSet()))
+            .call();
       }
     }
   }
@@ -215,11 +219,11 @@
     // Be conservative with batching: We don't want to exhaust the number of
     // results per page and maximum terms per query. Both are usually 1000+.
     private static final int MAX_BATCH_SIZE = 100;
-    private final Provider<InternalGroupQuery> groupQueryProvider;
+    private final RetryHelper retryHelper;
 
     @Inject
-    ParentGroupsLoader(Provider<InternalGroupQuery> groupQueryProvider) {
-      this.groupQueryProvider = groupQueryProvider;
+    ParentGroupsLoader(RetryHelper retryHelper) {
+      this.retryHelper = retryHelper;
     }
 
     @Override
@@ -238,10 +242,12 @@
       Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> result =
           Maps.newHashMapWithExpectedSize(numKeys);
       try (TraceTimer timer = TraceContext.newTimer("Loading " + numKeys + " parent groups")) {
+        Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> bySubgroups =
+            retryHelper
+                .groupIndexQuery("loadParentGroups", q -> q.bySubgroups(ImmutableSet.copyOf(keys)))
+                .call();
         Iterables.partition(keys, MAX_BATCH_SIZE)
-            .forEach(
-                keyPartition ->
-                    result.putAll(groupQueryProvider.get().bySubgroups(ImmutableSet.copyOf(keys))));
+            .forEach(keyPartition -> result.putAll(bySubgroups));
         return result;
       }
     }
diff --git a/java/com/google/gerrit/server/account/GroupMembers.java b/java/com/google/gerrit/server/account/GroupMembers.java
index 3ed82a1..42f07f1 100644
--- a/java/com/google/gerrit/server/account/GroupMembers.java
+++ b/java/com/google/gerrit/server/account/GroupMembers.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
@@ -123,7 +124,7 @@
     seen.add(group.getGroupUUID());
     GroupControl groupControl = groupControlFactory.controlFor(new InternalGroupDescription(group));
 
-    Set<Account> directMembers =
+    ImmutableSet<Account> directMembers =
         group.getMembers().stream()
             .filter(groupControl::canSeeMember)
             .map(accountCache::get)
diff --git a/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
index e1edf10..881a068 100644
--- a/java/com/google/gerrit/server/account/IncludingGroupMembership.java
+++ b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
@@ -45,7 +45,7 @@
   private final GroupIncludeCache includeCache;
   private final CurrentUser user;
   private final Map<AccountGroup.UUID, Boolean> memberOf;
-  private Set<AccountGroup.UUID> knownGroups;
+  private ImmutableSet<AccountGroup.UUID> knownGroups;
 
   @Inject
   IncludingGroupMembership(
diff --git a/java/com/google/gerrit/server/account/ListGroupMembership.java b/java/com/google/gerrit/server/account/ListGroupMembership.java
index 0f4fb78..67e168e 100644
--- a/java/com/google/gerrit/server/account/ListGroupMembership.java
+++ b/java/com/google/gerrit/server/account/ListGroupMembership.java
@@ -21,7 +21,7 @@
 
 /** GroupMembership over an explicit list. */
 public class ListGroupMembership implements GroupMembership {
-  private final Set<AccountGroup.UUID> groups;
+  private final ImmutableSet<AccountGroup.UUID> groups;
 
   public ListGroupMembership(Iterable<AccountGroup.UUID> groupIds) {
     this.groups = ImmutableSet.copyOf(groupIds);
diff --git a/java/com/google/gerrit/server/account/SetInactiveFlag.java b/java/com/google/gerrit/server/account/SetInactiveFlag.java
index 5babebd..4da4c5b 100644
--- a/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -51,6 +52,7 @@
     this.accountActivationListeners = accountActivationListeners;
   }
 
+  @CanIgnoreReturnValue
   public Response<?> deactivate(Account.Id accountId)
       throws RestApiException, IOException, ConfigInvalidException {
     AtomicBoolean alreadyInactive = new AtomicBoolean(false);
@@ -94,6 +96,7 @@
     return Response.none();
   }
 
+  @CanIgnoreReturnValue
   public Response<String> activate(Account.Id accountId)
       throws RestApiException, IOException, ConfigInvalidException {
     AtomicBoolean alreadyActive = new AtomicBoolean(false);
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 476ca79..38b79f0 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -163,7 +163,7 @@
   }
 
   private class UniversalGroupMembership implements GroupMembership {
-    private final Map<GroupBackend, GroupMembership> memberships;
+    private final ImmutableMap<GroupBackend, GroupMembership> memberships;
 
     private UniversalGroupMembership(CurrentUser user) {
       ImmutableMap.Builder<GroupBackend, GroupMembership> builder = ImmutableMap.builder();
diff --git a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
index 41a02a9..1de138f 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
@@ -51,6 +51,7 @@
     if (revision == null) {
       return;
     }
+    logger.atFine().log("Loading named destinations from ref %s", ref);
     String prefix = DestinationList.DIR_NAME + "/";
     for (PathInfo p : getPathInfos(true)) {
       if (p.fileMode == FileMode.REGULAR_FILE) {
diff --git a/java/com/google/gerrit/server/account/VersionedAccountQueries.java b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
index 0269ccf..6d89cfa 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountQueries.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
@@ -68,6 +68,7 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
+    logger.atFine().log("Loading named queries from ref %s", ref);
     queryList =
         QueryList.parse(
             readUTF8(QueryList.FILE_NAME),
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 1fce3d5..6119938 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -20,6 +20,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Ordering;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
@@ -96,6 +97,7 @@
       return read(accountId).getKey(seq);
     }
 
+    @CanIgnoreReturnValue
     public synchronized AccountSshKey addKey(Account.Id accountId, String pub)
         throws IOException, ConfigInvalidException, InvalidSshKeyException {
       VersionedAuthorizedKeys authorizedKeys = read(accountId);
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
index d226565..00a7f6c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
@@ -127,4 +127,7 @@
    * @return the created external ID
    */
   ExternalId createEmail(Account.Id accountId, String email);
+
+  /** Whether this {@link ExternalIdFactory} supports passwords. */
+  boolean arePasswordsAllowed();
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/account/externalids/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/account/externalids/package-info.java
index 0709b86..daf6920 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/account/externalids/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.account.externalids;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
index 3462c76..d3de715 100644
--- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
@@ -223,6 +223,11 @@
         blobId);
   }
 
+  @Override
+  public boolean arePasswordsAllowed() {
+    return true;
+  }
+
   private static int readAccountId(String noteId, Config externalIdConfig, String externalIdKeyStr)
       throws ConfigInvalidException {
     String accountIdStr =
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java
index 5346252..6137884 100644
--- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java
@@ -41,14 +41,11 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
-import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.logging.CallerFinder;
-import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -157,8 +154,8 @@
      * @param externalIdNotes the committed updates that should be applied to the cache. This first
      *     and last element must be the updates commited first and last, respectively.
      * @param accountsToSkipForReindex accounts that should not be reindexed. This is to avoid
-     *     double reindexing when updated accounts will already be reindexed by
-     *     ReindexAfterRefUpdate.
+     *     double reindexing when updated accounts will already be reindexed by {@link
+     *     com.google.gerrit.server.index.account.ReindexAccountsAfterRefUpdate}.
      */
     public void updateExternalIdCacheAndMaybeReindexAccounts(
         ExternalIdNotes externalIdNotes, Collection<Account.Id> accountsToSkipForReindex)
@@ -361,7 +358,6 @@
   private final Counter0 updateCount;
   private final Repository repo;
   private final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors;
-  private final CallerFinder callerFinder;
   private final ExternalIdFactoryNoteDbImpl externalIdFactory;
 
   private NoteMap noteMap;
@@ -415,19 +411,6 @@
     this.allUsersName = requireNonNull(allUsersName, "allUsersRepo");
     this.repo = requireNonNull(allUsersRepo, "allUsersRepo");
     this.upsertPreprocessors = upsertPreprocessors;
-    this.callerFinder =
-        CallerFinder.builder()
-            // 1. callers that come through ExternalIds
-            .addTarget(ExternalIds.class)
-
-            // 2. callers that come through AccountsUpdate
-            .addTarget(AccountsUpdate.class)
-            .addIgnoredPackage("com.github.rholder.retry")
-            .addIgnoredClass(RetryHelper.class)
-
-            // 3. direct callers
-            .addTarget(ExternalIdNotes.class)
-            .build();
     this.externalIdFactory = externalIdFactory;
     this.isUserNameCaseInsensitiveMigrationMode = isUserNameCaseInsensitiveMigrationMode;
   }
@@ -852,8 +835,6 @@
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     if (revision != null) {
-      logger.atFine().log(
-          "Reading external ID note map (caller: %s)", callerFinder.findCallerLazy());
       noteMap = NoteMap.read(reader, revision);
     } else {
       noteMap = NoteMap.newEmptyMap();
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsConsistencyCheckerNoteDbImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsConsistencyCheckerNoteDbImpl.java
index 83c72f1..db6fdce 100644
--- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsConsistencyCheckerNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsConsistencyCheckerNoteDbImpl.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.account.externalids.storage.notedb;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static java.util.stream.Collectors.joining;
 
@@ -24,7 +23,6 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.HashedPassword;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -53,14 +51,11 @@
       GitRepositoryManager repoManager,
       AllUsersName allUsers,
       OutgoingEmailValidator validator,
-      ExternalIdFactory externalIdFactory) {
+      ExternalIdFactoryNoteDbImpl externalIdFactory) {
     this.repoManager = repoManager;
     this.allUsers = allUsers;
     this.validator = validator;
-    checkState(
-        externalIdFactory instanceof ExternalIdFactoryNoteDbImpl,
-        "ExternalIdsConsistencyCheckerNoteDbImpl must be initiated with ExternalIdFactoryNoteDbImpl.");
-    this.externalIdFactory = (ExternalIdFactoryNoteDbImpl) externalIdFactory;
+    this.externalIdFactory = externalIdFactory;
   }
 
   @Override
@@ -148,7 +143,8 @@
 
     if (extId.password() != null && extId.isScheme(SCHEME_USERNAME)) {
       try {
-        HashedPassword.decode(extId.password());
+        @SuppressWarnings("unused")
+        var unused = HashedPassword.decode(extId.password());
       } catch (HashedPassword.DecoderException e) {
         addError(
             String.format(
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/OnlineExternalIdCaseSensivityMigrator.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/OnlineExternalIdCaseSensivityMigrator.java
index ab56c94..eaded12 100644
--- a/java/com/google/gerrit/server/account/externalids/storage/notedb/OnlineExternalIdCaseSensivityMigrator.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/OnlineExternalIdCaseSensivityMigrator.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account.externalids.storage.notedb;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
@@ -26,7 +27,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.Set;
 import java.util.concurrent.Executor;
 import java.util.concurrent.ExecutorService;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -81,7 +81,7 @@
     executor.execute(
         () -> {
           try {
-            Set<ExternalId> todo = externalIds.all();
+            ImmutableSet<ExternalId> todo = externalIds.all();
             try {
               monitor.beginTask("Converting external ID note names", todo.size());
               migratorFactory
@@ -93,7 +93,9 @@
             try {
               updateGerritConfig();
               monitor.beginTask("Reindex accounts", ProgressMonitor.UNKNOWN);
-              versionManager.startReindexer("accounts", true);
+
+              @SuppressWarnings("unused")
+              var unused = versionManager.startReindexer("accounts", true);
             } finally {
               monitor.endTask();
             }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/account/externalids/storage/notedb/package-info.java
index 0709b86..e3a6d9e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/account/externalids/testing/BUILD b/java/com/google/gerrit/server/account/externalids/testing/BUILD
index e2de6da..7202ca0 100644
--- a/java/com/google/gerrit/server/account/externalids/testing/BUILD
+++ b/java/com/google/gerrit/server/account/externalids/testing/BUILD
@@ -10,5 +10,6 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:test-ref-update-context",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
index dc2fd3c..eea78e5 100644
--- a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
+++ b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
@@ -19,6 +19,7 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -38,6 +39,7 @@
 /** Common methods for dealing with external IDs in tests. */
 public class ExternalIdTestUtil {
 
+  @CanIgnoreReturnValue
   public static String insertExternalIdWithoutAccountId(
       Repository repo, RevWalk rw, PersonIdent ident, Account.Id accountId, String externalId)
       throws IOException {
@@ -60,6 +62,7 @@
         });
   }
 
+  @CanIgnoreReturnValue
   public static String insertExternalIdWithKeyThatDoesntMatchNoteId(
       Repository repo, RevWalk rw, PersonIdent ident, Account.Id accountId, String externalId)
       throws IOException {
@@ -81,6 +84,7 @@
         });
   }
 
+  @CanIgnoreReturnValue
   public static String insertExternalIdWithInvalidConfig(
       Repository repo, RevWalk rw, PersonIdent ident, String externalId) throws IOException {
     return insertExternalId(
@@ -96,6 +100,7 @@
         });
   }
 
+  @CanIgnoreReturnValue
   public static String insertExternalIdWithEmptyNote(
       Repository repo, RevWalk rw, PersonIdent ident, String externalId) throws IOException {
     return insertExternalId(
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/account/externalids/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/account/externalids/testing/package-info.java
index 0709b86..a752fea 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/account/externalids/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.account.externalids.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/account/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/account/package-info.java
index 0709b86..786f0b0 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/account/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.account;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java
index 9253133..c861bc0 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java
@@ -16,6 +16,8 @@
 
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbReadStorageModule;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.MessageIdGeneratorImpl;
 import com.google.inject.AbstractModule;
 import com.google.inject.Singleton;
 
@@ -25,5 +27,6 @@
     install(new ExternalIdNoteDbReadStorageModule());
 
     bind(Accounts.class).to(AccountsNoteDbImpl.class).in(Singleton.class);
+    bind(MessageIdGenerator.class).to(MessageIdGeneratorImpl.class);
   }
 }
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java
index 24eabb1..6987de5 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.server.account.storage.notedb;
 
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbWriteStorageModule;
+import com.google.gerrit.server.index.account.ReindexAccountsAfterRefUpdate;
 import com.google.inject.AbstractModule;
 
 /** Module that binds {@link AccountsUpdate} */
@@ -29,5 +32,7 @@
     bind(AccountsUpdate.AccountsUpdateLoader.class)
         .annotatedWith(AccountsUpdate.AccountsUpdateLoader.NoReindex.class)
         .to(AccountsUpdateNoteDbImpl.FactoryNoReindex.class);
+    DynamicSet.bind(binder(), GitBatchRefUpdateListener.class)
+        .to(ReindexAccountsAfterRefUpdate.class);
   }
 }
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
index 265d036..ad3681d 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
@@ -49,7 +49,7 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
+import com.google.gerrit.server.index.account.ReindexAccountsAfterRefUpdate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.RetryableAction.Action;
 import com.google.gerrit.server.update.context.RefUpdateContext;
@@ -102,21 +102,24 @@
  * branch (see {@link ExternalIdNotes}).
  *
  * <p>On updating an account the account is evicted from the account cache and reindexed. The
- * eviction from the account cache and the reindexing is done by the {@link ReindexAfterRefUpdate}
- * class which receives the event about updating the user branch that is triggered by this class.
+ * eviction from the account cache and the reindexing is done by the {@link
+ * ReindexAccountsAfterRefUpdate} class which receives the event about updating the user branch that
+ * is triggered by this class.
  *
  * <p>If external IDs are updated, the ExternalIdCache is automatically updated by {@link
  * ExternalIdNotes}. In addition {@link ExternalIdNotes} takes care about evicting and reindexing
  * corresponding accounts. This is needed because external ID updates don't touch the user branches.
- * Hence in this case the accounts are not evicted and reindexed via {@link ReindexAfterRefUpdate}.
+ * Hence, in this case the accounts are not evicted and reindexed via {@link
+ * ReindexAccountsAfterRefUpdate}.
  *
- * <p>Reindexing and flushing accounts from the account cache can be disabled by
+ * <p>Reindexing and flushing accounts from the account cache can be disabled by-
  *
  * <ul>
  *   <li>using {@link
  *       com.google.gerrit.server.account.storage.notedb.AccountsUpdateNoteDbImpl.FactoryNoReindex}
  *       and
- *   <li>binding {@link GitReferenceUpdated#DISABLED}
+ *   <li>binding {@link GitReferenceUpdated#DISABLED}, or avoid binding {@link
+ *       ReindexAccountsAfterRefUpdate}.
  * </ul>
  *
  * <p>If there are concurrent account updates which updating the user branch in NoteDb may fail with
@@ -156,7 +159,7 @@
     }
 
     @Override
-    public AccountsUpdate create(IdentifiedUser currentUser) {
+    public AccountsUpdateNoteDbImpl create(IdentifiedUser currentUser) {
       PersonIdent serverIdent = serverIdentProvider.get();
       return new AccountsUpdateNoteDbImpl(
           repoManager,
@@ -173,7 +176,7 @@
     }
 
     @Override
-    public AccountsUpdate createWithServerIdent() {
+    public AccountsUpdateNoteDbImpl createWithServerIdent() {
       PersonIdent serverIdent = serverIdentProvider.get();
       return new AccountsUpdateNoteDbImpl(
           repoManager,
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/account/storage/notedb/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/account/storage/notedb/package-info.java
index 0709b86..9eaa284 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.account.storage.notedb;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/api/BUILD b/java/com/google/gerrit/server/api/BUILD
index 0275c79..d21ba45 100644
--- a/java/com/google/gerrit/server/api/BUILD
+++ b/java/com/google/gerrit/server/api/BUILD
@@ -19,6 +19,7 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:servlet-api",
+        "//lib/errorprone:annotations",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
     ],
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 400521b..55aa5d7 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -257,9 +257,11 @@
   public void setActive(boolean active) throws RestApiException {
     try {
       if (active) {
-        putActive.apply(account, new Input());
+        @SuppressWarnings("unused")
+        var unused = putActive.apply(account, new Input());
       } else {
-        deleteActive.apply(account, new Input());
+        @SuppressWarnings("unused")
+        var unused = deleteActive.apply(account, new Input());
       }
     } catch (Exception e) {
       throw asRestApiException("Cannot set active", e);
@@ -348,7 +350,8 @@
   @Override
   public void deleteWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException {
     try {
-      deleteWatchedProjects.apply(account, in);
+      @SuppressWarnings("unused")
+      var unused = deleteWatchedProjects.apply(account, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot delete watched projects", e);
     }
@@ -357,7 +360,8 @@
   @Override
   public void starChange(String changeId) throws RestApiException {
     try {
-      starredChangesCreate.apply(account, IdString.fromUrl(changeId), new Input());
+      @SuppressWarnings("unused")
+      var unused = starredChangesCreate.apply(account, IdString.fromUrl(changeId), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot star change", e);
     }
@@ -369,7 +373,9 @@
       ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
       AccountResource.StarredChange starredChange =
           new AccountResource.StarredChange(account.getUser(), rsrc);
-      starredChangesDelete.apply(starredChange, new Input());
+
+      @SuppressWarnings("unused")
+      var unused = starredChangesDelete.apply(starredChange, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot unstar change", e);
     }
@@ -397,7 +403,8 @@
   public void addEmail(EmailInput input) throws RestApiException {
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
     try {
-      createEmail.apply(rsrc, IdString.fromDecoded(input.email), input);
+      @SuppressWarnings("unused")
+      var unused = createEmail.apply(rsrc, IdString.fromDecoded(input.email), input);
     } catch (Exception e) {
       throw asRestApiException("Cannot add email", e);
     }
@@ -407,7 +414,8 @@
   public void deleteEmail(String email) throws RestApiException {
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), email);
     try {
-      deleteEmail.apply(rsrc, null);
+      @SuppressWarnings("unused")
+      var unused = deleteEmail.apply(rsrc, null);
     } catch (Exception e) {
       throw asRestApiException("Cannot delete email", e);
     }
@@ -417,7 +425,8 @@
   public EmailApi createEmail(EmailInput input) throws RestApiException {
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
     try {
-      createEmail.apply(rsrc, IdString.fromDecoded(input.email), input);
+      @SuppressWarnings("unused")
+      var unused = createEmail.apply(rsrc, IdString.fromDecoded(input.email), input);
       return email(rsrc.getEmail());
     } catch (Exception e) {
       throw asRestApiException("Cannot create email", e);
@@ -437,7 +446,8 @@
   public void setStatus(String status) throws RestApiException {
     StatusInput in = new StatusInput(status);
     try {
-      putStatus.apply(account, in);
+      @SuppressWarnings("unused")
+      var unused = putStatus.apply(account, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot set status", e);
     }
@@ -447,7 +457,8 @@
   public void setDisplayName(String displayName) throws RestApiException {
     DisplayNameInput in = new DisplayNameInput(displayName);
     try {
-      putDisplayName.apply(account, in);
+      @SuppressWarnings("unused")
+      var unused = putDisplayName.apply(account, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot set display name", e);
     }
@@ -478,7 +489,9 @@
     try {
       AccountResource.SshKey sshKeyRes =
           sshKeys.parse(account, IdString.fromDecoded(Integer.toString(seq)));
-      deleteSshKey.apply(sshKeyRes, null);
+
+      @SuppressWarnings("unused")
+      var unused = deleteSshKey.apply(sshKeyRes, null);
     } catch (Exception e) {
       throw asRestApiException("Cannot delete SSH key", e);
     }
@@ -526,7 +539,9 @@
     try {
       AgreementInput input = new AgreementInput();
       input.name = agreementName;
-      putAgreement.apply(account, input);
+
+      @SuppressWarnings("unused")
+      var unused = putAgreement.apply(account, input);
     } catch (Exception e) {
       throw asRestApiException("Cannot sign agreement", e);
     }
@@ -535,7 +550,8 @@
   @Override
   public void index() throws RestApiException {
     try {
-      index.apply(account, new Input());
+      @SuppressWarnings("unused")
+      var unused = index.apply(account, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot index account", e);
     }
@@ -553,7 +569,8 @@
   @Override
   public void deleteExternalIds(List<String> externalIds) throws RestApiException {
     try {
-      deleteExternalIds.apply(account, externalIds);
+      @SuppressWarnings("unused")
+      var unused = deleteExternalIds.apply(account, externalIds);
     } catch (Exception e) {
       throw asRestApiException("Cannot delete external IDs", e);
     }
@@ -574,7 +591,8 @@
     NameInput input = new NameInput();
     input.name = name;
     try {
-      putName.apply(account, input);
+      @SuppressWarnings("unused")
+      var unused = putName.apply(account, input);
     } catch (Exception e) {
       throw asRestApiException("Cannot set account name", e);
     }
diff --git a/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java b/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java
index f68142f..06a74f4 100644
--- a/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/EmailApiImpl.java
@@ -70,7 +70,8 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      delete.apply(resource(), new Input());
+      @SuppressWarnings("unused")
+      var unused = delete.apply(resource(), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot delete email", e);
     }
@@ -79,7 +80,8 @@
   @Override
   public void setPreferred() throws RestApiException {
     try {
-      putPreferred.apply(resource(), new Input());
+      @SuppressWarnings("unused")
+      var unused = putPreferred.apply(resource(), new Input());
     } catch (Exception e) {
       throw asRestApiException(String.format("Cannot set %s as preferred email", email), e);
     }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/api/accounts/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/api/accounts/package-info.java
index 0709b86..9edd8d7 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/api/accounts/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.api.accounts;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java b/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
index 6c79296..534bffb 100644
--- a/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
@@ -43,7 +43,8 @@
   @Override
   public void remove(AttentionSetInput input) throws RestApiException {
     try {
-      removeFromAttentionSet.apply(attentionSetEntryResource, input);
+      @SuppressWarnings("unused")
+      var unused = removeFromAttentionSet.apply(attentionSetEntryResource, input);
     } catch (Exception e) {
       throw asRestApiException("Cannot remove from attention set", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 62650d2..a3df786 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.extensions.common.ChangeInfoDifference;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.CommitMessageInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.InputWithMessage;
@@ -84,6 +85,7 @@
 import com.google.gerrit.server.restapi.change.GetChange;
 import com.google.gerrit.server.restapi.change.GetCustomKeyedValues;
 import com.google.gerrit.server.restapi.change.GetHashtags;
+import com.google.gerrit.server.restapi.change.GetMessage;
 import com.google.gerrit.server.restapi.change.GetMetaDiff;
 import com.google.gerrit.server.restapi.change.GetPureRevert;
 import com.google.gerrit.server.restapi.change.GetTopic;
@@ -172,6 +174,7 @@
   private final DeletePrivate deletePrivate;
   private final SetWorkInProgress setWip;
   private final SetReadyForReview setReady;
+  private final GetMessage getMessage;
   private final PutMessage putMessage;
   private final Provider<GetPureRevert> getPureRevertProvider;
   private final DynamicOptionParser dynamicOptionParser;
@@ -224,6 +227,7 @@
       DeletePrivate deletePrivate,
       SetWorkInProgress setWip,
       SetReadyForReview setReady,
+      GetMessage getMessage,
       PutMessage putMessage,
       Provider<GetPureRevert> getPureRevertProvider,
       DynamicOptionParser dynamicOptionParser,
@@ -274,6 +278,7 @@
     this.deletePrivate = deletePrivate;
     this.setWip = setWip;
     this.setReady = setReady;
+    this.getMessage = getMessage;
     this.putMessage = putMessage;
     this.getPureRevertProvider = getPureRevertProvider;
     this.dynamicOptionParser = dynamicOptionParser;
@@ -308,7 +313,8 @@
   @Override
   public void abandon(AbandonInput in) throws RestApiException {
     try {
-      abandon.apply(change, in);
+      @SuppressWarnings("unused")
+      var unused = abandon.apply(change, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot abandon change", e);
     }
@@ -317,7 +323,8 @@
   @Override
   public void restore(RestoreInput in) throws RestApiException {
     try {
-      restore.apply(change, in);
+      @SuppressWarnings("unused")
+      var unused = restore.apply(change, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot restore change", e);
     }
@@ -326,7 +333,8 @@
   @Override
   public void move(MoveInput in) throws RestApiException {
     try {
-      move.apply(change, in);
+      @SuppressWarnings("unused")
+      var unused = move.apply(change, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot move change", e);
     }
@@ -337,9 +345,11 @@
     try {
       InputWithMessage input = new InputWithMessage(message);
       if (value) {
-        postPrivate.apply(change, input);
+        @SuppressWarnings("unused")
+        var unused = postPrivate.apply(change, input);
       } else {
-        deletePrivate.apply(change, input);
+        @SuppressWarnings("unused")
+        var unused = deletePrivate.apply(change, input);
       }
     } catch (Exception e) {
       throw asRestApiException("Cannot change private status", e);
@@ -349,7 +359,8 @@
   @Override
   public void setWorkInProgress(@Nullable String message) throws RestApiException {
     try {
-      setWip.apply(change, new WorkInProgressOp.Input(message));
+      @SuppressWarnings("unused")
+      var unused = setWip.apply(change, new WorkInProgressOp.Input(message));
     } catch (Exception e) {
       throw asRestApiException("Cannot set work in progress state", e);
     }
@@ -358,7 +369,8 @@
   @Override
   public void setReadyForReview(@Nullable String message) throws RestApiException {
     try {
-      setReady.apply(change, new WorkInProgressOp.Input(message));
+      @SuppressWarnings("unused")
+      var unused = setReady.apply(change, new WorkInProgressOp.Input(message));
     } catch (Exception e) {
       throw asRestApiException("Cannot set ready for review state", e);
     }
@@ -418,7 +430,8 @@
   @Override
   public void rebase(RebaseInput in) throws RestApiException {
     try {
-      rebase.apply(change, in);
+      @SuppressWarnings("unused")
+      var unused = rebase.apply(change, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot rebase change", e);
     }
@@ -436,7 +449,8 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteChange.apply(change, null);
+      @SuppressWarnings("unused")
+      var unused = deleteChange.apply(change, null);
     } catch (Exception e) {
       throw asRestApiException("Cannot delete change", e);
     }
@@ -456,7 +470,8 @@
     TopicInput in = new TopicInput();
     in.topic = topic;
     try {
-      putTopic.apply(change, in);
+      @SuppressWarnings("unused")
+      var unused = putTopic.apply(change, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot set topic", e);
     }
@@ -551,9 +566,19 @@
   }
 
   @Override
+  public CommitMessageInfo getMessage() throws RestApiException {
+    try {
+      return getMessage.apply(change).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get message", e);
+    }
+  }
+
+  @Override
   public void setMessage(CommitMessageInput in) throws RestApiException {
     try {
-      putMessage.apply(change, in);
+      @SuppressWarnings("unused")
+      var unused = putMessage.apply(change, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot edit commit message", e);
     }
@@ -562,7 +587,8 @@
   @Override
   public void setHashtags(HashtagsInput input) throws RestApiException {
     try {
-      postHashtags.apply(change, input);
+      @SuppressWarnings("unused")
+      var unused = postHashtags.apply(change, input);
     } catch (Exception e) {
       throw asRestApiException("Cannot post hashtags", e);
     }
@@ -731,7 +757,8 @@
   @Override
   public void index() throws RestApiException {
     try {
-      index.apply(change, new Input());
+      @SuppressWarnings("unused")
+      var unused = index.apply(change, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot index change", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
index 1b0f0c5..c76eeeb 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
+import com.google.gerrit.extensions.api.changes.ChangeEditIdentityType;
 import com.google.gerrit.extensions.api.changes.FileContentInput;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.client.ChangeEditDetailOption;
@@ -55,6 +56,7 @@
   private final ChangeEdits.DeleteContent changeEditDeleteContent;
   private final Provider<ChangeEdits.GetMessage> getChangeEditCommitMessageProvider;
   private final ChangeEdits.EditMessage modifyChangeEditCommitMessage;
+  private final ChangeEdits.EditIdentity modifyIdentity;
   private final ChangeEdits changeEdits;
   private final ChangeResource changeResource;
 
@@ -70,6 +72,7 @@
       ChangeEdits.DeleteContent changeEditDeleteContent,
       Provider<ChangeEdits.GetMessage> getChangeEditCommitMessageProvider,
       ChangeEdits.EditMessage modifyChangeEditCommitMessage,
+      ChangeEdits.EditIdentity modifyIdentity,
       ChangeEdits changeEdits,
       @Assisted ChangeResource changeResource) {
     this.editDetailProvider = editDetailProvider;
@@ -82,6 +85,7 @@
     this.changeEditDeleteContent = changeEditDeleteContent;
     this.getChangeEditCommitMessageProvider = getChangeEditCommitMessageProvider;
     this.modifyChangeEditCommitMessage = modifyChangeEditCommitMessage;
+    this.modifyIdentity = modifyIdentity;
     this.changeEdits = changeEdits;
     this.changeResource = changeResource;
   }
@@ -127,7 +131,8 @@
   @Override
   public void create() throws RestApiException {
     try {
-      changeEditsPost.apply(changeResource, null);
+      @SuppressWarnings("unused")
+      var unused = changeEditsPost.apply(changeResource, null);
     } catch (Exception e) {
       throw asRestApiException("Cannot create change edit", e);
     }
@@ -136,7 +141,8 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteChangeEdit.apply(changeResource, new Input());
+      @SuppressWarnings("unused")
+      var unused = deleteChangeEdit.apply(changeResource, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot delete change edit", e);
     }
@@ -145,7 +151,8 @@
   @Override
   public void rebase() throws RestApiException {
     try {
-      rebaseChangeEdit.apply(changeResource, null);
+      @SuppressWarnings("unused")
+      var unused = rebaseChangeEdit.apply(changeResource, null);
     } catch (Exception e) {
       throw asRestApiException("Cannot rebase change edit", e);
     }
@@ -159,7 +166,8 @@
   @Override
   public void publish(PublishChangeEditInput publishChangeEditInput) throws RestApiException {
     try {
-      publishChangeEdit.apply(changeResource, publishChangeEditInput);
+      @SuppressWarnings("unused")
+      var unused = publishChangeEdit.apply(changeResource, publishChangeEditInput);
     } catch (Exception e) {
       throw asRestApiException("Cannot publish change edit", e);
     }
@@ -182,7 +190,9 @@
       ChangeEdits.Post.Input renameInput = new ChangeEdits.Post.Input();
       renameInput.oldPath = oldFilePath;
       renameInput.newPath = newFilePath;
-      changeEditsPost.apply(changeResource, renameInput);
+
+      @SuppressWarnings("unused")
+      var unused = changeEditsPost.apply(changeResource, renameInput);
     } catch (Exception e) {
       throw asRestApiException("Cannot rename file of change edit", e);
     }
@@ -193,7 +203,9 @@
     try {
       ChangeEdits.Post.Input restoreInput = new ChangeEdits.Post.Input();
       restoreInput.restorePath = filePath;
-      changeEditsPost.apply(changeResource, restoreInput);
+
+      @SuppressWarnings("unused")
+      var unused = changeEditsPost.apply(changeResource, restoreInput);
     } catch (Exception e) {
       throw asRestApiException("Cannot restore file of change edit", e);
     }
@@ -202,7 +214,8 @@
   @Override
   public void modifyFile(String filePath, FileContentInput input) throws RestApiException {
     try {
-      changeEditsPut.apply(changeResource, filePath, input);
+      @SuppressWarnings("unused")
+      var unused = changeEditsPut.apply(changeResource, filePath, input);
     } catch (Exception e) {
       throw asRestApiException("Cannot modify file of change edit", e);
     }
@@ -211,7 +224,8 @@
   @Override
   public void deleteFile(String filePath) throws RestApiException {
     try {
-      changeEditDeleteContent.apply(changeResource, filePath);
+      @SuppressWarnings("unused")
+      var unused = changeEditDeleteContent.apply(changeResource, filePath);
     } catch (Exception e) {
       throw asRestApiException("Cannot delete file of change edit", e);
     }
@@ -234,12 +248,28 @@
     ChangeEdits.EditMessage.Input input = new ChangeEdits.EditMessage.Input();
     input.message = newCommitMessage;
     try {
-      modifyChangeEditCommitMessage.apply(changeResource, input);
+      @SuppressWarnings("unused")
+      var unused = modifyChangeEditCommitMessage.apply(changeResource, input);
     } catch (Exception e) {
       throw asRestApiException("Cannot modify commit message of change edit", e);
     }
   }
 
+  @Override
+  public void modifyIdentity(String name, String email, ChangeEditIdentityType type)
+      throws RestApiException {
+    ChangeEdits.EditIdentity.Input input = new ChangeEdits.EditIdentity.Input();
+    input.name = name;
+    input.email = email;
+    input.type = type;
+    try {
+      @SuppressWarnings("unused")
+      var unused = modifyIdentity.apply(changeResource, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot edit identity of change", e);
+    }
+  }
+
   private ChangeEditResource getChangeEditResource(String filePath)
       throws ResourceNotFoundException, AuthException, IOException {
     return changeEdits.parse(changeResource, IdString.fromDecoded(filePath));
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index 1666820..cf08aee 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -126,7 +126,7 @@
   public QueryRequest query() {
     return new QueryRequest() {
       @Override
-      public List<ChangeInfo> get() throws RestApiException {
+      public ImmutableList<ChangeInfo> get() throws RestApiException {
         return ChangesImpl.this.get(this);
       }
     };
@@ -137,7 +137,7 @@
     return query().withQuery(query);
   }
 
-  private List<ChangeInfo> get(QueryRequest q) throws RestApiException {
+  private ImmutableList<ChangeInfo> get(QueryRequest q) throws RestApiException {
     try (DynamicOptions dynamicOptions = new DynamicOptions(injector, dynamicBeans)) {
       QueryChanges qc = queryProvider.get();
       if (q.getQuery() != null) {
diff --git a/java/com/google/gerrit/server/api/changes/DraftApiImpl.java b/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
index f6eb3c5..67f98ca 100644
--- a/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
@@ -72,7 +72,8 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteDraft.apply(draft, null);
+      @SuppressWarnings("unused")
+      var unused = deleteDraft.apply(draft, null);
     } catch (Exception e) {
       throw asRestApiException("Cannot delete draft", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
index cf9f243..6aa2cf1 100644
--- a/java/com/google/gerrit/server/api/changes/FileApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
@@ -110,9 +110,11 @@
   public void setReviewed(boolean reviewed) throws RestApiException {
     try {
       if (reviewed) {
-        putReviewed.apply(file, new Input());
+        @SuppressWarnings("unused")
+        var unused = putReviewed.apply(file, new Input());
       } else {
-        deleteReviewed.apply(file, new Input());
+        @SuppressWarnings("unused")
+        var unused = deleteReviewed.apply(file, new Input());
       }
     } catch (Exception e) {
       throw asRestApiException(String.format("Cannot set %sreviewed", reviewed ? "" : "un"), e);
diff --git a/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java b/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
index 2174ef0..10973ca 100644
--- a/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
@@ -63,7 +63,8 @@
   @Override
   public void deleteVote(String label) throws RestApiException {
     try {
-      deleteVote.apply(new VoteResource(reviewer, label), null);
+      @SuppressWarnings("unused")
+      var unused = deleteVote.apply(new VoteResource(reviewer, label), null);
     } catch (Exception e) {
       throw asRestApiException("Cannot delete vote", e);
     }
@@ -72,7 +73,8 @@
   @Override
   public void deleteVote(DeleteVoteInput input) throws RestApiException {
     try {
-      deleteVote.apply(new VoteResource(reviewer, input.label), input);
+      @SuppressWarnings("unused")
+      var unused = deleteVote.apply(new VoteResource(reviewer, input.label), input);
     } catch (Exception e) {
       throw asRestApiException("Cannot delete vote", e);
     }
@@ -86,7 +88,8 @@
   @Override
   public void remove(DeleteReviewerInput input) throws RestApiException {
     try {
-      deleteReviewer.apply(reviewer, input);
+      @SuppressWarnings("unused")
+      var unused = deleteReviewer.apply(reviewer, input);
     } catch (Exception e) {
       throw asRestApiException("Cannot remove reviewer", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index a7931f1..38510e3 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -339,7 +339,9 @@
       } else {
         view = deleteReviewed;
       }
-      view.apply(files.parse(revision, IdString.fromDecoded(path)), new Input());
+
+      @SuppressWarnings("unused")
+      var unused = view.apply(files.parse(revision, IdString.fromDecoded(path)), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot update reviewed flag", e);
     }
@@ -691,7 +693,8 @@
     DescriptionInput in = new DescriptionInput();
     in.description = description;
     try {
-      putDescription.apply(revision, in);
+      @SuppressWarnings("unused")
+      var unused = putDescription.apply(revision, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot set description", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
index 49c2d49..fcce2dd 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
@@ -56,7 +56,8 @@
   @Override
   public void deleteVote(String label) throws RestApiException {
     try {
-      deleteVote.apply(new VoteResource(reviewer, label), null);
+      @SuppressWarnings("unused")
+      var unused = deleteVote.apply(new VoteResource(reviewer, label), null);
     } catch (Exception e) {
       throw asRestApiException("Cannot delete vote", e);
     }
@@ -65,7 +66,8 @@
   @Override
   public void deleteVote(DeleteVoteInput input) throws RestApiException {
     try {
-      deleteVote.apply(new VoteResource(reviewer, input.label), input);
+      @SuppressWarnings("unused")
+      var unused = deleteVote.apply(new VoteResource(reviewer, input.label), input);
     } catch (Exception e) {
       throw asRestApiException("Cannot delete vote", e);
     }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/api/changes/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/api/changes/package-info.java
index 0709b86..ccd493c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/api/changes/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.api.changes;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/api/config/ConfigModule.java b/java/com/google/gerrit/server/api/config/ConfigModule.java
index 9340ae1..0eae2ad 100644
--- a/java/com/google/gerrit/server/api/config/ConfigModule.java
+++ b/java/com/google/gerrit/server/api/config/ConfigModule.java
@@ -23,5 +23,7 @@
   protected void configure() {
     bind(Config.class).to(ConfigImpl.class);
     bind(Server.class).to(ServerImpl.class);
+
+    factory(ExperimentApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/api/config/ExperimentApiImpl.java b/java/com/google/gerrit/server/api/config/ExperimentApiImpl.java
new file mode 100644
index 0000000..7eacf20
--- /dev/null
+++ b/java/com/google/gerrit/server/api/config/ExperimentApiImpl.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.config;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.config.ExperimentApi;
+import com.google.gerrit.extensions.common.ExperimentInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.ExperimentResource;
+import com.google.gerrit.server.restapi.config.GetExperiment;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class ExperimentApiImpl implements ExperimentApi {
+  interface Factory {
+    ExperimentApiImpl create(ExperimentResource r);
+  }
+
+  private final ExperimentResource experiment;
+  private final GetExperiment getExperiment;
+
+  @Inject
+  ExperimentApiImpl(GetExperiment getExperiment, @Assisted ExperimentResource r) {
+    this.getExperiment = getExperiment;
+    this.experiment = r;
+  }
+
+  @Override
+  public ExperimentInfo get() throws RestApiException {
+    try {
+      return getExperiment.apply(experiment).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get experiment", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/config/ServerImpl.java b/java/com/google/gerrit/server/api/config/ServerImpl.java
index ab40ec8..3928387 100644
--- a/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -16,22 +16,28 @@
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.api.config.ExperimentApi;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.common.ExperimentInfo;
 import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.restapi.config.CheckConsistency;
+import com.google.gerrit.server.restapi.config.ExperimentsCollection;
 import com.google.gerrit.server.restapi.config.GetDiffPreferences;
 import com.google.gerrit.server.restapi.config.GetEditPreferences;
 import com.google.gerrit.server.restapi.config.GetPreferences;
 import com.google.gerrit.server.restapi.config.GetServerInfo;
+import com.google.gerrit.server.restapi.config.ListExperiments;
 import com.google.gerrit.server.restapi.config.ListTopMenus;
 import com.google.gerrit.server.restapi.config.SetDiffPreferences;
 import com.google.gerrit.server.restapi.config.SetEditPreferences;
@@ -52,6 +58,9 @@
   private final GetServerInfo getServerInfo;
   private final Provider<CheckConsistency> checkConsistency;
   private final ListTopMenus listTopMenus;
+  private final ExperimentApiImpl.Factory experimentApi;
+  private final ExperimentsCollection experimentsCollection;
+  private final Provider<ListExperiments> listExperimentsProvider;
 
   @Inject
   ServerImpl(
@@ -63,7 +72,10 @@
       SetEditPreferences setEditPreferences,
       GetServerInfo getServerInfo,
       Provider<CheckConsistency> checkConsistency,
-      ListTopMenus listTopMenus) {
+      ListTopMenus listTopMenus,
+      ExperimentApiImpl.Factory experimentApi,
+      ExperimentsCollection experimentsCollection,
+      Provider<ListExperiments> listExperimentsProvider) {
     this.getPreferences = getPreferences;
     this.setPreferences = setPreferences;
     this.getDiffPreferences = getDiffPreferences;
@@ -73,6 +85,9 @@
     this.getServerInfo = getServerInfo;
     this.checkConsistency = checkConsistency;
     this.listTopMenus = listTopMenus;
+    this.experimentApi = experimentApi;
+    this.experimentsCollection = experimentsCollection;
+    this.listExperimentsProvider = listExperimentsProvider;
   }
 
   @Override
@@ -163,4 +178,37 @@
       throw asRestApiException("Cannot get top menus", e);
     }
   }
+
+  @Override
+  public ExperimentApi experiment(String name) throws RestApiException {
+    try {
+      return experimentApi.create(
+          experimentsCollection.parse(new ConfigResource(), IdString.fromDecoded(name)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse experiment", e);
+    }
+  }
+
+  @Override
+  public ListExperimentsRequest listExperiments() throws RestApiException {
+    return new ListExperimentsRequest() {
+      @Override
+      public ImmutableMap<String, ExperimentInfo> get() throws RestApiException {
+        return ServerImpl.this.listExperiments(this);
+      }
+    };
+  }
+
+  private ImmutableMap<String, ExperimentInfo> listExperiments(ListExperimentsRequest r)
+      throws RestApiException {
+    try {
+      ListExperiments listExperiments = listExperimentsProvider.get();
+      if (r.getEnabledOnly()) {
+        listExperiments.setEnabledOnly(true);
+      }
+      return listExperiments.apply(new ConfigResource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve experiments", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/api/config/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/api/config/package-info.java
index 0709b86..686e656 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/api/config/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.api.config;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index bb04ab4..db906a9 100644
--- a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -148,7 +148,8 @@
     NameInput in = new NameInput();
     in.name = name;
     try {
-      putName.apply(rsrc, in);
+      @SuppressWarnings("unused")
+      var unused = putName.apply(rsrc, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot put group name", e);
     }
@@ -168,7 +169,8 @@
     OwnerInput in = new OwnerInput();
     in.owner = owner;
     try {
-      putOwner.apply(rsrc, in);
+      @SuppressWarnings("unused")
+      var unused = putOwner.apply(rsrc, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot put group owner", e);
     }
@@ -188,7 +190,8 @@
     DescriptionInput in = new DescriptionInput();
     in.description = description;
     try {
-      putDescription.apply(rsrc, in);
+      @SuppressWarnings("unused")
+      var unused = putDescription.apply(rsrc, in);
     } catch (Exception e) {
       throw asRestApiException("Cannot put group description", e);
     }
@@ -206,7 +209,8 @@
   @Override
   public void options(GroupOptionsInfo options) throws RestApiException {
     try {
-      putOptions.apply(rsrc, options);
+      @SuppressWarnings("unused")
+      var unused = putOptions.apply(rsrc, options);
     } catch (Exception e) {
       throw asRestApiException("Cannot put group options", e);
     }
@@ -230,7 +234,8 @@
   @Override
   public void addMembers(List<String> members) throws RestApiException {
     try {
-      addMembers.apply(rsrc, AddMembers.Input.fromMembers(members));
+      @SuppressWarnings("unused")
+      var unused = addMembers.apply(rsrc, AddMembers.Input.fromMembers(members));
     } catch (Exception e) {
       throw asRestApiException("Cannot add group members", e);
     }
@@ -239,7 +244,8 @@
   @Override
   public void removeMembers(List<String> members) throws RestApiException {
     try {
-      deleteMembers.apply(rsrc, AddMembers.Input.fromMembers(members));
+      @SuppressWarnings("unused")
+      var unused = deleteMembers.apply(rsrc, AddMembers.Input.fromMembers(members));
     } catch (Exception e) {
       throw asRestApiException("Cannot remove group members", e);
     }
@@ -257,7 +263,8 @@
   @Override
   public void addGroups(List<String> groups) throws RestApiException {
     try {
-      addSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(groups));
+      @SuppressWarnings("unused")
+      var unused = addSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(groups));
     } catch (Exception e) {
       throw asRestApiException("Cannot add subgroups", e);
     }
@@ -266,7 +273,8 @@
   @Override
   public void removeGroups(List<String> groups) throws RestApiException {
     try {
-      deleteSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(groups));
+      @SuppressWarnings("unused")
+      var unused = deleteSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(groups));
     } catch (Exception e) {
       throw asRestApiException("Cannot remove subgroups", e);
     }
@@ -284,7 +292,8 @@
   @Override
   public void index() throws RestApiException {
     try {
-      index.apply(rsrc, new Input());
+      @SuppressWarnings("unused")
+      var unused = index.apply(rsrc, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot index group", e);
     }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/api/groups/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/api/groups/package-info.java
index 0709b86..69f84c6 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/api/groups/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.api.groups;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/api/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/api/package-info.java
index 0709b86..6e4c05d 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/api/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.api;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
index 59c396a..347094f 100644
--- a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
+++ b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
@@ -64,13 +64,15 @@
 
   @Override
   public void enable() throws RestApiException {
-    enable.apply(resource, new Input());
+    @SuppressWarnings("unused")
+    var unused = enable.apply(resource, new Input());
   }
 
   @Override
   public void disable() throws RestApiException {
     try {
-      disable.apply(resource, new Input());
+      @SuppressWarnings("unused")
+      var unused = disable.apply(resource, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot disable plugin", e);
     }
@@ -78,6 +80,7 @@
 
   @Override
   public void reload() throws RestApiException {
-    reload.apply(resource, new Input());
+    @SuppressWarnings("unused")
+    var unused = reload.apply(resource, new Input());
   }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/api/plugins/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/api/plugins/package-info.java
index 0709b86..cd6ad53 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/api/plugins/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.api.plugins;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 1fad91d..549c8f3 100644
--- a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -88,7 +88,8 @@
   @Override
   public BranchApi create(BranchInput input) throws RestApiException {
     try {
-      createBranch.apply(project, IdString.fromDecoded(ref), input);
+      @SuppressWarnings("unused")
+      var unused = createBranch.apply(project, IdString.fromDecoded(ref), input);
       return this;
     } catch (Exception e) {
       throw asRestApiException("Cannot create branch", e);
@@ -107,7 +108,8 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteBranch.apply(resource(), new Input());
+      @SuppressWarnings("unused")
+      var unused = deleteBranch.apply(resource(), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot delete branch", e);
     }
diff --git a/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java b/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
index 61736f6..1bb0b76 100644
--- a/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
@@ -78,8 +78,11 @@
     SetDashboardInput input = new SetDashboardInput();
     input.id = id;
     try {
-      set.apply(
-          DashboardResource.projectDefault(project.getProjectState(), project.getUser()), input);
+      @SuppressWarnings("unused")
+      var unused =
+          set.apply(
+              DashboardResource.projectDefault(project.getProjectState(), project.getUser()),
+              input);
     } catch (Exception e) {
       String msg = String.format("Cannot %s default dashboard", id != null ? "set" : "remove");
       throw asRestApiException(msg, e);
diff --git a/java/com/google/gerrit/server/api/projects/LabelApiImpl.java b/java/com/google/gerrit/server/api/projects/LabelApiImpl.java
index d00f447..2a7ad0a 100644
--- a/java/com/google/gerrit/server/api/projects/LabelApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/LabelApiImpl.java
@@ -74,7 +74,8 @@
   @Override
   public LabelApi create(LabelDefinitionInput input) throws RestApiException {
     try {
-      createLabel.apply(project, IdString.fromDecoded(label), input);
+      @SuppressWarnings("unused")
+      var unused = createLabel.apply(project, IdString.fromDecoded(label), input);
 
       // recreate project resource because project state was updated by creating the new label and
       // needs to be reloaded
@@ -111,7 +112,8 @@
   @Override
   public void delete(@Nullable String commitMessage) throws RestApiException {
     try {
-      deleteLabel.apply(resource(), new InputWithCommitMessage(commitMessage));
+      @SuppressWarnings("unused")
+      var unused = deleteLabel.apply(resource(), new InputWithCommitMessage(commitMessage));
     } catch (Exception e) {
       throw asRestApiException("Cannot delete label", e);
     }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index ad42ae6..5c24ddc 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.HEAD_MODIFICATION;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
@@ -417,7 +418,10 @@
       permissionBackend
           .currentUser()
           .checkAny(GlobalPermission.fromAnnotation(createProject.getClass()));
-      createProject.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(name), in);
+
+      @SuppressWarnings("unused")
+      var unused = createProject.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(name), in);
+
       return projectApi.create(projects.parse(name));
     } catch (Exception e) {
       throw asRestApiException("Cannot create project: " + e.getMessage(), e);
@@ -489,7 +493,8 @@
   @Override
   public void description(DescriptionInput in) throws RestApiException {
     try {
-      putDescription.apply(checkExists(), in);
+      @SuppressWarnings("unused")
+      var unused = putDescription.apply(checkExists(), in);
     } catch (Exception e) {
       throw asRestApiException("Cannot put project description", e);
     }
@@ -529,7 +534,7 @@
   public ListRefsRequest<BranchInfo> branches() {
     return new ListRefsRequest<>() {
       @Override
-      public List<BranchInfo> get() throws RestApiException {
+      public ImmutableList<BranchInfo> get() throws RestApiException {
         try {
           return listBranches.get().request(this).apply(checkExists()).value();
         } catch (Exception e) {
@@ -543,7 +548,7 @@
   public ListRefsRequest<TagInfo> tags() {
     return new ListRefsRequest<>() {
       @Override
-      public List<TagInfo> get() throws RestApiException {
+      public ImmutableList<TagInfo> get() throws RestApiException {
         try {
           return listTags.get().request(this).apply(checkExists()).value();
         } catch (Exception e) {
@@ -599,7 +604,8 @@
   public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
     try {
       try (RefUpdateContext ctx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
-        deleteBranches.apply(checkExists(), in);
+        @SuppressWarnings("unused")
+        var unused = deleteBranches.apply(checkExists(), in);
       }
     } catch (Exception e) {
       throw asRestApiException("Cannot delete branches", e);
@@ -609,7 +615,8 @@
   @Override
   public void deleteTags(DeleteTagsInput in) throws RestApiException {
     try {
-      deleteTags.apply(checkExists(), in);
+      @SuppressWarnings("unused")
+      var unused = deleteTags.apply(checkExists(), in);
     } catch (Exception e) {
       throw asRestApiException("Cannot delete tags", e);
     }
@@ -692,7 +699,8 @@
     input.ref = head;
     try {
       try (RefUpdateContext ctx = RefUpdateContext.open(HEAD_MODIFICATION)) {
-        setHead.apply(checkExists(), input);
+        @SuppressWarnings("unused")
+        var unused = setHead.apply(checkExists(), input);
       }
     } catch (Exception e) {
       throw asRestApiException("Cannot set HEAD", e);
@@ -713,7 +721,9 @@
     try {
       ParentInput input = new ParentInput();
       input.parent = parent;
-      setParent.apply(checkExists(), input);
+
+      @SuppressWarnings("unused")
+      var unused = setParent.apply(checkExists(), input);
     } catch (Exception e) {
       throw asRestApiException("Cannot set parent", e);
     }
@@ -724,7 +734,9 @@
     try {
       IndexProjectInput input = new IndexProjectInput();
       input.indexChildren = indexChildren;
-      index.apply(checkExists(), input);
+
+      @SuppressWarnings("unused")
+      var unused = index.apply(checkExists(), input);
     } catch (Exception e) {
       throw asRestApiException("Cannot index project", e);
     }
@@ -733,7 +745,8 @@
   @Override
   public void indexChanges() throws RestApiException {
     try {
-      indexChanges.apply(checkExists(), new Input());
+      @SuppressWarnings("unused")
+      var unused = indexChanges.apply(checkExists(), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot index changes", e);
     }
@@ -795,7 +808,8 @@
   @Override
   public void labels(BatchLabelInput input) throws RestApiException {
     try {
-      postLabels.apply(checkExists(), input);
+      @SuppressWarnings("unused")
+      var unused = postLabels.apply(checkExists(), input);
     } catch (Exception e) {
       throw asRestApiException("Cannot update labels", e);
     }
diff --git a/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
index aa6ef71..73be088 100644
--- a/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
@@ -73,7 +73,8 @@
   @Override
   public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException {
     try {
-      createSubmitRequirement.apply(project, IdString.fromDecoded(name), input);
+      @SuppressWarnings("unused")
+      var unused = createSubmitRequirement.apply(project, IdString.fromDecoded(name), input);
 
       // recreate project resource because project state was updated
       project =
@@ -110,7 +111,8 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteSubmitRequirement.apply(resource(), new Input());
+      @SuppressWarnings("unused")
+      var unused = deleteSubmitRequirement.apply(resource(), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot delete submit requirement", e);
     }
diff --git a/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
index f9bd048..6585093 100644
--- a/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -66,7 +66,8 @@
   @Override
   public TagApi create(TagInput input) throws RestApiException {
     try {
-      createTag.apply(project, IdString.fromDecoded(ref), input);
+      @SuppressWarnings("unused")
+      var unused = createTag.apply(project, IdString.fromDecoded(ref), input);
       return this;
     } catch (Exception e) {
       throw asRestApiException("Cannot create tag", e);
@@ -86,7 +87,8 @@
   public void delete() throws RestApiException {
     try {
       try (RefUpdateContext ctx = RefUpdateContext.open(TAG_MODIFICATION)) {
-        deleteTag.apply(resource(), new Input());
+        @SuppressWarnings("unused")
+        var unused = deleteTag.apply(resource(), new Input());
       }
     } catch (Exception e) {
       throw asRestApiException("Cannot delete tag", e);
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/api/projects/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/api/projects/package-info.java
index 0709b86..6430a05 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/api/projects/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.api.projects;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/approval/ApprovalCopier.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
index a1889da..9bb3bc9 100644
--- a/java/com/google/gerrit/server/approval/ApprovalCopier.java
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.approval.ApprovalContext;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
@@ -54,7 +55,8 @@
 import java.io.IOException;
 import java.util.Map;
 import java.util.Optional;
-import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -208,7 +210,7 @@
    * </ul>
    */
   @VisibleForTesting
-  public Result forPatchSet(ChangeNotes notes, PatchSet ps, RevWalk rw, Config repoConfig) {
+  public Result forPatchSet(ChangeNotes notes, PatchSet ps, RepoView repoView) {
     ProjectState project;
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
@@ -221,7 +223,7 @@
           projectCache
               .get(notes.getProjectName())
               .orElseThrow(illegalState(notes.getProjectName()));
-      return computeForPatchSet(project.getLabelTypes(), notes, ps, rw, repoConfig);
+      return computeForPatchSet(project.getLabelTypes(), notes, ps, repoView);
     }
   }
 
@@ -267,7 +269,9 @@
     }
 
     try (Repository repo = repoManager.openRepository(changeNotes.getProjectName());
-        RevWalk revWalk = new RevWalk(repo)) {
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
       ImmutableList<PatchSet.Id> followUpPatchSets =
           changeNotes.getPatchSets().keySet().stream()
               .filter(psId -> psId.get() > sourcePatchSet.id().get())
@@ -296,8 +300,7 @@
                 approvalValue,
                 changeKind,
                 isMerge,
-                revWalk,
-                repo.getConfig())
+                new RepoView(repo, revWalk, ins))
             .canCopy()) {
           targetPatchSetsBuilder.add(followUpPatchSetId);
         } else {
@@ -328,8 +331,7 @@
       short approvalValue,
       ChangeKind changeKind,
       boolean isMerge,
-      RevWalk revWalk,
-      Config repoConfig) {
+      RepoView repoView) {
     if (!labelType.getCopyCondition().isPresent()) {
       return ApprovalCopyResult.createForMissingCopyCondition();
     }
@@ -343,8 +345,7 @@
             targetPatchSet,
             changeKind,
             isMerge,
-            revWalk,
-            repoConfig);
+            repoView);
     try {
       // Use a request context to run checks as an internal user with expanded visibility. This is
       // so that the output of the copy condition does not depend on who is running the current
@@ -382,11 +383,7 @@
   }
 
   private Result computeForPatchSet(
-      LabelTypes labelTypes,
-      ChangeNotes notes,
-      PatchSet targetPatchSet,
-      RevWalk rw,
-      Config repoConfig) {
+      LabelTypes labelTypes, ChangeNotes notes, PatchSet targetPatchSet, RepoView repoView) {
     Project.NameKey projectName = notes.getProjectName();
     PatchSet.Id targetPsId = targetPatchSet.id();
 
@@ -420,11 +417,11 @@
     ChangeKind changeKind =
         changeKindCache.getChangeKind(
             projectName,
-            rw,
-            repoConfig,
+            repoView.getRevWalk(),
+            repoView.getConfig(),
             priorPatchSet.getValue().commitId(),
             targetPatchSet.commitId());
-    boolean isMerge = isMerge(projectName, rw, targetPatchSet);
+    boolean isMerge = isMerge(projectName, repoView.getRevWalk(), targetPatchSet);
     logger.atFine().log(
         "change kind for patch set %d of change %d against prior patch set %s is %s",
         targetPatchSet.id().get(),
@@ -464,8 +461,7 @@
               priorPsa.value(),
               changeKind,
               isMerge,
-              rw,
-              repoConfig);
+              repoView);
       if (approvalCopyResult.canCopy()) {
         if (!currentApprovalsByUser.contains(priorPsa.label(), priorPsa.accountId())) {
           PatchSetApproval copiedApproval = priorPsa.copyWithPatchSet(targetPatchSet.id());
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index e64c273..468c7fd 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -35,10 +35,10 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Multimap;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
@@ -70,6 +70,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.gerrit.server.query.approval.UserInPredicate;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.ManualRequestContext;
@@ -89,8 +90,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.StringTokenizer;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * Utility functions to manipulate patchset approvals.
@@ -314,6 +313,7 @@
    * @param user user adding approvals.
    * @param approvals approvals to add.
    */
+  @CanIgnoreReturnValue
   public Iterable<PatchSetApproval> addApprovalsForNewPatchSet(
       ChangeUpdate update,
       LabelTypes labelTypes,
@@ -394,20 +394,15 @@
    *
    * @param notes the change notes
    * @param patchSet the newly created patch set
-   * @param revWalk {@link RevWalk} that can see the new patch set revision
-   * @param repoConfig the repo config
+   * @param repoView repo view
    * @param changeUpdate changeUpdate that is used to persist the copied approvals and update the
    *     attention set
    * @return the result of the approval copying
    */
   public ApprovalCopier.Result copyApprovalsToNewPatchSet(
-      ChangeNotes notes,
-      PatchSet patchSet,
-      RevWalk revWalk,
-      Config repoConfig,
-      ChangeUpdate changeUpdate) {
+      ChangeNotes notes, PatchSet patchSet, RepoView repoView, ChangeUpdate changeUpdate) {
     ApprovalCopier.Result approvalCopierResult =
-        approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig);
+        approvalCopier.forPatchSet(notes, patchSet, repoView);
     approvalCopierResult
         .copiedApprovals()
         .forEach(approvalData -> changeUpdate.putCopiedApproval(approvalData.patchSetApproval()));
@@ -428,7 +423,7 @@
       ChangeUpdate changeUpdate, ImmutableSet<PatchSetApproval> outdatedApprovals) {
     Set<AttentionSetUpdate> updates = new HashSet<>();
 
-    Multimap<Account.Id, PatchSetApproval> outdatedApprovalsByUser = ArrayListMultimap.create();
+    ListMultimap<Account.Id, PatchSetApproval> outdatedApprovalsByUser = ArrayListMultimap.create();
     outdatedApprovals.forEach(psa -> outdatedApprovalsByUser.put(psa.accountId(), psa));
     for (Map.Entry<Account.Id, Collection<PatchSetApproval>> e :
         outdatedApprovalsByUser.asMap().entrySet()) {
@@ -862,7 +857,8 @@
    *     deleted labels.
    */
   public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovals().all().get(psId);
+    ImmutableList<PatchSetApproval> approvalsNotNormalized =
+        notes.load().getApprovals().all().get(psId);
     return labelNormalizer.normalize(notes, approvalsNotNormalized).getNormalized();
   }
 
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/approval/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/approval/package-info.java
index 0709b86..29d11bb 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/approval/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.approval;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/approval/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/approval/testing/package-info.java
index 0709b86..5bec0dd 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/approval/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.approval.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/args4j/ListTagSortOptionHandler.java b/java/com/google/gerrit/server/args4j/ListTagSortOptionHandler.java
new file mode 100644
index 0000000..9359ca1
--- /dev/null
+++ b/java/com/google/gerrit/server/args4j/ListTagSortOptionHandler.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.args4j;
+
+import static com.google.gerrit.util.cli.Localizable.localizable;
+
+import com.google.gerrit.extensions.common.ListTagSortOption;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.OptionDef;
+import org.kohsuke.args4j.spi.OptionHandler;
+import org.kohsuke.args4j.spi.Parameters;
+import org.kohsuke.args4j.spi.Setter;
+
+public class ListTagSortOptionHandler extends OptionHandler<ListTagSortOption> {
+  @Inject
+  public ListTagSortOptionHandler(
+      @Assisted CmdLineParser parser,
+      @Assisted OptionDef option,
+      @Assisted Setter<ListTagSortOption> setter) {
+    super(parser, option, setter);
+  }
+
+  @Override
+  public int parseArguments(Parameters params) throws CmdLineException {
+    String param = params.getParameter(0);
+    try {
+      setter.addValue(ListTagSortOption.valueOf(param.toUpperCase()));
+      return 1;
+    } catch (IllegalArgumentException e) {
+      throw new CmdLineException(
+          owner, localizable("\"%s\" is not a valid sort option: %s"), param, e.getMessage());
+    }
+  }
+
+  @Override
+  public String getDefaultMetaVariable() {
+    return "SORT";
+  }
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/args4j/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/args4j/package-info.java
index 0709b86..56499ee 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/args4j/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.args4j;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
index 654996a..a9526d3 100644
--- a/java/com/google/gerrit/server/audit/BUILD
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -15,6 +15,7 @@
         "//lib:servlet-api",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
     ],
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/audit/group/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/audit/group/package-info.java
index 0709b86..af318ca 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/audit/group/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.audit.group;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/audit/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/audit/package-info.java
index 0709b86..6ae2570 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/audit/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.audit;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/auth/openid/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/auth/openid/package-info.java
index 0709b86..a516fc5 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/auth/openid/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.auth.openid;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/auth/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/auth/package-info.java
index 0709b86..28395e1 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/auth/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.auth;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/avatar/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/avatar/package-info.java
index 0709b86..8fed9db 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/avatar/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.avatar;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/cache/CacheBinding.java b/java/com/google/gerrit/server/cache/CacheBinding.java
index 99db64e..8249948 100644
--- a/java/com/google/gerrit/server/cache/CacheBinding.java
+++ b/java/com/google/gerrit/server/cache/CacheBinding.java
@@ -16,17 +16,21 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.time.Duration;
 
 /** Configure a cache declared within a {@link CacheModule} instance. */
 public interface CacheBinding<K, V> {
   /** Set the total size of the cache. */
+  @CanIgnoreReturnValue
   CacheBinding<K, V> maximumWeight(long weight);
 
   /** Set the time an element lives after last write before being expired. */
+  @CanIgnoreReturnValue
   CacheBinding<K, V> expireAfterWrite(Duration duration);
 
   /** Set the time an element lives after last access before being expired. */
+  @CanIgnoreReturnValue
   CacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration);
 
   /**
@@ -34,12 +38,15 @@
    * {@link #expireAfterWrite(Duration)} will still be returned, but on access a task is queued to
    * refresh their value asynchronously.
    */
+  @CanIgnoreReturnValue
   CacheBinding<K, V> refreshAfterWrite(Duration duration);
 
   /** Populate the cache with items from the CacheLoader. */
+  @CanIgnoreReturnValue
   CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz);
 
   /** Algorithm to weigh an object with a method other than the unit weight 1. */
+  @CanIgnoreReturnValue
   CacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz);
 
   /**
@@ -47,5 +54,6 @@
    *
    * @see CacheDef#configKey()
    */
+  @CanIgnoreReturnValue
   CacheBinding<K, V> configKey(String configKey);
 }
diff --git a/java/com/google/gerrit/server/cache/CacheMetrics.java b/java/com/google/gerrit/server/cache/CacheMetrics.java
index f1fd4a8..27a75eb 100644
--- a/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -73,7 +72,7 @@
             new Description("Disk hit ratio for persistent cache").setGauge().setUnit("percent"),
             F_NAME);
 
-    Set<CallbackMetric<?>> cacheMetrics =
+    ImmutableSet<CallbackMetric<?>> cacheMetrics =
         ImmutableSet.of(memEnt, memHit, memEvict, perDiskEnt, perDiskHit);
 
     metrics.newTrigger(
diff --git a/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java
index d4e4509..85c5cc56 100644
--- a/java/com/google/gerrit/server/cache/CacheModule.java
+++ b/java/com/google/gerrit/server/cache/CacheModule.java
@@ -18,6 +18,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.cache.Weigher;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.cache.serialize.JavaCacheSerializer;
@@ -44,6 +45,7 @@
    * @param <V> type of value stored by the cache.
    * @return binding to describe the cache.
    */
+  @CanIgnoreReturnValue
   protected <K, V> CacheBinding<K, V> cache(String name, Class<K> keyType, Class<V> valType) {
     return cache(name, TypeLiteral.get(keyType), TypeLiteral.get(valType));
   }
@@ -55,6 +57,7 @@
    * @param <V> type of value stored by the cache.
    * @return binding to describe the cache.
    */
+  @CanIgnoreReturnValue
   protected <K, V> CacheBinding<K, V> cache(String name, Class<K> keyType, TypeLiteral<V> valType) {
     return cache(name, TypeLiteral.get(keyType), valType);
   }
@@ -66,6 +69,7 @@
    * @param <V> type of value stored by the cache.
    * @return binding to describe the cache.
    */
+  @CanIgnoreReturnValue
   protected <K, V> CacheBinding<K, V> cache(
       String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
     CacheProvider<K, V> m = new CacheProvider<>(this, name, keyType, valType);
diff --git a/java/com/google/gerrit/server/cache/CacheProvider.java b/java/com/google/gerrit/server/cache/CacheProvider.java
index 94504b6..41e4bc7 100644
--- a/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -21,6 +21,7 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.inject.Inject;
@@ -63,6 +64,7 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public CacheBinding<K, V> maximumWeight(long weight) {
     checkNotFrozen();
     maximumWeight = weight;
@@ -70,6 +72,7 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public CacheBinding<K, V> expireAfterWrite(Duration duration) {
     checkNotFrozen();
     expireAfterWrite = duration;
@@ -77,6 +80,7 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public CacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration) {
     checkNotFrozen();
     expireFromMemoryAfterAccess = duration;
@@ -84,6 +88,7 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public CacheBinding<K, V> refreshAfterWrite(Duration duration) {
     checkNotFrozen();
     refreshAfterWrite = duration;
@@ -91,6 +96,7 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> impl) {
     checkNotFrozen();
     loader = module.bindCacheLoader(this, impl);
@@ -98,6 +104,7 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public CacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> impl) {
     checkNotFrozen();
     weigher = module.bindWeigher(this, impl);
@@ -105,6 +112,7 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public CacheBinding<K, V> configKey(String name) {
     checkNotFrozen();
     configKey = requireNonNull(name);
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBinding.java b/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
index 5635f44..5a2de69 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBinding.java
@@ -16,26 +16,33 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.Weigher;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import java.time.Duration;
 
 /** Configure a persistent cache declared within a {@link CacheModule} instance. */
 public interface PersistentCacheBinding<K, V> extends CacheBinding<K, V> {
   @Override
+  @CanIgnoreReturnValue
   PersistentCacheBinding<K, V> maximumWeight(long weight);
 
   @Override
+  @CanIgnoreReturnValue
   PersistentCacheBinding<K, V> expireAfterWrite(Duration duration);
 
   @Override
+  @CanIgnoreReturnValue
   PersistentCacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz);
 
   @Override
+  @CanIgnoreReturnValue
   PersistentCacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration);
 
   @Override
+  @CanIgnoreReturnValue
   PersistentCacheBinding<K, V> weigher(Class<? extends Weigher<K, V>> clazz);
 
+  @CanIgnoreReturnValue
   PersistentCacheBinding<K, V> version(int version);
 
   /**
@@ -44,9 +51,12 @@
    * <p>If 0 or negative, persistence for the cache is disabled by default, but may still be
    * overridden in the config.
    */
+  @CanIgnoreReturnValue
   PersistentCacheBinding<K, V> diskLimit(long limit);
 
+  @CanIgnoreReturnValue
   PersistentCacheBinding<K, V> keySerializer(CacheSerializer<K> keySerializer);
 
+  @CanIgnoreReturnValue
   PersistentCacheBinding<K, V> valueSerializer(CacheSerializer<V> valueSerializer);
 }
diff --git a/java/com/google/gerrit/server/cache/h2/BUILD b/java/com/google/gerrit/server/cache/h2/BUILD
index 5e64aa7..722bf12 100644
--- a/java/com/google/gerrit/server/cache/h2/BUILD
+++ b/java/com/google/gerrit/server/cache/h2/BUILD
@@ -15,6 +15,7 @@
         "//lib:guava",
         "//lib:h2",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
     ],
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index fdd55ac..ddfee38 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -32,11 +32,13 @@
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ScheduleConfig;
+import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.options.BuildBloomFilter;
 import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
-import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -67,6 +69,8 @@
   private final boolean h2AutoServer;
   private final boolean isOfflineReindex;
   private final boolean buildBloomFilter;
+  private final boolean pruneOnStartup;
+  private final Schedule schedule;
 
   @Inject
   H2CacheFactory(
@@ -74,12 +78,18 @@
       @GerritServerConfig Config cfg,
       SitePaths site,
       DynamicMap<Cache<?, ?>> cacheMap,
+      WorkQueue queue,
       @Nullable IsFirstInsertForEntry isFirstInsertForEntry,
       @Nullable BuildBloomFilter buildBloomFilter) {
     super(memCacheFactory, cfg, site);
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
     h2AutoServer = cfg.getBoolean("cache", null, "h2AutoServer", false);
+    pruneOnStartup = cfg.getBoolean("cachePruning", null, "pruneOnStartup", true);
     caches = new ArrayList<>();
+    schedule =
+        ScheduleConfig.createSchedule(cfg, "cachePruning")
+            .orElseGet(() -> Schedule.createOrFail(Duration.ofDays(1).toMillis(), "01:00"));
+    logger.atInfo().log("Scheduling cache pruning with schedule %s", schedule);
     this.cacheMap = cacheMap;
     this.isOfflineReindex =
         isFirstInsertForEntry != null && isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES);
@@ -92,16 +102,7 @@
               Executors.newFixedThreadPool(
                   1, new ThreadFactoryBuilder().setNameFormat("DiskCache-Store-%d").build()));
 
-      cleanup =
-          isOfflineReindex
-              ? null
-              : new LoggingContextAwareScheduledExecutorService(
-                  Executors.newScheduledThreadPool(
-                      1,
-                      new ThreadFactoryBuilder()
-                          .setNameFormat("DiskCache-Prune-%d")
-                          .setDaemon(true)
-                          .build()));
+      cleanup = isOfflineReindex ? null : queue.createQueue(1, "DiskCache-Prune", true);
     } else {
       executor = null;
       cleanup = null;
@@ -114,9 +115,19 @@
       for (H2CacheImpl<?, ?> cache : caches) {
         executor.execute(cache::start);
         if (cleanup != null) {
+          if (pruneOnStartup) {
+            @SuppressWarnings("unused")
+            Future<?> possiblyIgnoredError =
+                cleanup.schedule(() -> cache.prune(), 30, TimeUnit.SECONDS);
+          }
+
           @SuppressWarnings("unused")
           Future<?> possiblyIgnoredError =
-              cleanup.schedule(() -> cache.prune(cleanup), 30, TimeUnit.SECONDS);
+              cleanup.scheduleAtFixedRate(
+                  () -> cache.prune(),
+                  schedule.initialDelay(),
+                  schedule.interval(),
+                  TimeUnit.MILLISECONDS);
         }
       }
     }
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 27a09ed..a869946 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -47,7 +47,6 @@
 import java.time.Duration;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Calendar;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -56,9 +55,6 @@
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executor;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 
 /**
@@ -92,6 +88,7 @@
   private final SqlStore<K, V> store;
   private final TypeLiteral<K> keyType;
   private final Cache<K, ValueHolder<V>> mem;
+  private final String cacheName;
 
   H2CacheImpl(
       Executor executor,
@@ -102,6 +99,7 @@
     this.store = store;
     this.keyType = keyType;
     this.mem = mem;
+    this.cacheName = store.url.substring(store.url.lastIndexOf('/') + 1);
   }
 
   @Nullable
@@ -230,20 +228,10 @@
     store.close();
   }
 
-  void prune(ScheduledExecutorService service) {
+  void prune() {
+    logger.atFine().log("Pruning cache %s...", cacheName);
     store.prune(mem);
-
-    Calendar cal = Calendar.getInstance();
-    cal.set(Calendar.HOUR_OF_DAY, 01);
-    cal.set(Calendar.MINUTE, 0);
-    cal.set(Calendar.SECOND, 0);
-    cal.set(Calendar.MILLISECOND, 0);
-    cal.add(Calendar.DAY_OF_MONTH, 1);
-
-    long delay = cal.getTimeInMillis() - TimeUtil.nowMs();
-    @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError =
-        service.schedule(() -> prune(service), delay, TimeUnit.MILLISECONDS);
+    logger.atFine().log("Finished pruning cache %s...", cacheName);
   }
 
   static class ValueHolder<V> {
@@ -431,7 +419,7 @@
     @Nullable
     private BloomFilter<K> buildBloomFilter() {
       SqlHandle c = null;
-      try {
+      try (TraceTimer ignored = TraceContext.newTimer("Build bloom filter", Metadata.empty())) {
         c = acquire();
         if (estimatedSize <= 0) {
           try (PreparedStatement ps =
@@ -773,6 +761,8 @@
             "ALTER TABLE data ADD COLUMN IF NOT EXISTS "
                 + "space BIGINT AS OCTET_LENGTH(k) + OCTET_LENGTH(v)");
         stmt.addBatch("ALTER TABLE data ADD COLUMN IF NOT EXISTS version INT DEFAULT 0 NOT NULL");
+        stmt.addBatch("CREATE INDEX IF NOT EXISTS version_key ON data(version, k)");
+        stmt.addBatch("CREATE INDEX IF NOT EXISTS accessed ON data(accessed)");
         stmt.executeBatch();
       }
     }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/cache/h2/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/cache/h2/package-info.java
index 0709b86..ea541d3 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/cache/h2/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.cache.h2;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/cache/mem/BUILD b/java/com/google/gerrit/server/cache/mem/BUILD
index a666df7..a0cc079 100644
--- a/java/com/google/gerrit/server/cache/mem/BUILD
+++ b/java/com/google/gerrit/server/cache/mem/BUILD
@@ -12,6 +12,7 @@
         "//lib:caffeine-guava",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/cache/mem/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/cache/mem/package-info.java
index 0709b86..b8abd10 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/cache/mem/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.cache.mem;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/cache/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/cache/package-info.java
index 0709b86..4779c73 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/cache/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.cache;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/cache/serialize/BUILD b/java/com/google/gerrit/server/cache/serialize/BUILD
index aa9106b..c77db89 100644
--- a/java/com/google/gerrit/server/cache/serialize/BUILD
+++ b/java/com/google/gerrit/server/cache/serialize/BUILD
@@ -11,5 +11,6 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/BUILD b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
index 55080e8..ead40c5 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -11,6 +11,7 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
+        "//lib/errorprone:annotations",
         "//proto:cache_java_proto",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/cache/serialize/entities/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/cache/serialize/entities/package-info.java
index 0709b86..0737d1b 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.cache.serialize.entities;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/cache/serialize/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/cache/serialize/package-info.java
index 0709b86..05df981 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/cache/serialize/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.cache.serialize;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/cache/testing/BUILD b/java/com/google/gerrit/server/cache/testing/BUILD
index 09f698c..b823187 100644
--- a/java/com/google/gerrit/server/cache/testing/BUILD
+++ b/java/com/google/gerrit/server/cache/testing/BUILD
@@ -8,5 +8,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//lib:protobuf",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/cache/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/cache/testing/package-info.java
index 0709b86..ca05150 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/cache/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.cache.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/cancellation/BUILD b/java/com/google/gerrit/server/cancellation/BUILD
index 40557b1..c48456c 100644
--- a/java/com/google/gerrit/server/cancellation/BUILD
+++ b/java/com/google/gerrit/server/cancellation/BUILD
@@ -10,5 +10,6 @@
         "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib/commons:text",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/cancellation/RequestStateContext.java b/java/com/google/gerrit/server/cancellation/RequestStateContext.java
index 390c76f..39cd441 100644
--- a/java/com/google/gerrit/server/cancellation/RequestStateContext.java
+++ b/java/com/google/gerrit/server/cancellation/RequestStateContext.java
@@ -16,6 +16,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.util.HashSet;
 import java.util.Set;
 
@@ -136,6 +137,7 @@
    * @param requestStateProvider the {@link RequestStateProvider} that should be registered
    * @return the {@code RequestStateContext} instance for chaining calls
    */
+  @CanIgnoreReturnValue
   public RequestStateContext addRequestStateProvider(RequestStateProvider requestStateProvider) {
     if (threadLocalRequestStateProviders.get() == null) {
       threadLocalRequestStateProviders.set(new HashSet<>());
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/cancellation/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/cancellation/package-info.java
index 0709b86..ac09229 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/cancellation/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.cancellation;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index 6734434..07280ba 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -40,7 +42,7 @@
 
   private final ChangeCleanupConfig cfg;
   private final Provider<ChangeQueryProcessor> queryProvider;
-  private final ChangeQueryBuilder queryBuilder;
+  private final Supplier<ChangeQueryBuilder> queryBuilderSupplier;
   private final BatchAbandon batchAbandon;
   private final InternalUser internalUser;
 
@@ -49,11 +51,11 @@
       ChangeCleanupConfig cfg,
       InternalUser.Factory internalUserFactory,
       Provider<ChangeQueryProcessor> queryProvider,
-      ChangeQueryBuilder queryBuilder,
+      Provider<ChangeQueryBuilder> queryBuilderProvider,
       BatchAbandon batchAbandon) {
     this.cfg = cfg;
     this.queryProvider = queryProvider;
-    this.queryBuilder = queryBuilder;
+    this.queryBuilderSupplier = Suppliers.memoize(queryBuilderProvider::get);
     this.batchAbandon = batchAbandon;
     internalUser = internalUserFactory.create();
   }
@@ -70,8 +72,12 @@
         query += " -is:mergeable";
       }
 
-      List<ChangeData> changesToAbandon =
-          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
+      ImmutableList<ChangeData> changesToAbandon =
+          queryProvider
+              .get()
+              .enforceVisibility(false)
+              .query(queryBuilderSupplier.get().parse(query))
+              .entities();
       ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
           ImmutableListMultimap.builder();
       for (ChangeData cd : changesToAbandon) {
@@ -79,10 +85,10 @@
       }
 
       int count = 0;
-      ListMultimap<Project.NameKey, ChangeData> abandons = builder.build();
+      ImmutableListMultimap<Project.NameKey, ChangeData> abandons = builder.build();
       String message = cfg.getAbandonMessage();
       for (Project.NameKey project : abandons.keySet()) {
-        Collection<ChangeData> changes = getValidChanges(abandons.get(project), query);
+        List<ChangeData> changes = getValidChanges(abandons.get(project), query);
         try {
           batchAbandon.batchAbandon(updateFactory, project, internalUser, changes, message);
           count += changes.size();
@@ -102,16 +108,16 @@
     }
   }
 
-  private Collection<ChangeData> getValidChanges(Collection<ChangeData> changes, String query)
+  private List<ChangeData> getValidChanges(Collection<ChangeData> changes, String query)
       throws QueryParseException {
     List<ChangeData> validChanges = new ArrayList<>();
     for (ChangeData cd : changes) {
       String newQuery = query + " change:" + cd.getId();
-      List<ChangeData> changesToAbandon =
+      ImmutableList<ChangeData> changesToAbandon =
           queryProvider
               .get()
               .enforceVisibility(false)
-              .query(queryBuilder.parse(newQuery))
+              .query(queryBuilderSupplier.get().parse(newQuery))
               .entities();
       if (!changesToAbandon.isEmpty()) {
         validChanges.add(cd);
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index 52230ba..950d390 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -31,6 +31,9 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.extensions.webui.UiActions;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -179,38 +182,43 @@
 
   private Map<String, ActionInfo> toActionMap(
       ChangeData changeData, List<ActionVisitor> visitors, ChangeInfo changeInfo) {
-    CurrentUser user = userProvider.get();
-    Map<String, ActionInfo> out = new LinkedHashMap<>();
-    if (!user.isIdentifiedUser()) {
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get actions",
+            Metadata.builder().changeId(changeData.change().getId().get()).build())) {
+      CurrentUser user = userProvider.get();
+      Map<String, ActionInfo> out = new LinkedHashMap<>();
+      if (!user.isIdentifiedUser()) {
+        return out;
+      }
+
+      Iterable<UiAction.Description> descs =
+          uiActions.from(changeViews, changeResourceFactory.create(changeData, user));
+
+      // The followup action is a client-side only operation that does not
+      // have a server side handler. It must be manually registered into the
+      // resulting action map.
+      if (!changeData.change().isAbandoned()) {
+        UiAction.Description descr = new UiAction.Description();
+        PrivateInternals_UiActionDescription.setId(descr, "followup");
+        PrivateInternals_UiActionDescription.setMethod(descr, "POST");
+        descr.setTitle("Create follow-up change");
+        descr.setLabel("Follow-Up");
+        descs = Iterables.concat(descs, Collections.singleton(descr));
+      }
+
+      ACTION:
+      for (UiAction.Description d : descs) {
+        ActionInfo actionInfo = new ActionInfo(d);
+        for (ActionVisitor visitor : visitors) {
+          if (!visitor.visit(d.getId(), actionInfo, changeInfo)) {
+            continue ACTION;
+          }
+        }
+        out.put(d.getId(), actionInfo);
+      }
       return out;
     }
-
-    Iterable<UiAction.Description> descs =
-        uiActions.from(changeViews, changeResourceFactory.create(changeData, user));
-
-    // The followup action is a client-side only operation that does not
-    // have a server side handler. It must be manually registered into the
-    // resulting action map.
-    if (!changeData.change().isAbandoned()) {
-      UiAction.Description descr = new UiAction.Description();
-      PrivateInternals_UiActionDescription.setId(descr, "followup");
-      PrivateInternals_UiActionDescription.setMethod(descr, "POST");
-      descr.setTitle("Create follow-up change");
-      descr.setLabel("Follow-Up");
-      descs = Iterables.concat(descs, Collections.singleton(descr));
-    }
-
-    ACTION:
-    for (UiAction.Description d : descs) {
-      ActionInfo actionInfo = new ActionInfo(d);
-      for (ActionVisitor visitor : visitors) {
-        if (!visitor.visit(d.getId(), actionInfo, changeInfo)) {
-          continue ACTION;
-        }
-      }
-      out.put(d.getId(), actionInfo);
-    }
-    return out;
   }
 
   private ImmutableMap<String, ActionInfo> toActionMap(
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index cbbd01a..6690911 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -86,9 +86,9 @@
   // Unlike addedCCs, addedReviewers is a PatchSetApproval because the ReviewerResult returned
   // via the REST API is supposed to include vote information.
   private List<PatchSetApproval> addedReviewers = ImmutableList.of();
-  private Collection<Address> addedReviewersByEmail = ImmutableList.of();
+  private ImmutableList<Address> addedReviewersByEmail = ImmutableList.of();
   private Collection<Account.Id> addedCCs = ImmutableList.of();
-  private Collection<Address> addedCCsByEmail = ImmutableList.of();
+  private ImmutableList<Address> addedCCsByEmail = ImmutableList.of();
 
   private Change change;
 
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index f0a70bb..8403ae3 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange.USER_ADDED;
 import static java.util.Objects.requireNonNull;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
@@ -44,6 +46,7 @@
 
   private Change change;
   private boolean notify;
+  @Nullable private AttentionSetUpdateCondition condition;
 
   /**
    * Add a specified user to the attention set.
@@ -66,13 +69,25 @@
     this.notify = notify;
   }
 
+  /** Sets a condition for performing this attention set update. */
+  @CanIgnoreReturnValue
+  public AddToAttentionSetOp setCondition(AttentionSetUpdateCondition condition) {
+    this.condition = condition;
+    return this;
+  }
+
   @Override
   public boolean updateChange(ChangeContext ctx) throws RestApiException {
+    if (condition != null && !condition.check()) {
+      return false;
+    }
+
     ChangeData changeData = changeDataFactory.create(ctx.getNotes());
     if (changeData.attentionSet().stream()
         .anyMatch(
             u ->
                 u.account().equals(attentionUserId)
+                    && u.reason().equals(reason)
                     && u.operation() == AttentionSetUpdate.Operation.ADD)) {
       // We still need to perform this update to ensure that we don't remove the user in a follow-up
       // operation, but no need to send an email about it.
diff --git a/java/com/google/gerrit/server/change/ArchiveFormatInternal.java b/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
index 0ed1f11..b5ac87f5 100644
--- a/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
+++ b/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
@@ -63,8 +63,8 @@
     return format.suffixes();
   }
 
-  public ArchiveOutputStream createArchiveOutputStream(OutputStream o) throws IOException {
-    return (ArchiveOutputStream) this.format.createArchiveOutputStream(o);
+  public ArchiveOutputStream<?> createArchiveOutputStream(OutputStream o) throws IOException {
+    return (ArchiveOutputStream<?>) this.format.createArchiveOutputStream(o);
   }
 
   public <T extends Closeable> void putEntry(T out, String path, byte[] data) throws IOException {
diff --git a/java/com/google/gerrit/server/change/AttentionSetUpdateCondition.java b/java/com/google/gerrit/server/change/AttentionSetUpdateCondition.java
new file mode 100644
index 0000000..b275bd3
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AttentionSetUpdateCondition.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+/**
+ * Condition to be checked by {@link AddToAttentionSetOp} and {@link RemoveFromAttentionSetOp}
+ * before performing an attention set update.
+ */
+@FunctionalInterface
+public interface AttentionSetUpdateCondition {
+  /**
+   * Checks whether the condition is fulfilled and the attention set update should be performed.
+   *
+   * @return {@code true} if the attention set should be updated, {@code false} if the attention set
+   *     should not be updated
+   */
+  boolean check();
+}
diff --git a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index a080d15..2f2cff9 100644
--- a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -79,14 +79,16 @@
       // abandonInactiveOpenChanges skips failures instead of throwing, so retrying will never
       // actually happen. For the purposes of this class that is fine: they'll get tried again the
       // next time the scheduled task is run.
-      retryHelper
-          .changeUpdate(
-              "abandonInactiveOpenChanges",
-              updateFactory -> {
-                abandonUtil.abandonInactiveOpenChanges(updateFactory);
-                return null;
-              })
-          .call();
+      @SuppressWarnings("unused")
+      var unused =
+          retryHelper
+              .changeUpdate(
+                  "abandonInactiveOpenChanges",
+                  updateFactory -> {
+                    abandonUtil.abandonInactiveOpenChanges(updateFactory);
+                    return null;
+                  })
+              .call();
     } catch (RestApiException | UpdateException e) {
       logger.atSevere().withCause(e).log("Failed to cleanup changes.");
     }
diff --git a/java/com/google/gerrit/server/change/ChangeFinder.java b/java/com/google/gerrit/server/change/ChangeFinder.java
index 9f253de..5668c27 100644
--- a/java/com/google/gerrit/server/change/ChangeFinder.java
+++ b/java/com/google/gerrit/server/change/ChangeFinder.java
@@ -208,6 +208,12 @@
     return Optional.of(notes.get(0));
   }
 
+  /**
+   * @deprecated this method is not reliable in Gerrit instances with imported changes, since
+   *     multiple changes can have the same change number and make the `changeIdProjectCache` cache
+   *     pointless.
+   */
+  @Deprecated(since = "3.10", forRemoval = true)
   public List<ChangeNotes> find(Change.Id id) {
     String project = changeIdProjectCache.getIfPresent(id);
     if (project != null) {
@@ -245,7 +251,7 @@
     // this case.)
     Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(cds.size());
     for (ChangeData cd : cds) {
-      if (seen.add(cd.getId())) {
+      if (seen.add(cd.virtualId())) {
         try {
           notes.add(cd.notes());
         } catch (NoSuchChangeException e) {
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index f32b2eb..ef36bd4 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -72,6 +72,7 @@
 import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.patch.DiffOperationsForCommitValidation;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -125,6 +126,7 @@
   private final MessageIdGenerator messageIdGenerator;
   private final AutoMerger autoMerger;
   private final ChangeUtil changeUtil;
+  private final DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory;
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
@@ -179,6 +181,7 @@
       MessageIdGenerator messageIdGenerator,
       AutoMerger autoMerger,
       ChangeUtil changeUtil,
+      DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory,
       @Assisted Change.Id changeId,
       @Assisted ObjectId commitId,
       @Assisted String refName) {
@@ -198,6 +201,7 @@
     this.messageIdGenerator = messageIdGenerator;
     this.autoMerger = autoMerger;
     this.changeUtil = changeUtil;
+    this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory;
 
     this.changeId = changeId;
     this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
@@ -451,10 +455,7 @@
     ctx.addRefUpdate(cmd);
     Optional<ReceiveCommand> autoMerge =
         autoMerger.createAutoMergeCommitIfNecessary(
-            ctx.getRepoView(),
-            ctx.getRevWalk(),
-            ctx.getInserter(),
-            ctx.getRevWalk().parseCommit(commitId));
+            ctx.getRepoView(), ctx.getInserter(), ctx.getRevWalk().parseCommit(commitId));
     if (autoMerge.isPresent()) {
       ctx.addRefUpdate(autoMerge.get());
     }
@@ -652,7 +653,9 @@
               ctx.getRepoView().getConfig(),
               ctx.getRevWalk().getObjectReader(),
               commitId,
-              ctx.getIdentifiedUser())) {
+              ctx.getIdentifiedUser(),
+              diffOperationsForCommitValidationFactory.create(
+                  ctx.getRepoView(), ctx.getInserter()))) {
         commitValidatorsFactory
             .forGerritCommits(
                 permissionBackend.user(ctx.getUser()).project(ctx.getProject()),
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 2fce475..0abe9cf 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -53,6 +53,7 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
@@ -62,7 +63,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
@@ -85,7 +85,6 @@
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -106,6 +105,9 @@
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -296,6 +298,7 @@
     logger.atFine().log("options = %s", options);
   }
 
+  @CanIgnoreReturnValue
   public ChangeJson fix(FixInput fix) {
     this.fix = fix;
     return this;
@@ -374,33 +377,47 @@
   }
 
   private static List<LegacySubmitRequirementInfo> requirementsFor(ChangeData cd) {
-    List<LegacySubmitRequirementInfo> reqInfos = new ArrayList<>();
-    for (SubmitRecord submitRecord : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
-      if (submitRecord.requirements == null) {
-        continue;
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get requirements", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      List<LegacySubmitRequirementInfo> reqInfos = new ArrayList<>();
+      for (SubmitRecord submitRecord : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
+        if (submitRecord.requirements == null) {
+          continue;
+        }
+        for (LegacySubmitRequirement requirement : submitRecord.requirements) {
+          reqInfos.add(requirementToInfo(requirement, submitRecord.status));
+        }
       }
-      for (LegacySubmitRequirement requirement : submitRecord.requirements) {
-        reqInfos.add(requirementToInfo(requirement, submitRecord.status));
-      }
+      return reqInfos;
     }
-    return reqInfos;
   }
 
   private List<SubmitRecordInfo> submitRecordsFor(ChangeData cd) {
-    List<SubmitRecordInfo> submitRecordInfos = new ArrayList<>();
-    for (SubmitRecord record : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
-      submitRecordInfos.add(submitRecordToInfo(record));
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get submit records", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      List<SubmitRecordInfo> submitRecordInfos = new ArrayList<>();
+      for (SubmitRecord record : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
+        submitRecordInfos.add(submitRecordToInfo(record));
+      }
+      return submitRecordInfos;
     }
-    return submitRecordInfos;
   }
 
   private List<SubmitRequirementResultInfo> submitRequirementsFor(ChangeData cd) {
-    List<SubmitRequirementResultInfo> reqInfos = new ArrayList<>();
-    cd.submitRequirementsIncludingLegacy().entrySet().stream()
-        .filter(entry -> !entry.getValue().isHidden())
-        .forEach(
-            entry -> reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue())));
-    return reqInfos;
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get submit requirements",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      List<SubmitRequirementResultInfo> reqInfos = new ArrayList<>();
+      cd.submitRequirementsIncludingLegacy().entrySet().stream()
+          .filter(entry -> !entry.getValue().isHidden())
+          .forEach(
+              entry ->
+                  reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue())));
+      return reqInfos;
+    }
   }
 
   private static LegacySubmitRequirementInfo requirementToInfo(
@@ -475,25 +492,30 @@
     }
   }
 
-  private void ensureLoaded(Iterable<ChangeData> all) {
+  private void ensureLoaded(Collection<ChangeData> all) {
     if (lazyLoad) {
-      for (ChangeData cd : all) {
-        // Mark all ChangeDatas as coming from the index, but allow backfilling data from NoteDb
-        cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Load change data for lazyLoad options",
+              Metadata.builder().resourceCount(all.size()).build())) {
+        for (ChangeData cd : all) {
+          // Mark all ChangeDatas as coming from the index, but allow backfilling data from NoteDb
+          cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
+        }
+        ChangeData.ensureChangeLoaded(all);
+        if (has(ALL_REVISIONS)) {
+          ChangeData.ensureAllPatchSetsLoaded(all);
+        } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
+          ChangeData.ensureCurrentPatchSetLoaded(all);
+        }
+        if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
+          ChangeData.ensureReviewedByLoadedForOpenChanges(all);
+        }
+        if (has(STAR) && userProvider.get().isIdentifiedUser()) {
+          ChangeData.ensureChangeServerId(all);
+        }
+        ChangeData.ensureCurrentApprovalsLoaded(all);
       }
-      ChangeData.ensureChangeLoaded(all);
-      if (has(ALL_REVISIONS)) {
-        ChangeData.ensureAllPatchSetsLoaded(all);
-      } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
-        ChangeData.ensureCurrentPatchSetLoaded(all);
-      }
-      if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
-        ChangeData.ensureReviewedByLoadedForOpenChanges(all);
-      }
-      if (has(STAR) && userProvider.get().isIdentifiedUser()) {
-        ChangeData.ensureChangeServerId(all);
-      }
-      ChangeData.ensureCurrentApprovalsLoaded(all);
     } else {
       for (ChangeData cd : all) {
         // Mark all ChangeDatas as coming from the index. Disallow using NoteDb
@@ -528,7 +550,8 @@
           }
           continue;
         }
-        ChangeInfo info = cache.get(cd.getId());
+        Change.Id cdUniqueId = cd.virtualId();
+        ChangeInfo info = cache.get(cdUniqueId);
         if (info != null && isCacheable) {
           changeInfos.add(info);
           continue;
@@ -540,7 +563,7 @@
           info = format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId()));
           changeInfos.add(info);
           if (isCacheable) {
-            cache.put(Change.id(info._number), info);
+            cache.put(cdUniqueId, info);
           }
         } catch (RuntimeException e) {
           Optional<RequestCancelledException> requestCancelledException =
@@ -619,10 +642,12 @@
     if (has(CHECK)) {
       out.problems = checkerProvider.get().check(cd.notes(), fix).problems();
       // If any problems were fixed, the ChangeData needs to be reloaded.
-      for (ProblemInfo p : out.problems) {
-        if (p.status == ProblemInfo.Status.FIXED) {
+      if (out.problems.stream().anyMatch(p -> p.status == ProblemInfo.Status.FIXED)) {
+        try (TraceTimer timer =
+            TraceContext.newTimer(
+                "Reload change data after fixing a problem",
+                Metadata.builder().changeId(cd.change().getChangeId()).build())) {
           cd = changeDataFactory.create(cd.project(), cd.getId());
-          break;
         }
       }
     }
@@ -630,6 +655,7 @@
     Change in = cd.change();
     out.project = in.getProject().get();
     out.branch = in.getDest().shortName();
+    out.currentRevisionNumber = in.currentPatchSetId().get();
     out.topic = in.getTopic();
     if (!cd.attentionSet().isEmpty()) {
       out.removedFromAttentionSet =
@@ -679,28 +705,18 @@
     out.setCreated(in.getCreatedOn());
     out.setUpdated(in.getLastUpdatedOn());
     out._number = in.getId().get();
-    out.totalCommentCount = cd.totalCommentCount();
-    out.unresolvedCommentCount = cd.unresolvedCommentCount();
 
-    if (cd.getRefStates() != null) {
-      String metaName = RefNames.changeMetaRef(cd.getId());
-      Optional<RefState> metaState =
-          cd.getRefStates().values().stream().filter(r -> r.ref().equals(metaName)).findAny();
-
-      // metaState should always be there, but it doesn't hurt to be extra careful.
-      metaState.ifPresent(rs -> out.metaRevId = rs.id().getName());
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Count comments", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      out.totalCommentCount = cd.totalCommentCount();
+      out.unresolvedCommentCount = cd.unresolvedCommentCount();
     }
 
-    if (user.isIdentifiedUser()) {
-      if (cd.isStarred(user.getAccountId())) {
-        out.starred = true;
-      }
-    }
+    getMetaState(cd).ifPresent(id -> out.metaRevId = id.getName());
 
-    if (in.isNew() && has(REVIEWED) && user.isIdentifiedUser()) {
-      out.reviewed = cd.isReviewedBy(user.getAccountId()) ? true : null;
-    }
-
+    out.reviewed = isReviewedByCurrentUser(cd, user);
+    out.starred = isStarredByCurrentUser(cd, user);
     out.labels = labelsJson.labelsFor(accountLoader, cd, has(LABELS), has(DETAILED_LABELS));
     out.requirements = requirementsFor(cd);
     out.submitRecords = submitRecordsFor(cd);
@@ -747,7 +763,7 @@
 
     boolean needMessages = has(MESSAGES);
     boolean needRevisions = has(ALL_REVISIONS) || has(CURRENT_REVISION) || limitToPsId.isPresent();
-    Map<PatchSet.Id, PatchSet> src;
+    ImmutableMap<PatchSet.Id, PatchSet> src;
     if (needMessages || needRevisions) {
       src = loadPatchSets(cd, limitToPsId);
     } else {
@@ -777,11 +793,15 @@
     }
 
     if (has(TRACKING_IDS)) {
-      ListMultimap<String, String> set = trackingFooters.extract(cd.commitFooters());
-      out.trackingIds =
-          set.entries().stream()
-              .map(e -> new TrackingIdInfo(e.getKey(), e.getValue()))
-              .collect(toList());
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Get tracking IDs", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+        ListMultimap<String, String> set = trackingFooters.extract(cd.commitFooters());
+        out.trackingIds =
+            set.entries().stream()
+                .map(e -> new TrackingIdInfo(e.getKey(), e.getValue()))
+                .collect(toList());
+      }
     }
 
     out._virtualIdNumber = cd.virtualId().get();
@@ -791,142 +811,206 @@
 
   private Map<ReviewerState, Collection<AccountInfo>> reviewerMap(
       ReviewerSet reviewers, ReviewerByEmailSet reviewersByEmail, boolean includeRemoved) {
-    Map<ReviewerState, Collection<AccountInfo>> reviewerMap = new HashMap<>();
-    for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
-      if (!includeRemoved && state == ReviewerStateInternal.REMOVED) {
-        continue;
+    try (TraceTimer timer = TraceContext.newTimer("Get reviewer map", Metadata.empty())) {
+      Map<ReviewerState, Collection<AccountInfo>> reviewerMap = new HashMap<>();
+      for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
+        if (!includeRemoved && state == ReviewerStateInternal.REMOVED) {
+          continue;
+        }
+        List<AccountInfo> reviewersByState = toAccountInfo(reviewers.byState(state));
+        reviewersByState.addAll(toAccountInfoByEmail(reviewersByEmail.byState(state)));
+        if (!reviewersByState.isEmpty()) {
+          reviewerMap.put(state.asReviewerState(), reviewersByState);
+        }
       }
-      Collection<AccountInfo> reviewersByState = toAccountInfo(reviewers.byState(state));
-      reviewersByState.addAll(toAccountInfoByEmail(reviewersByEmail.byState(state)));
-      if (!reviewersByState.isEmpty()) {
-        reviewerMap.put(state.asReviewerState(), reviewersByState);
-      }
+      return reviewerMap;
     }
-    return reviewerMap;
   }
 
   private List<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd) {
-    List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
-    List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
-    for (ReviewerStatusUpdate c : reviewerUpdates) {
-      ReviewerUpdateInfo change =
-          new ReviewerUpdateInfo(
-              c.date(),
-              accountLoader.get(c.updatedBy()),
-              accountLoader.get(c.reviewer()),
-              c.state().asReviewerState());
-      result.add(change);
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get reviewer updates",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
+      List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
+      for (ReviewerStatusUpdate c : reviewerUpdates) {
+        if (c.reviewer().isPresent()) {
+          result.add(
+              new ReviewerUpdateInfo(
+                  c.date(),
+                  accountLoader.get(c.updatedBy()),
+                  accountLoader.get(c.reviewer().get()),
+                  c.state().asReviewerState()));
+        }
+
+        if (c.reviewerByEmail().isPresent()) {
+          result.add(
+              new ReviewerUpdateInfo(
+                  c.date(),
+                  accountLoader.get(c.updatedBy()),
+                  toAccountInfoByEmail(c.reviewerByEmail().get()),
+                  c.state().asReviewerState()));
+        }
+      }
+      return result;
     }
-    return result;
   }
 
   private boolean submittable(ChangeData cd) {
-    return cd.submitRequirementsIncludingLegacy().values().stream()
-        .allMatch(SubmitRequirementResult::fulfilled);
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Compute submittability",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      return cd.submitRequirementsIncludingLegacy().values().stream()
+          .allMatch(SubmitRequirementResult::fulfilled);
+    }
+  }
+
+  private Optional<ObjectId> getMetaState(ChangeData cd) {
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get change meta ref",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      return cd.metaRevision();
+    }
+  }
+
+  private Boolean isReviewedByCurrentUser(ChangeData cd, CurrentUser user) {
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get reviewed by", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      return toBoolean(
+          cd.change().isNew()
+              && has(REVIEWED)
+              && user.isIdentifiedUser()
+              && cd.isReviewedBy(user.getAccountId()));
+    }
+  }
+
+  private Boolean isStarredByCurrentUser(ChangeData cd, CurrentUser user) {
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get starred by", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      return toBoolean(user.isIdentifiedUser() && cd.isStarred(user.getAccountId()));
+    }
   }
 
   private void setSubmitter(ChangeData cd, ChangeInfo out) {
-    Optional<PatchSetApproval> s = cd.getSubmitApproval();
-    if (!s.isPresent()) {
-      return;
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Set submitter", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      Optional<PatchSetApproval> s = cd.getSubmitApproval();
+      if (!s.isPresent()) {
+        return;
+      }
+      out.setSubmitted(s.get().granted(), accountLoader.get(s.get().accountId()));
     }
-    out.setSubmitted(s.get().granted(), accountLoader.get(s.get().accountId()));
   }
 
   private ImmutableList<ChangeMessageInfo> messages(ChangeData cd) {
-    List<ChangeMessage> messages = cmUtil.byChange(cd.notes());
-    if (messages.isEmpty()) {
-      return ImmutableList.of();
-    }
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get messages", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      List<ChangeMessage> messages = cmUtil.byChange(cd.notes());
+      if (messages.isEmpty()) {
+        return ImmutableList.of();
+      }
 
-    List<ChangeMessageInfo> result = Lists.newArrayListWithCapacity(messages.size());
-    for (ChangeMessage message : messages) {
-      result.add(createChangeMessageInfo(message, accountLoader));
+      List<ChangeMessageInfo> result = Lists.newArrayListWithCapacity(messages.size());
+      for (ChangeMessage message : messages) {
+        result.add(createChangeMessageInfo(message, accountLoader));
+      }
+      return ImmutableList.copyOf(result);
     }
-    return ImmutableList.copyOf(result);
   }
 
   private List<AccountInfo> removableReviewers(ChangeData cd, ChangeInfo out)
       throws PermissionBackendException {
-    // Although this is called removableReviewers, this method also determines
-    // which CCs are removable.
-    //
-    // For reviewers, we need to look at each approval, because the reviewer
-    // should only be considered removable if *all* of their approvals can be
-    // removed. First, add all reviewers with *any* removable approval to the
-    // "removable" set. Along the way, if we encounter a non-removable approval,
-    // add the reviewer to the "fixed" set. Before we return, remove all members
-    // of "fixed" from "removable", because not all of their approvals can be
-    // removed.
-    Collection<LabelInfo> labels = out.labels.values();
-    Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
-    Set<Account.Id> removable = new HashSet<>();
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get removable reviewers",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      // Although this is called removableReviewers, this method also determines
+      // which CCs are removable.
+      //
+      // For reviewers, we need to look at each approval, because the reviewer
+      // should only be considered removable if *all* of their approvals can be
+      // removed. First, add all reviewers with *any* removable approval to the
+      // "removable" set. Along the way, if we encounter a non-removable approval,
+      // add the reviewer to the "fixed" set. Before we return, remove all members
+      // of "fixed" from "removable", because not all of their approvals can be
+      // removed.
+      Collection<LabelInfo> labels = out.labels.values();
+      Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
+      Set<Account.Id> removable = new HashSet<>();
 
-    // Add all reviewers, which will later be removed if they are in the "fixed" set.
-    removable.addAll(
-        out.reviewers.getOrDefault(ReviewerState.REVIEWER, Collections.emptySet()).stream()
-            .filter(a -> a._accountId != null)
-            .map(a -> Account.id(a._accountId))
-            .collect(Collectors.toSet()));
+      // Add all reviewers, which will later be removed if they are in the "fixed" set.
+      removable.addAll(
+          out.reviewers.getOrDefault(ReviewerState.REVIEWER, Collections.emptySet()).stream()
+              .filter(a -> a._accountId != null)
+              .map(a -> Account.id(a._accountId))
+              .collect(Collectors.toSet()));
 
-    // Check if the user has the permission to remove a reviewer. This means we can bypass the
-    // testRemoveReviewer check for a specific reviewer in the loop saving potentially many
-    // permission checks.
-    boolean canRemoveAnyReviewer =
-        permissionBackend
-            .user(userProvider.get())
-            .change(cd)
-            .test(ChangePermission.REMOVE_REVIEWER);
-    for (LabelInfo label : labels) {
-      if (label.all == null) {
-        continue;
-      }
-      for (ApprovalInfo ai : label.all) {
-        Account.Id id = Account.id(ai._accountId);
-
-        if (!canRemoveAnyReviewer
-            && !removeReviewerControl.testRemoveReviewer(
-                cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
-          fixed.add(id);
+      // Check if the user has the permission to remove a reviewer. This means we can bypass the
+      // testRemoveReviewer check for a specific reviewer in the loop saving potentially many
+      // permission checks.
+      boolean canRemoveAnyReviewer =
+          permissionBackend
+              .user(userProvider.get())
+              .change(cd)
+              .test(ChangePermission.REMOVE_REVIEWER);
+      for (LabelInfo label : labels) {
+        if (label.all == null) {
+          continue;
         }
-      }
-    }
-
-    // CCs are simpler than reviewers. They are removable if the ChangeControl
-    // would permit a non-negative approval by that account to be removed, in
-    // which case add them to removable. We don't need to add unremovable CCs to
-    // "fixed" because we only visit each CC once here.
-    Collection<AccountInfo> ccs = out.reviewers.get(ReviewerState.CC);
-    if (ccs != null) {
-      for (AccountInfo ai : ccs) {
-        if (ai._accountId != null) {
+        for (ApprovalInfo ai : label.all) {
           Account.Id id = Account.id(ai._accountId);
-          if (canRemoveAnyReviewer
-              || removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
-            removable.add(id);
+
+          if (!canRemoveAnyReviewer
+              && !removeReviewerControl.testRemoveReviewer(
+                  cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
+            fixed.add(id);
           }
         }
       }
-    }
 
-    // Subtract any reviewers with non-removable approvals from the "removable"
-    // set. This also subtracts any CCs that for some reason also hold
-    // unremovable approvals.
-    removable.removeAll(fixed);
-
-    List<AccountInfo> result = Lists.newArrayListWithCapacity(removable.size());
-    for (Account.Id id : removable) {
-      result.add(accountLoader.get(id));
-    }
-    // Reviewers added by email are always removable
-    for (Collection<AccountInfo> infos : out.reviewers.values()) {
-      for (AccountInfo info : infos) {
-        if (info._accountId == null) {
-          result.add(info);
+      // CCs are simpler than reviewers. They are removable if the ChangeControl
+      // would permit a non-negative approval by that account to be removed, in
+      // which case add them to removable. We don't need to add unremovable CCs to
+      // "fixed" because we only visit each CC once here.
+      Collection<AccountInfo> ccs = out.reviewers.get(ReviewerState.CC);
+      if (ccs != null) {
+        for (AccountInfo ai : ccs) {
+          if (ai._accountId != null) {
+            Account.Id id = Account.id(ai._accountId);
+            if (canRemoveAnyReviewer
+                || removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
+              removable.add(id);
+            }
+          }
         }
       }
+
+      // Subtract any reviewers with non-removable approvals from the "removable"
+      // set. This also subtracts any CCs that for some reason also hold
+      // unremovable approvals.
+      removable.removeAll(fixed);
+
+      List<AccountInfo> result = Lists.newArrayListWithCapacity(removable.size());
+      for (Account.Id id : removable) {
+        result.add(accountLoader.get(id));
+      }
+      // Reviewers added by email are always removable
+      for (Collection<AccountInfo> infos : out.reviewers.values()) {
+        for (AccountInfo info : infos) {
+          if (info._accountId == null) {
+            result.add(info);
+          }
+        }
+      }
+      return result;
     }
-    return result;
   }
 
   private List<AccountInfo> toAccountInfo(Collection<Account.Id> accounts) {
@@ -936,39 +1020,47 @@
         .collect(toList());
   }
 
+  private AccountInfo toAccountInfoByEmail(Address address) {
+    return new AccountInfo(address.name(), address.email());
+  }
+
   private List<AccountInfo> toAccountInfoByEmail(Collection<Address> addresses) {
     return addresses.stream()
-        .map(a -> new AccountInfo(a.name(), a.email()))
+        .map(this::toAccountInfoByEmail)
         .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
         .collect(toList());
   }
 
-  private Map<PatchSet.Id, PatchSet> loadPatchSets(
+  private ImmutableMap<PatchSet.Id, PatchSet> loadPatchSets(
       ChangeData cd, Optional<PatchSet.Id> limitToPsId) {
-    Collection<PatchSet> src;
-    if (has(ALL_REVISIONS) || has(MESSAGES)) {
-      src = cd.patchSets();
-    } else {
-      PatchSet ps;
-      if (limitToPsId.isPresent()) {
-        ps = cd.patchSet(limitToPsId.get());
-        if (ps == null) {
-          throw new StorageException("missing patch set " + limitToPsId.get());
-        }
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Load patch sets", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      Collection<PatchSet> src;
+      if (has(ALL_REVISIONS) || has(MESSAGES)) {
+        src = cd.patchSets();
       } else {
-        ps = cd.currentPatchSet();
-        if (ps == null) {
-          throw new StorageException("missing current patch set for change " + cd.getId());
+        PatchSet ps;
+        if (limitToPsId.isPresent()) {
+          ps = cd.patchSet(limitToPsId.get());
+          if (ps == null) {
+            throw new StorageException("missing patch set " + limitToPsId.get());
+          }
+        } else {
+          ps = cd.currentPatchSet();
+          if (ps == null) {
+            throw new StorageException("missing current patch set for change " + cd.getId());
+          }
         }
+        src = Collections.singletonList(ps);
       }
-      src = Collections.singletonList(ps);
+      // Sort by patch set ID in increasing order to have a stable output.
+      ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> map = ImmutableSortedMap.naturalOrder();
+      for (PatchSet patchSet : src) {
+        map.put(patchSet.id(), patchSet);
+      }
+      return map.build();
     }
-    // Sort by patch set ID in increasing order to have a stable output.
-    ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> map = ImmutableSortedMap.naturalOrder();
-    for (PatchSet patchSet : src) {
-      map.put(patchSet.id(), patchSet);
-    }
-    return map.build();
   }
 
   /** Populate the 'starred' field. */
@@ -991,14 +1083,18 @@
     }
   }
 
-  private List<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
+  private ImmutableList<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
     return getPluginInfos(Collections.singleton(cd)).get(cd.getId());
   }
 
   private ImmutableListMultimap<Change.Id, PluginDefinedInfo> getPluginInfos(
       Collection<ChangeData> cds) {
     if (pluginDefinedInfosFactory.isPresent()) {
-      return pluginDefinedInfosFactory.get().createPluginDefinedInfos(cds);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Get plugin infos", Metadata.builder().resourceCount(cds.size()).build())) {
+        return pluginDefinedInfosFactory.get().createPluginDefinedInfos(cds);
+      }
     }
     return ImmutableListMultimap.of();
   }
@@ -1023,4 +1119,9 @@
     info.owner = new AccountInfo(c.getOwner().get());
     return Optional.of(info);
   }
+
+  @Nullable
+  private static Boolean toBoolean(boolean value) {
+    return value ? true : null;
+  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 1e14954..74aa373 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -21,6 +21,7 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.Weigher;
 import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
@@ -46,7 +47,6 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Objects;
-import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.errors.LargeObjectException;
@@ -272,12 +272,12 @@
     }
 
     private static boolean sameRestOfParents(RevCommit prior, RevCommit next) {
-      Set<RevCommit> priorRestParents = allExceptFirstParent(prior.getParents());
-      Set<RevCommit> nextRestParents = allExceptFirstParent(next.getParents());
+      ImmutableSet<RevCommit> priorRestParents = allExceptFirstParent(prior.getParents());
+      ImmutableSet<RevCommit> nextRestParents = allExceptFirstParent(next.getParents());
       return priorRestParents.equals(nextRestParents);
     }
 
-    private static Set<RevCommit> allExceptFirstParent(RevCommit[] parents) {
+    private static ImmutableSet<RevCommit> allExceptFirstParent(RevCommit[] parents) {
       return FluentIterable.from(Arrays.asList(parents)).skip(1).toSet();
     }
 
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index fe7fd8e..8300541 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -255,7 +255,15 @@
 
   private void hashAccount(Hasher h, AccountState accountState, byte[] buf) {
     h.putInt(accountState.account().id().get());
-    h.putString(MoreObjects.firstNonNull(accountState.account().metaId(), ZERO_ID_STRING), UTF_8);
+    // Based on the code, it seems the metaId should never be null in this place and so the
+    // uniqueTag.
+    // However, the null-check for metaId has been existed here for some time already - for safety
+    // the same check is applied to uniqueTag.
+    h.putString(
+        MoreObjects.firstNonNull(
+            accountState.account().uniqueTag(),
+            MoreObjects.firstNonNull(accountState.account().metaId(), ZERO_ID_STRING)),
+        UTF_8);
     accountState.externalIds().stream().forEach(e -> hashObjectId(h, e.blobId(), buf));
   }
 }
diff --git a/java/com/google/gerrit/server/change/CommentThread.java b/java/com/google/gerrit/server/change/CommentThread.java
index 0265f60..51422b1 100644
--- a/java/com/google/gerrit/server/change/CommentThread.java
+++ b/java/com/google/gerrit/server/change/CommentThread.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Streams;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import java.util.List;
@@ -58,6 +59,7 @@
 
     public abstract Builder<T> comments(List<T> value);
 
+    @CanIgnoreReturnValue
     public Builder<T> addComment(T comment) {
       commentsBuilder().add(comment);
       return this;
diff --git a/java/com/google/gerrit/server/change/CommentsValidator.java b/java/com/google/gerrit/server/change/CommentsValidator.java
new file mode 100644
index 0000000..0cdd9b6
--- /dev/null
+++ b/java/com/google/gerrit/server/change/CommentsValidator.java
@@ -0,0 +1,251 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static java.util.stream.Collectors.groupingBy;
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.client.Comment.Range;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.DiffSummary;
+import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.lib.ObjectId;
+
+@Singleton
+public class CommentsValidator {
+
+  private final CommentsUtil commentsUtil;
+  private final PatchListCache patchListCache;
+
+  @Inject
+  CommentsValidator(CommentsUtil commentsUtil, PatchListCache patchListCache) {
+    this.commentsUtil = commentsUtil;
+    this.patchListCache = patchListCache;
+  }
+
+  public static void ensureFixSuggestionsAreAddable(
+      List<FixSuggestionInfo> fixSuggestionInfos, String commentPath) throws BadRequestException {
+    if (fixSuggestionInfos == null) {
+      return;
+    }
+
+    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
+      ensureDescriptionIsSet(commentPath, fixSuggestionInfo.description);
+      ensureFixReplacementsAreAddable(commentPath, fixSuggestionInfo.replacements);
+    }
+  }
+
+  public <T extends com.google.gerrit.extensions.client.Comment> void checkComments(
+      RevisionResource revision, Map<String, List<T>> commentsPerPath)
+      throws BadRequestException, PatchListNotAvailableException {
+    Set<String> revisionFilePaths = getAffectedFilePaths(revision);
+    for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
+      String path = entry.getKey();
+      PatchSet.Id patchSetId = revision.getPatchSet().id();
+      ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);
+
+      List<T> comments = entry.getValue();
+      for (T comment : comments) {
+        ensureLineIsNonNegative(comment.line, path);
+        ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
+        ensureRangeIsValid(path, comment.range);
+        ensureValidPatchsetLevelComment(path, comment);
+        ensureValidInReplyTo(revision.getNotes(), comment.inReplyTo);
+        ensureFixSuggestionsAreAddable(comment.fixSuggestions, path);
+      }
+    }
+  }
+
+  private void ensureValidInReplyTo(ChangeNotes changeNotes, String inReplyTo)
+      throws BadRequestException {
+    if (inReplyTo != null
+        && !commentsUtil.getPublishedHumanComment(changeNotes, inReplyTo).isPresent()
+        && !commentsUtil.getRobotComment(changeNotes, inReplyTo).isPresent()) {
+      throw new BadRequestException(
+          String.format("Invalid inReplyTo, comment %s not found", inReplyTo));
+    }
+  }
+
+  private Set<String> getAffectedFilePaths(RevisionResource revision)
+      throws PatchListNotAvailableException {
+    ObjectId newId = revision.getPatchSet().commitId();
+    DiffSummaryKey key =
+        DiffSummaryKey.fromPatchListKey(
+            PatchListKey.againstDefaultBase(newId, Whitespace.IGNORE_NONE));
+    DiffSummary ds = patchListCache.getDiffSummary(key, revision.getProject());
+    return new HashSet<>(ds.getPaths());
+  }
+
+  private static void ensurePathRefersToAvailableOrMagicFile(
+      String path, Set<String> availableFilePaths, PatchSet.Id patchSetId)
+      throws BadRequestException {
+    if (!availableFilePaths.contains(path) && !Patch.isMagic(path)) {
+      throw new BadRequestException(
+          String.format("file %s not found in revision %s", path, patchSetId));
+    }
+  }
+
+  private static void ensureLineIsNonNegative(Integer line, String path)
+      throws BadRequestException {
+    if (line != null && line < 0) {
+      throw new BadRequestException(
+          String.format("negative line number %d not allowed on %s", line, path));
+    }
+  }
+
+  private static <T extends com.google.gerrit.extensions.client.Comment>
+      void ensureCommentNotOnMagicFilesOfAutoMerge(String path, T comment)
+          throws BadRequestException {
+    if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
+      throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
+    }
+  }
+
+  private static <T extends com.google.gerrit.extensions.client.Comment>
+      void ensureValidPatchsetLevelComment(String path, T comment) throws BadRequestException {
+    if (path.equals(PATCHSET_LEVEL)
+        && (comment.side != null || comment.range != null || comment.line != null)) {
+      throw new BadRequestException("Patchset-level comments can't have side, range, or line");
+    }
+  }
+
+  private static void ensureFixReplacementsAreAddable(
+      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
+    ensureReplacementsArePresent(commentPath, fixReplacementInfos);
+
+    for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
+      ensureReplacementPathIsSetAndNotPatchsetLevel(commentPath, fixReplacementInfo.path);
+      ensureRangeIsSet(commentPath, fixReplacementInfo.range);
+      ensureRangeIsValid(commentPath, fixReplacementInfo.range);
+      ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
+    }
+
+    Map<String, List<FixReplacementInfo>> replacementsPerFilePath =
+        fixReplacementInfos.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
+    for (List<FixReplacementInfo> sameFileReplacements : replacementsPerFilePath.values()) {
+      ensureRangesDoNotOverlap(commentPath, sameFileReplacements);
+    }
+  }
+
+  private static void ensureReplacementsArePresent(
+      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
+    if (fixReplacementInfos == null || fixReplacementInfos.isEmpty()) {
+      throw new BadRequestException(
+          String.format(
+              "At least one replacement is "
+                  + "required for the suggested fix of the comment on %s",
+              commentPath));
+    }
+  }
+
+  private static void ensureReplacementPathIsSetAndNotPatchsetLevel(
+      String commentPath, String replacementPath) throws BadRequestException {
+    if (replacementPath == null) {
+      throw new BadRequestException(
+          String.format(
+              "A file path must be given for the replacement of the comment on %s", commentPath));
+    }
+    if (replacementPath.equals(PATCHSET_LEVEL)) {
+      throw new BadRequestException(
+          String.format(
+              "A file path must not be %s for the replacement of the comment on %s",
+              PATCHSET_LEVEL, commentPath));
+    }
+  }
+
+  private static void ensureRangeIsSet(String commentPath, Range range) throws BadRequestException {
+    if (range == null) {
+      throw new BadRequestException(
+          String.format(
+              "A range must be given for the replacement of the comment on %s", commentPath));
+    }
+  }
+
+  private static void ensureRangeIsValid(String commentPath, Range range)
+      throws BadRequestException {
+    if (range == null) {
+      return;
+    }
+    if (!range.isValid()) {
+      throw new BadRequestException(
+          String.format(
+              "Range (%s:%s - %s:%s) is not valid for the comment on %s",
+              range.startLine,
+              range.startCharacter,
+              range.endLine,
+              range.endCharacter,
+              commentPath));
+    }
+  }
+
+  private static void ensureReplacementStringIsSet(String commentPath, String replacement)
+      throws BadRequestException {
+    if (replacement == null) {
+      throw new BadRequestException(
+          String.format(
+              "A content for replacement "
+                  + "must be indicated for the replacement of the comment on %s",
+              commentPath));
+    }
+  }
+
+  private static void ensureRangesDoNotOverlap(
+      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
+    List<Range> sortedRanges =
+        fixReplacementInfos.stream()
+            .map(fixReplacementInfo -> fixReplacementInfo.range)
+            .sorted()
+            .collect(toList());
+
+    int previousEndLine = 0;
+    int previousOffset = -1;
+    for (Range range : sortedRanges) {
+      if (range.startLine < previousEndLine
+          || (range.startLine == previousEndLine && range.startCharacter < previousOffset)) {
+        throw new BadRequestException(
+            String.format("Replacements overlap for the comment on %s", commentPath));
+      }
+      previousEndLine = range.endLine;
+      previousOffset = range.endCharacter;
+    }
+  }
+
+  private static void ensureDescriptionIsSet(String commentPath, String description)
+      throws BadRequestException {
+    if (description == null) {
+      throw new BadRequestException(
+          String.format(
+              "A description is required for the suggested fix of the comment on %s", commentPath));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index b216db3..9f7a7fc 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -29,6 +29,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -778,6 +779,7 @@
     return null;
   }
 
+  @CanIgnoreReturnValue
   private ProblemInfo problem(String msg) {
     ProblemInfo p = new ProblemInfo();
     p.message = requireNonNull(msg);
diff --git a/java/com/google/gerrit/server/change/DeleteChangeOp.java b/java/com/google/gerrit/server/change/DeleteChangeOp.java
index 435f2f1..bcfa48a 100644
--- a/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.flogger.LazyArgs.lazy;
 
+import com.google.common.collect.ImmutableCollection;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -75,7 +76,7 @@
   // fail gracefully if the second delete fails, but fortunately that's not what happens.
   @Override
   public boolean updateChange(ChangeContext ctx) throws RestApiException, IOException {
-    Collection<PatchSet> patchSets = psUtil.byChange(ctx.getNotes());
+    ImmutableCollection<PatchSet> patchSets = psUtil.byChange(ctx.getNotes());
 
     ensureDeletable(ctx, id, patchSets);
     // Cleaning up is only possible as long as the change and its elements are
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 90cb9a9..4f001fb 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.mail.EmailFactories.REVIEWER_DELETED;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
@@ -229,8 +230,7 @@
   }
 
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
-    Iterable<PatchSetApproval> approvals;
-    approvals = ctx.getNotes().getApprovals().all().values();
+    ImmutableCollection<PatchSetApproval> approvals = ctx.getNotes().getApprovals().all().values();
     return Iterables.filter(approvals, psa -> accountId.equals(psa.accountId()));
   }
 
diff --git a/java/com/google/gerrit/server/change/DeleteReviewersUtil.java b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
index 79ed043..662ee6b 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
@@ -26,7 +27,6 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.util.Collection;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class DeleteReviewersUtil {
@@ -78,7 +78,7 @@
     throw new ResourceNotFoundException(reviewerInput.reviewer);
   }
 
-  private Collection<Account.Id> fetchAccountIds(ChangeNotes changeNotes) {
+  private ImmutableSet<Account.Id> fetchAccountIds(ChangeNotes changeNotes) {
     return approvalsUtil.getReviewers(changeNotes).all();
   }
 }
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
index b295469..30d82a4 100644
--- a/java/com/google/gerrit/server/change/EmailNewPatchSet.java
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -145,7 +145,8 @@
                   try {
                     asyncSender.run();
                   } finally {
-                    threadLocalRequestContext.setContext(old);
+                    @SuppressWarnings("unused")
+                    var unused = threadLocalRequestContext.setContext(old);
                   }
                 });
   }
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index 27e68a8..bb93cd3 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -228,7 +228,8 @@
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
       } finally {
-        requestContext.setContext(old);
+        @SuppressWarnings("unused")
+        var unused = requestContext.setContext(old);
       }
     }
 
diff --git a/java/com/google/gerrit/server/change/FilterIncludedIn.java b/java/com/google/gerrit/server/change/FilterIncludedIn.java
new file mode 100644
index 0000000..dbc88f1
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FilterIncludedIn.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.util.function.Predicate;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/**
+ * Filter branches and tags from the result of Included In.
+ *
+ * <p>Plugins can implement this interface to filter branches and tags from the result of Included
+ * In. The filter is applied after the default filtering and sort.
+ */
+@ExtensionPoint
+public interface FilterIncludedIn {
+
+  /**
+   * Returns a predicate for filtering branches.
+   *
+   * @param project the name of the project
+   * @param commit the commit for which it should do the filtering
+   * @return A predicate that returns true if the branch should be included in the result
+   */
+  public default Predicate<String> getBranchFilter(Project.NameKey project, RevCommit commit) {
+    return branch -> true;
+  }
+
+  /**
+   * Returns a predicate for filtering tags.
+   *
+   * @param project the name of the project
+   * @param commit the commit for which it should do the filtering
+   * @return A predicate that returns true if the tag should be included in the result
+   */
+  public default Predicate<String> getTagFilter(Project.NameKey project, RevCommit commit) {
+    return tag -> true;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
index 834a623..ad7deec 100644
--- a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
+++ b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
@@ -18,6 +18,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.index.IndexConfig;
@@ -98,7 +99,7 @@
       return Collections.emptyList();
     }
 
-    List<ChangeData> cds =
+    ImmutableList<ChangeData> cds =
         InternalChangeQuery.byProjectGroups(
             queryProvider, indexConfig, changeData.project(), groups);
     if (cds.isEmpty()) {
diff --git a/java/com/google/gerrit/server/change/IncludedIn.java b/java/com/google/gerrit/server/change/IncludedIn.java
index bc6579e..f104a57 100644
--- a/java/com/google/gerrit/server/change/IncludedIn.java
+++ b/java/com/google/gerrit/server/change/IncludedIn.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -41,6 +42,7 @@
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Constants;
@@ -56,15 +58,18 @@
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final PluginSetContext<ExternalIncludedIn> externalIncludedIn;
+  private final PluginSetContext<FilterIncludedIn> filterIncludedIn;
 
   @Inject
   IncludedIn(
       GitRepositoryManager repoManager,
       PermissionBackend permissionBackend,
-      PluginSetContext<ExternalIncludedIn> externalIncludedIn) {
+      PluginSetContext<ExternalIncludedIn> externalIncludedIn,
+      PluginSetContext<FilterIncludedIn> filterIncludedIn) {
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.externalIncludedIn = externalIncludedIn;
+    this.filterIncludedIn = filterIncludedIn;
   }
 
   public IncludedInInfo apply(Project.NameKey project, String revisionId)
@@ -94,13 +99,23 @@
               .collect(Collectors.toSet());
 
       // Filter branches and tags according to their visbility by the user
-      ImmutableSortedSet<String> filteredBranches =
+      Stream<String> filteredBranchesStream =
           sortedShortNames(
               filterReadableRefs(
                   project, getMatchingRefNames(allMatchingTagsAndBranches, branches)));
-      ImmutableSortedSet<String> filteredTags =
+      Stream<String> filteredTagsStream =
           sortedShortNames(
               filterReadableRefs(project, getMatchingRefNames(allMatchingTagsAndBranches, tags)));
+      for (PluginSetEntryContext<FilterIncludedIn> pluginFilter : filterIncludedIn) {
+        filteredBranchesStream =
+            filteredBranchesStream.filter(pluginFilter.get().getBranchFilter(project, rev));
+        filteredTagsStream =
+            filteredTagsStream.filter(pluginFilter.get().getTagFilter(project, rev));
+      }
+      ImmutableSortedSet<String> filteredBranches =
+          filteredBranchesStream.collect(toImmutableSortedSet(naturalOrder()));
+      ImmutableSortedSet<String> filteredTags =
+          filteredTagsStream.collect(toImmutableSortedSet(naturalOrder()));
 
       ListMultimap<String, String> external = MultimapBuilder.hashKeys().arrayListValues().build();
       externalIncludedIn.runEach(
@@ -146,9 +161,7 @@
         .collect(toImmutableList());
   }
 
-  private ImmutableSortedSet<String> sortedShortNames(Collection<String> refs) {
-    return refs.stream()
-        .map(Repository::shortenRefName)
-        .collect(toImmutableSortedSet(naturalOrder()));
+  private Stream<String> sortedShortNames(Collection<String> refs) {
+    return refs.stream().map(Repository::shortenRefName).sorted(naturalOrder());
   }
 }
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index e4118d5..ac22453 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -43,6 +43,9 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -99,12 +102,16 @@
       return null;
     }
 
-    LabelTypes labelTypes = cd.getLabelTypes();
-    Map<String, LabelWithStatus> withStatus =
-        cd.change().isMerged()
-            ? labelsForSubmittedChange(accountLoader, cd, labelTypes, standard, detailed)
-            : labelsForUnsubmittedChange(accountLoader, cd, labelTypes, standard, detailed);
-    return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get labels", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      LabelTypes labelTypes = cd.getLabelTypes();
+      Map<String, LabelWithStatus> withStatus =
+          cd.change().isMerged()
+              ? labelsForSubmittedChange(accountLoader, cd, labelTypes, standard, detailed)
+              : labelsForUnsubmittedChange(accountLoader, cd, labelTypes, standard, detailed);
+      return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
+    }
   }
 
   /**
@@ -118,30 +125,36 @@
    */
   Map<String, Collection<String>> permittedLabels(Account.Id filterApprovalsBy, ChangeData cd)
       throws PermissionBackendException {
-    SetMultimap<String, String> permitted = LinkedHashMultimap.create();
-    boolean isMerged = cd.change().isMerged();
-    Map<String, Short> currentUserVotes = currentLabels(filterApprovalsBy, cd);
-    for (LabelType labelType : cd.getLabelTypes().getLabelTypes()) {
-      if (isMerged && !labelType.isAllowPostSubmit()) {
-        continue;
-      }
-      Set<LabelPermission.WithValue> can =
-          permissionBackend.absentUser(filterApprovalsBy).change(cd).test(labelType);
-      for (LabelValue v : labelType.getValues()) {
-        boolean ok = can.contains(new LabelPermission.WithValue(labelType, v));
-        if (isMerged) {
-          // Votes cannot be decreased if the change is merged. Only accept the label value if it's
-          // greater or equal than the user's latest vote.
-          short prev = currentUserVotes.getOrDefault(labelType.getName(), (short) 0);
-          ok &= v.getValue() >= prev;
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get permitted labels",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      SetMultimap<String, String> permitted = LinkedHashMultimap.create();
+      boolean isMerged = cd.change().isMerged();
+      Map<String, Short> currentUserVotes = currentLabels(filterApprovalsBy, cd);
+      for (LabelType labelType : cd.getLabelTypes().getLabelTypes()) {
+        if (isMerged && !labelType.isAllowPostSubmit()) {
+          continue;
         }
-        if (ok) {
-          permitted.put(labelType.getName(), v.formatValue());
+        Set<LabelPermission.WithValue> can =
+            permissionBackend.absentUser(filterApprovalsBy).change(cd).test(labelType);
+        for (LabelValue v : labelType.getValues()) {
+          boolean ok = can.contains(new LabelPermission.WithValue(labelType, v));
+          if (isMerged) {
+            // Votes cannot be decreased if the change is merged. Only accept the label value if
+            // it's
+            // greater or equal than the user's latest vote.
+            short prev = currentUserVotes.getOrDefault(labelType.getName(), (short) 0);
+            ok &= v.getValue() >= prev;
+          }
+          if (ok) {
+            permitted.put(labelType.getName(), v.formatValue());
+          }
         }
       }
+      clearOnlyZerosEntries(permitted);
+      return permitted.asMap();
     }
-    clearOnlyZerosEntries(permitted);
-    return permitted.asMap();
   }
 
   /**
@@ -156,32 +169,37 @@
   Map<String, Map<String, List<AccountInfo>>> removableLabels(
       AccountLoader accountLoader, CurrentUser user, ChangeData cd)
       throws PermissionBackendException {
-    if (cd.change().isMerged()) {
-      return new HashMap<>();
-    }
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get removable labels",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      if (cd.change().isMerged()) {
+        return new HashMap<>();
+      }
 
-    Map<String, Map<String, List<AccountInfo>>> res = new HashMap<>();
-    LabelTypes labelTypes = cd.getLabelTypes();
-    for (PatchSetApproval approval : cd.currentApprovals()) {
-      Optional<LabelType> labelType = labelTypes.byLabel(approval.labelId());
-      if (!labelType.isPresent()) {
-        continue;
+      Map<String, Map<String, List<AccountInfo>>> res = new HashMap<>();
+      LabelTypes labelTypes = cd.getLabelTypes();
+      for (PatchSetApproval approval : cd.currentApprovals()) {
+        Optional<LabelType> labelType = labelTypes.byLabel(approval.labelId());
+        if (!labelType.isPresent()) {
+          continue;
+        }
+        if (!(deleteVoteControl.testDeleteVotePermissions(user, cd, approval, labelType.get())
+            || removeReviewerControl.testRemoveReviewer(
+                cd, user, approval.accountId(), approval.value()))) {
+          continue;
+        }
+        if (!res.containsKey(approval.label())) {
+          res.put(approval.label(), new HashMap<>());
+        }
+        String labelValue = LabelValue.formatValue(approval.value());
+        if (!res.get(approval.label()).containsKey(labelValue)) {
+          res.get(approval.label()).put(labelValue, new ArrayList<>());
+        }
+        res.get(approval.label()).get(labelValue).add(accountLoader.get(approval.accountId()));
       }
-      if (!(deleteVoteControl.testDeleteVotePermissions(user, cd, approval, labelType.get())
-          || removeReviewerControl.testRemoveReviewer(
-              cd, user, approval.accountId(), approval.value()))) {
-        continue;
-      }
-      if (!res.containsKey(approval.label())) {
-        res.put(approval.label(), new HashMap<>());
-      }
-      String labelValue = LabelValue.formatValue(approval.value());
-      if (!res.get(approval.label()).containsKey(labelValue)) {
-        res.get(approval.label()).put(labelValue, new ArrayList<>());
-      }
-      res.get(approval.label()).get(labelValue).add(accountLoader.get(approval.accountId()));
+      return res;
     }
-    return res;
   }
 
   private static void clearOnlyZerosEntries(SetMultimap<String, String> permitted) {
diff --git a/java/com/google/gerrit/server/change/ParentDataProvider.java b/java/com/google/gerrit/server/change/ParentDataProvider.java
index 48ab59d..c0a1ffe 100644
--- a/java/com/google/gerrit/server/change/ParentDataProvider.java
+++ b/java/com/google/gerrit/server/change/ParentDataProvider.java
@@ -98,12 +98,14 @@
   private Optional<ParentCommitData> getFromGerritChange(
       Project.NameKey project, ObjectId parentCommitId, String targetBranch) {
     List<ChangeData> changeData = queryProvider.get().byCommit(parentCommitId.name());
-    if (changeData.size() != 1) {
+    if (changeData.size() > 1) {
       logger.atWarning().log(
-          "Did not find a single change associated with parent revision %s (project: %s). Found changes %s.",
+          "Found more than one change associated with parent revision %s (project: %s). Found changes %s.",
           parentCommitId.name(),
           project.get(),
           changeData.stream().map(ChangeData::getId).collect(ImmutableList.toImmutableList()));
+    }
+    if (changeData.size() != 1) {
       return Optional.empty();
     }
     ChangeData singleData = changeData.get(0);
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 854fd4e..3b0f6fb 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -23,6 +23,7 @@
 
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -47,6 +48,7 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.patch.DiffOperationsForCommitValidation;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -86,6 +88,7 @@
   private final WorkInProgressStateChanged wipStateChanged;
   private final AutoMerger autoMerger;
   private final TopicValidator topicValidator;
+  private final DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory;
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
@@ -135,6 +138,7 @@
       WorkInProgressStateChanged wipStateChanged,
       AutoMerger autoMerger,
       TopicValidator topicValidator,
+      DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
       @Assisted ObjectId commitId) {
@@ -151,6 +155,7 @@
     this.wipStateChanged = wipStateChanged;
     this.autoMerger = autoMerger;
     this.topicValidator = topicValidator;
+    this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory;
 
     this.origNotes = notes;
     this.psId = psId;
@@ -161,37 +166,44 @@
     return psId;
   }
 
+  @CanIgnoreReturnValue
   public PatchSetInserter setMessage(String message) {
     this.message = message;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PatchSetInserter setDescription(String description) {
     this.description = description;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PatchSetInserter setWorkInProgress(boolean workInProgress) {
     this.workInProgress = workInProgress;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PatchSetInserter setValidate(boolean validate) {
     this.validate = validate;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PatchSetInserter setCheckAddPatchSetPermission(boolean checkAddPatchSetPermission) {
     this.checkAddPatchSetPermission = checkAddPatchSetPermission;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PatchSetInserter setGroups(List<String> groups) {
     requireNonNull(groups, "groups may not be null");
     this.groups = groups;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PatchSetInserter setValidationOptions(
       ImmutableListMultimap<String, String> validationOptions) {
     requireNonNull(validationOptions, "validationOptions may not be null");
@@ -199,21 +211,25 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PatchSetInserter setFireRevisionCreated(boolean fireRevisionCreated) {
     this.fireRevisionCreated = fireRevisionCreated;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PatchSetInserter setAllowClosed(boolean allowClosed) {
     this.allowClosed = allowClosed;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PatchSetInserter setSendEmail(boolean sendEmail) {
     this.sendEmail = sendEmail;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public PatchSetInserter setTopic(String topic) {
     this.topic = topic;
     return this;
@@ -225,6 +241,7 @@
    * cases, we already store the votes of the new patch-sets in SubmitStrategyOp#saveApprovals. We
    * should not also store the copied votes.
    */
+  @CanIgnoreReturnValue
   public PatchSetInserter setStoreCopiedVotes(boolean storeCopiedVotes) {
     this.storeCopiedVotes = storeCopiedVotes;
     return this;
@@ -256,10 +273,7 @@
 
     Optional<ReceiveCommand> autoMerge =
         autoMerger.createAutoMergeCommitIfNecessary(
-            ctx.getRepoView(),
-            ctx.getRevWalk(),
-            ctx.getInserter(),
-            ctx.getRevWalk().parseCommit(commitId));
+            ctx.getRepoView(), ctx.getInserter(), ctx.getRevWalk().parseCommit(commitId));
     if (autoMerge.isPresent()) {
       ctx.addRefUpdate(autoMerge.get());
     }
@@ -320,7 +334,7 @@
     if (storeCopiedVotes) {
       approvalCopierResult =
           approvalsUtil.copyApprovalsToNewPatchSet(
-              ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
+              ctx.getNotes(), patchSet, ctx.getRepoView(), update);
     }
 
     mailMessage = insertChangeMessage(update, ctx);
@@ -424,7 +438,9 @@
             ctx.getRepoView().getConfig(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
-            ctx.getIdentifiedUser())) {
+            ctx.getIdentifiedUser(),
+            diffOperationsForCommitValidationFactory.create(
+                ctx.getRepoView(), ctx.getInserter()))) {
       commitValidatorsFactory
           .forGerritCommits(
               permissionBackend.user(ctx.getUser()).project(ctx.getProject()),
diff --git a/java/com/google/gerrit/server/change/ReaddOwnerUtil.java b/java/com/google/gerrit/server/change/ReaddOwnerUtil.java
index afbe30b..f42a843 100644
--- a/java/com/google/gerrit/server/change/ReaddOwnerUtil.java
+++ b/java/com/google/gerrit/server/change/ReaddOwnerUtil.java
@@ -16,8 +16,10 @@
 
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
@@ -38,7 +40,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.List;
 import java.util.concurrent.TimeUnit;
 
 @Singleton
@@ -47,7 +48,7 @@
 
   private final AttentionSetConfig cfg;
   private final Provider<ChangeQueryProcessor> queryProvider;
-  private final ChangeQueryBuilder queryBuilder;
+  private final Supplier<ChangeQueryBuilder> queryBuilderSupplier;
   private final AddToAttentionSetOp.Factory opFactory;
   private final ServiceUserClassifier serviceUserClassifier;
   private final InternalUser internalUser;
@@ -56,13 +57,13 @@
   ReaddOwnerUtil(
       AttentionSetConfig cfg,
       Provider<ChangeQueryProcessor> queryProvider,
-      ChangeQueryBuilder queryBuilder,
+      Provider<ChangeQueryBuilder> queryBuilderProvider,
       AddToAttentionSetOp.Factory opFactory,
       ServiceUserClassifier serviceUserClassifier,
       InternalUser.Factory internalUserFactory) {
     this.cfg = cfg;
     this.queryProvider = queryProvider;
-    this.queryBuilder = queryBuilder;
+    this.queryBuilderSupplier = Suppliers.memoize(queryBuilderProvider::get);
     this.opFactory = opFactory;
     this.serviceUserClassifier = serviceUserClassifier;
     internalUser = internalUserFactory.create();
@@ -80,8 +81,12 @@
               + TimeUnit.MILLISECONDS.toMinutes(cfg.getReaddAfter())
               + "m";
 
-      List<ChangeData> changesToAddOwner =
-          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
+      ImmutableList<ChangeData> changesToAddOwner =
+          queryProvider
+              .get()
+              .enforceVisibility(false)
+              .query(queryBuilderSupplier.get().parse(query))
+              .entities();
 
       ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
           ImmutableListMultimap.builder();
@@ -89,7 +94,7 @@
         builder.put(cd.project(), cd);
       }
 
-      ListMultimap<Project.NameKey, ChangeData> ownerAdds = builder.build();
+      ImmutableListMultimap<Project.NameKey, ChangeData> ownerAdds = builder.build();
       int ownersAdded = 0;
       for (Project.NameKey project : ownerAdds.keySet()) {
         try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index de3b7d5..a430034 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -23,7 +23,10 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -43,6 +46,7 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -56,6 +60,7 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -80,6 +85,8 @@
  * RevWalk, org.eclipse.jgit.lib.ObjectInserter)}).
  */
 public class RebaseChangeOp implements BatchUpdateOp {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
 
@@ -113,6 +120,7 @@
   private boolean matchAuthorToCommitterDate = false;
   private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
   private String mergeStrategy;
+  private boolean verifyNeedsRebase = true;
 
   private CodeReviewCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
@@ -193,26 +201,31 @@
     this.originalPatchSet = originalPatchSet;
   }
 
+  @CanIgnoreReturnValue
   public RebaseChangeOp setCommitterIdent(PersonIdent committerIdent) {
     this.committerIdent = committerIdent;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public RebaseChangeOp setValidate(boolean validate) {
     this.validate = validate;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public RebaseChangeOp setCheckAddPatchSetPermission(boolean checkAddPatchSetPermission) {
     this.checkAddPatchSetPermission = checkAddPatchSetPermission;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public RebaseChangeOp setFireRevisionCreated(boolean fireRevisionCreated) {
     this.fireRevisionCreated = fireRevisionCreated;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public RebaseChangeOp setForceContentMerge(boolean forceContentMerge) {
     this.forceContentMerge = forceContentMerge;
     return this;
@@ -226,16 +239,19 @@
    *
    * @see #setForceContentMerge(boolean)
    */
+  @CanIgnoreReturnValue
   public RebaseChangeOp setAllowConflicts(boolean allowConflicts) {
     this.allowConflicts = allowConflicts;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public RebaseChangeOp setDetailedCommitMessage(boolean detailedCommitMessage) {
     this.detailedCommitMessage = detailedCommitMessage;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public RebaseChangeOp setPostMessage(boolean postMessage) {
     this.postMessage = postMessage;
     return this;
@@ -247,21 +263,25 @@
    * cases, we already store the votes of the new patch-sets in SubmitStrategyOp#saveApprovals. We
    * should not also store the copied votes.
    */
+  @CanIgnoreReturnValue
   public RebaseChangeOp setStoreCopiedVotes(boolean storeCopiedVotes) {
     this.storeCopiedVotes = storeCopiedVotes;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public RebaseChangeOp setSendEmail(boolean sendEmail) {
     this.sendEmail = sendEmail;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public RebaseChangeOp setMatchAuthorToCommitterDate(boolean matchAuthorToCommitterDate) {
     this.matchAuthorToCommitterDate = matchAuthorToCommitterDate;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public RebaseChangeOp setValidationOptions(
       ImmutableListMultimap<String, String> validationOptions) {
     requireNonNull(validationOptions, "validationOptions may not be null");
@@ -269,15 +289,22 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public RebaseChangeOp setMergeStrategy(String strategy) {
     this.mergeStrategy = strategy;
     return this;
   }
 
+  @CanIgnoreReturnValue
+  public RebaseChangeOp setVerifyNeedsRebase(boolean verifyNeedsRebase) {
+    this.verifyNeedsRebase = verifyNeedsRebase;
+    return this;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx)
       throws InvalidChangeOperationException, RestApiException, IOException, NoSuchChangeException,
-          PermissionBackendException {
+          PermissionBackendException, DiffNotAvailableException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
     RevWalk rw = ctx.getRevWalk();
@@ -351,6 +378,8 @@
       }
     }
 
+    logger.atFine().log(
+        "flushing inserter %s", ctx.getRevWalk().getObjectReader().getCreatedFromInserter());
     ctx.getRevWalk().getObjectReader().getCreatedFromInserter().flush();
     patchSetInserter.updateRepo(ctx);
   }
@@ -440,7 +469,7 @@
       throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
 
-    if (base.equals(parentCommit)) {
+    if (verifyNeedsRebase && base.equals(parentCommit)) {
       throw new ResourceConflictException("Change is already up to date.");
     }
 
@@ -467,10 +496,37 @@
     if (success) {
       filesWithGitConflicts = null;
       tree = merger.getResultTreeId();
+      logger.atFine().log(
+          "tree of rebased commit: %s (no conflicts, inserter: %s)",
+          tree.name(), merger.getObjectInserter());
     } else {
       List<String> conflicts = ImmutableList.of();
+      Map<String, ResolveMerger.MergeFailureReason> failed = ImmutableMap.of();
       if (merger instanceof ResolveMerger) {
         conflicts = ((ResolveMerger) merger).getUnmergedPaths();
+        failed = ((ResolveMerger) merger).getFailingPaths();
+      }
+
+      if (merger.getResultTreeId() != null) {
+        // Merging with conflicts below uses the same DirCache instance that has been used by the
+        // Merger to attempt the merge without conflicts.
+        //
+        // The Merger uses the DirCache to do the updates, and in particular to write the result
+        // tree. DirCache caches a single DirCacheTree instance that is used to write the result
+        // tree, but it writes the result tree only if there were no conflicts.
+        //
+        // Merging with conflicts uses the same DirCache instance to write the tree with conflicts
+        // that has been used by the Merger. This means if the Merger unexpectedly wrote a result
+        // tree although there had been conflicts, then merging with conflicts uses the same
+        // DirCacheTree instance to write the tree with conflicts. However DirCacheTree#writeTree
+        // writes a tree only once and then that tree is cached. Further invocations of
+        // DirCacheTree#writeTree have no effect and return the previously created tree. This means
+        // merging with conflicts can only successfully create the tree with conflicts if the Merger
+        // didn't write a result tree yet. Hence this is checked here and we log a warning if the
+        // result tree was already written.
+        logger.atWarning().log(
+            "result tree has already been written: %s (merger: %s, conflicts: %s, failed: %s)",
+            merger, merger.getResultTreeId().name(), conflicts, failed);
       }
 
       if (!allowConflicts || !(merger instanceof ResolveMerger)) {
@@ -489,6 +545,7 @@
               .map(Map.Entry::getKey)
               .collect(toImmutableSet());
 
+      logger.atFine().log("rebasing with conflicts");
       tree =
           MergeUtil.mergeWithConflicts(
               ctx.getRevWalk(),
@@ -499,11 +556,23 @@
               "BASE",
               ctx.getRevWalk().parseCommit(base),
               mergeResults);
+      logger.atFine().log(
+          "tree of rebased commit: %s (with conflicts, inserter: %s)",
+          tree.name(), ctx.getInserter());
+    }
+
+    List<ObjectId> parents = new ArrayList<>();
+    parents.add(base);
+    if (original.getParentCount() > 1) {
+      // If a merge commit is rebased add all other parents (parent 2 to N).
+      for (int parent = 1; parent < original.getParentCount(); parent++) {
+        parents.add(original.getParent(parent));
+      }
     }
 
     CommitBuilder cb = new CommitBuilder();
     cb.setTreeId(tree);
-    cb.setParentId(base);
+    cb.setParentIds(parents);
     cb.setAuthor(original.getAuthorIdent());
     cb.setMessage(commitMessage);
     if (committerIdent != null) {
@@ -523,6 +592,7 @@
     ObjectId objectId = ctx.getInserter().insert(cb);
     CodeReviewCommit commit = ((CodeReviewRevWalk) ctx.getRevWalk()).parseCommit(objectId);
     commit.setFilesWithGitConflicts(filesWithGitConflicts);
+    logger.atFine().log("rebased commit=%s", commit.name());
     return commit;
   }
 }
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 47a1e11..93fcbc6 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -51,6 +51,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -252,18 +253,16 @@
           String.format("Change %s is %s", change.getId(), ChangeUtil.status(change)));
     }
 
-    if (!hasOneParent(rw, patchSet)) {
+    if (!hasAtLeastOneParent(rw, patchSet)) {
       throw new ResourceConflictException(
           String.format(
-              "Error rebasing %s. Cannot rebase %s",
-              change.getId(),
-              countParents(rw, patchSet) > 1 ? "merge commits" : "commit with no ancestor"));
+              "Error rebasing %s. Cannot rebase commit with no ancestor", change.getId()));
     }
   }
 
-  public static boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
-    // Prevent rebase of exotic changes (merge commit, no ancestor).
-    return countParents(rw, ps) == 1;
+  public static boolean hasAtLeastOneParent(RevWalk rw, PatchSet ps) throws IOException {
+    // Prevent rebase of changes with no ancestor.
+    return countParents(rw, ps) >= 1;
   }
 
   private static int countParents(RevWalk rw, PatchSet ps) throws IOException {
@@ -346,7 +345,7 @@
       }
     }
 
-    // Try parsing as SHA-1.
+    // Try parsing as SHA-1 based on the change-index.
     Base ret = null;
     for (ChangeData cd : queryProvider.get().byProjectCommit(rsrc.getProject(), base)) {
       for (PatchSet ps : cd.patchSets()) {
@@ -371,8 +370,8 @@
   /**
    * Parse or find the commit onto which a patch set should be rebased.
    *
-   * <p>If a {@code rebaseInput.base} is provided, parse it. Otherwise, finds the latest patch set
-   * of the change corresponding to this commit's parent, or the destination branch tip in the case
+   * <p>If a {@code rebaseInput.base} is provided, parse it. Otherwise, find the latest patch set of
+   * the change corresponding to this commit's parent, or the destination branch tip in the case
    * where the parent's change is merged.
    *
    * @param git the repository.
@@ -413,11 +412,16 @@
       throw new UnprocessableEntityException(
           String.format("Base change not found: %s", inputBase), e);
     }
-    if (base == null) {
-      throw new ResourceConflictException(
-          "base revision is missing from the destination branch: " + inputBase);
+    if (base != null) {
+      return getLatestRevisionForBaseChange(rw, permissionBackend, rsrc, base);
     }
-    return getLatestRevisionForBaseChange(rw, permissionBackend, rsrc, base);
+    if (isBaseRevisionInDestBranch(rw, inputBase, git, change.getDest())) {
+      // The requested base is a valid commit in the dest branch, which is not associated with any
+      // Gerrit change.
+      return ObjectId.fromString(inputBase);
+    }
+    throw new ResourceConflictException(
+        "base revision is missing from the destination branch: " + inputBase);
   }
 
   private ObjectId getDestRefTip(Repository git, BranchNameKey destRefKey)
@@ -487,9 +491,7 @@
     ObjectId baseId = null;
     RevCommit commit = rw.parseCommit(patchSet.commitId());
 
-    if (commit.getParentCount() > 1) {
-      throw new UnprocessableEntityException("Cannot rebase a change with multiple parents.");
-    } else if (commit.getParentCount() == 0) {
+    if (commit.getParentCount() == 0) {
       throw new UnprocessableEntityException(
           "Cannot rebase a change without any parents (is this the initial commit?).");
     }
@@ -535,6 +537,18 @@
     return baseId;
   }
 
+  private boolean isBaseRevisionInDestBranch(
+      RevWalk rw, String expectedBaseSha1, Repository git, BranchNameKey destRefKey)
+      throws IOException, ResourceConflictException {
+    RevCommit potentialBaseCommit;
+    try {
+      potentialBaseCommit = rw.parseCommit(ObjectId.fromString(expectedBaseSha1));
+    } catch (InvalidObjectIdException | IOException e) {
+      return false;
+    }
+    return rw.isMergedInto(potentialBaseCommit, rw.parseCommit(getDestRefTip(git, destRefKey)));
+  }
+
   public RebaseChangeOp getRebaseOp(
       RevWalk rw,
       RevisionResource revRsrc,
diff --git a/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index 2d93d4a..b95d592 100644
--- a/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -102,7 +102,7 @@
       }
     }
 
-    Collection<PatchSetData> ancestors = walkAncestors(parents, start);
+    Set<PatchSetData> ancestors = walkAncestors(parents, start);
     List<PatchSetData> descendants =
         walkDescendants(children, start, otherPatchSetsOfStart, ancestors);
     List<PatchSetData> result = new ArrayList<>(ancestors.size() + descendants.size() - 1);
@@ -135,7 +135,7 @@
       }
     }
 
-    Collection<PatchSetData> ancestors = walkAncestors(parents, start);
+    Set<PatchSetData> ancestors = walkAncestors(parents, start);
     return List.copyOf(ancestors);
   }
 
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 5930f7a..5ccbc04 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange.USER_REMOVED;
 import static java.util.Objects.requireNonNull;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
@@ -46,6 +48,7 @@
 
   private Change change;
   private boolean notify;
+  @Nullable private AttentionSetUpdateCondition condition;
 
   /**
    * Remove a specified user from the attention set.
@@ -68,8 +71,19 @@
     this.notify = notify;
   }
 
+  /** Sets a condition for performing this attention set update. */
+  @CanIgnoreReturnValue
+  public RemoveFromAttentionSetOp setCondition(AttentionSetUpdateCondition condition) {
+    this.condition = condition;
+    return this;
+  }
+
   @Override
   public boolean updateChange(ChangeContext ctx) throws RestApiException {
+    if (condition != null && !condition.check()) {
+      return false;
+    }
+
     ChangeData changeData = changeDataFactory.create(ctx.getNotes());
     Optional<AttentionSetUpdate> existingEntry =
         changeData.attentionSet().stream()
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index daa41bf..61fab27 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -24,6 +24,7 @@
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
@@ -222,7 +223,10 @@
       throws IOException, PermissionBackendException, ConfigInvalidException {
     try (TraceContext.TraceTimer ignored =
         TraceContext.newTimer(getClass().getSimpleName() + "#prepare", Metadata.empty())) {
-      requireNonNull(input.reviewer);
+      if (Strings.nullToEmpty(input.reviewer).trim().isEmpty()) {
+        return fail(input, FailureType.NOT_FOUND, "reviewer user identifier is required");
+      }
+
       boolean confirmed = input.confirmed();
       boolean allowByEmail =
           projectCache
@@ -667,7 +671,9 @@
         throws RestApiException, IOException, PermissionBackendException {
       for (ReviewerModification addition : modifications()) {
         addition.op.setPatchSet(patchSet);
-        addition.op.updateChange(ctx);
+
+        @SuppressWarnings("unused")
+        var unused = addition.op.updateChange(ctx);
       }
     }
 
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 2ab7e15..5b63fac 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -65,6 +65,9 @@
 import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtilFactory;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -258,26 +261,30 @@
       Optional<PatchSet.Id> limitToPsId,
       ChangeInfo changeInfo)
       throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
-    Map<String, RevisionInfo> res = new LinkedHashMap<>();
-    try (Repository repo = openRepoIfNecessary(cd.project());
-        RevWalk rw = newRevWalk(repo)) {
-      for (PatchSet in : map.values()) {
-        PatchSet.Id id = in.id();
-        boolean want;
-        if (has(ALL_REVISIONS)) {
-          want = true;
-        } else if (limitToPsId.isPresent()) {
-          want = id.equals(limitToPsId.get());
-        } else {
-          want = id.equals(cd.change().currentPatchSetId());
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get revisions", Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      Map<String, RevisionInfo> res = new LinkedHashMap<>();
+      try (Repository repo = openRepoIfNecessary(cd.project());
+          RevWalk rw = newRevWalk(repo)) {
+        for (PatchSet in : map.values()) {
+          PatchSet.Id id = in.id();
+          boolean want;
+          if (has(ALL_REVISIONS)) {
+            want = true;
+          } else if (limitToPsId.isPresent()) {
+            want = id.equals(limitToPsId.get());
+          } else {
+            want = id.equals(cd.change().currentPatchSetId());
+          }
+          if (want) {
+            res.put(
+                in.commitId().name(),
+                toRevisionInfo(accountLoader, cd, in, repo, rw, false, changeInfo));
+          }
         }
-        if (want) {
-          res.put(
-              in.commitId().name(),
-              toRevisionInfo(accountLoader, cd, in, repo, rw, false, changeInfo));
-        }
+        return res;
       }
-      return res;
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
index e5a57b2..4a10158 100644
--- a/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -41,22 +41,22 @@
     return new RevisionResource(change, ps, Optional.empty(), false);
   }
 
-  private final ChangeResource change;
+  private final ChangeResource changeResource;
   private final PatchSet ps;
   private final Optional<ChangeEdit> edit;
   private final boolean cacheable;
 
-  public RevisionResource(ChangeResource change, PatchSet ps) {
-    this(change, ps, Optional.empty());
+  public RevisionResource(ChangeResource changeResource, PatchSet ps) {
+    this(changeResource, ps, Optional.empty());
   }
 
-  public RevisionResource(ChangeResource change, PatchSet ps, Optional<ChangeEdit> edit) {
-    this(change, ps, edit, true);
+  public RevisionResource(ChangeResource changeResource, PatchSet ps, Optional<ChangeEdit> edit) {
+    this(changeResource, ps, edit, true);
   }
 
   private RevisionResource(
-      ChangeResource change, PatchSet ps, Optional<ChangeEdit> edit, boolean cacheable) {
-    this.change = change;
+      ChangeResource changeResource, PatchSet ps, Optional<ChangeEdit> edit, boolean cacheable) {
+    this.changeResource = changeResource;
     this.ps = ps;
     this.edit = edit;
     this.cacheable = cacheable;
@@ -67,15 +67,15 @@
   }
 
   public PermissionBackend.ForChange permissions() {
-    return change.permissions();
+    return changeResource.permissions();
   }
 
   public ChangeResource getChangeResource() {
-    return change;
+    return changeResource;
   }
 
   public Change getChange() {
-    return getChangeResource().getChange();
+    return changeResource.getChange();
   }
 
   public Project.NameKey getProject() {
@@ -83,7 +83,7 @@
   }
 
   public ChangeNotes getNotes() {
-    return getChangeResource().getNotes();
+    return changeResource.getNotes();
   }
 
   public PatchSet getPatchSet() {
@@ -96,9 +96,9 @@
         TraceContext.newTimer(
             "Compute revision ETag",
             Metadata.builder()
-                .changeId(change.getId().get())
+                .changeId(changeResource.getId().get())
                 .patchSetId(ps.number())
-                .projectName(change.getProject().get())
+                .projectName(changeResource.getProject().get())
                 .build())) {
       Hasher h = Hashing.murmur3_128().newHasher();
       prepareETag(h, getUser());
@@ -109,7 +109,7 @@
   public void prepareETag(Hasher h, CurrentUser user) {
     // Conservative estimate: refresh the revision if its parent change has changed, so we don't
     // have to check whether a given modification affected this revision specifically.
-    change.prepareETag(h, user);
+    changeResource.prepareETag(h, user);
   }
 
   public Account.Id getAccountId() {
@@ -117,7 +117,7 @@
   }
 
   public CurrentUser getUser() {
-    return getChangeResource().getUser();
+    return changeResource.getUser();
   }
 
   public Optional<ChangeEdit> getEdit() {
diff --git a/java/com/google/gerrit/server/change/SetHashtagsOp.java b/java/com/google/gerrit/server/change/SetHashtagsOp.java
index bfc4834..43045a4 100644
--- a/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.change.HashtagsUtil.extractTags;
 
 import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
@@ -91,7 +92,7 @@
     ChangeNotes notes = update.getNotes().load();
 
     try {
-      Set<String> existingHashtags = notes.getHashtags();
+      ImmutableSet<String> existingHashtags = notes.getHashtags();
       Set<String> updated = new HashSet<>();
       toAdd = new HashSet<>(extractTags(input.add));
       toRemove = new HashSet<>(extractTags(input.remove));
diff --git a/java/com/google/gerrit/server/change/WalkSorter.java b/java/com/google/gerrit/server/change/WalkSorter.java
index d2f7ede..cc87ea8 100644
--- a/java/com/google/gerrit/server/change/WalkSorter.java
+++ b/java/com/google/gerrit/server/change/WalkSorter.java
@@ -23,7 +23,9 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Ordering;
+import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -87,11 +89,13 @@
     includePatchSets = new HashSet<>();
   }
 
+  @CanIgnoreReturnValue
   public WalkSorter includePatchSets(Iterable<PatchSet.Id> patchSets) {
     Iterables.addAll(includePatchSets, patchSets);
     return this;
   }
 
+  @CanIgnoreReturnValue
   public WalkSorter setRetainBody(boolean retainBody) {
     this.retainBody = retainBody;
     return this;
@@ -143,8 +147,8 @@
 
       Set<RevCommit> commits = byCommit.keySet();
       ListMultimap<RevCommit, RevCommit> children = collectChildren(commits);
-      ListMultimap<RevCommit, RevCommit> pending =
-          MultimapBuilder.hashKeys().arrayListValues().build();
+      SetMultimap<RevCommit, RevCommit> pending =
+          MultimapBuilder.hashKeys().hashSetValues().build();
       Deque<RevCommit> todo = new ArrayDeque<>();
 
       RevFlag done = rw.newFlag("done");
@@ -153,6 +157,7 @@
       int found = 0;
       RevCommit c;
       List<PatchSetData> result = new ArrayList<>(expected);
+      int maxPopSize = commits.size() * commits.size();
       while (found < expected && (c = rw.next()) != null) {
         if (!commits.contains(c)) {
           continue;
@@ -161,9 +166,15 @@
         todo.add(c);
         int i = 0;
         while (!todo.isEmpty()) {
-          // Sanity check: we can't pop more than N pending commits, otherwise
-          // we have an infinite loop due to programmer error or something.
-          checkState(++i <= commits.size(), "Too many pending steps while sorting %s", commits);
+          // Sanity check: in the worst case scenario, each commit can add all previous commits in
+          // the todo queue. This can lead to (n-1) + (n-2) + ... +1 iterations of the algorithm.
+          // So, in the worst case we can't pop more than N^2 pending commits, otherwise
+          // we have an infinite loop due to programmer error or something. (actually, it is
+          // (N-1) + (N-2) + (N-3) + (1) + 1 = N/2*(N-1)+1, but N^2 is enough for us.)
+          checkState(
+              ++i <= maxPopSize,
+              "Too many pending steps while sorting %s - can be a problem in the algorithm.",
+              commits);
           RevCommit t = todo.removeFirst();
           if (t.has(done)) {
             continue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/change/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/change/package-info.java
index 0709b86..0f70412 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/change/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.change;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/change/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/change/testing/package-info.java
index 0709b86..3cd4da3 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/change/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.change.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/comment/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/comment/package-info.java
index 0709b86..e06653b 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/comment/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.comment;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java b/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
index 9f6ecfb5..1551df7 100644
--- a/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
+++ b/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
@@ -54,7 +54,8 @@
       }
       groups = builder.build();
     } finally {
-      threadContext.setContext(ctx);
+      @SuppressWarnings("unused")
+      var unused = threadContext.setContext(ctx);
     }
   }
 
diff --git a/java/com/google/gerrit/server/config/CachedPreferences.java b/java/com/google/gerrit/server/config/CachedPreferences.java
index 169d9ec..6e957e6 100644
--- a/java/com/google/gerrit/server/config/CachedPreferences.java
+++ b/java/com/google/gerrit/server/config/CachedPreferences.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.config;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.Function;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -23,6 +22,10 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.proto.Entities.UserPreferences;
 import com.google.gerrit.server.cache.proto.Cache.CachedPreferencesProto;
+import com.google.gerrit.server.config.PreferencesParserUtil.DiffPreferencesParser;
+import com.google.gerrit.server.config.PreferencesParserUtil.EditPreferencesParser;
+import com.google.gerrit.server.config.PreferencesParserUtil.GeneralPreferencesParser;
+import com.google.gerrit.server.config.PreferencesParserUtil.PreferencesParser;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -67,38 +70,17 @@
 
   public static GeneralPreferencesInfo general(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    return getPreferences(
-        defaultPreferences,
-        userPreferences,
-        PreferencesParserUtil::parseGeneralPreferences,
-        p ->
-            UserPreferencesConverter.GeneralPreferencesInfoConverter.fromProto(
-                p.getGeneralPreferencesInfo()),
-        GeneralPreferencesInfo.defaults());
+    return getPreferences(defaultPreferences, userPreferences, GeneralPreferencesParser.Instance);
   }
 
   public static DiffPreferencesInfo diff(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    return getPreferences(
-        defaultPreferences,
-        userPreferences,
-        PreferencesParserUtil::parseDiffPreferences,
-        p ->
-            UserPreferencesConverter.DiffPreferencesInfoConverter.fromProto(
-                p.getDiffPreferencesInfo()),
-        DiffPreferencesInfo.defaults());
+    return getPreferences(defaultPreferences, userPreferences, DiffPreferencesParser.Instance);
   }
 
   public static EditPreferencesInfo edit(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    return getPreferences(
-        defaultPreferences,
-        userPreferences,
-        PreferencesParserUtil::parseEditPreferences,
-        p ->
-            UserPreferencesConverter.EditPreferencesInfoConverter.fromProto(
-                p.getEditPreferencesInfo()),
-        EditPreferencesInfo.defaults());
+    return getPreferences(defaultPreferences, userPreferences, EditPreferencesParser.Instance);
   }
 
   public Config asConfig() {
@@ -135,34 +117,26 @@
     return cachedPreferences.map(CachedPreferences::asConfig).orElse(null);
   }
 
-  @FunctionalInterface
-  private interface ComputePreferencesFn<PreferencesT> {
-    PreferencesT apply(Config cfg, @Nullable Config defaultCfg, @Nullable PreferencesT input)
-        throws ConfigInvalidException;
-  }
-
   private static <PreferencesT> PreferencesT getPreferences(
       Optional<CachedPreferences> defaultPreferences,
       CachedPreferences userPreferences,
-      ComputePreferencesFn<PreferencesT> computePreferencesFn,
-      Function<UserPreferences, PreferencesT> fromUserPreferencesFn,
-      PreferencesT javaDefaults) {
+      PreferencesParser<PreferencesT> preferencesParser) {
     try {
       CachedPreferencesProto userPreferencesProto = userPreferences.config();
       switch (userPreferencesProto.getPreferencesCase()) {
         case USER_PREFERENCES:
           PreferencesT pref =
-              fromUserPreferencesFn.apply(userPreferencesProto.getUserPreferences());
-          return computePreferencesFn.apply(new Config(), configOrNull(defaultPreferences), pref);
+              preferencesParser.fromUserPreferences(userPreferencesProto.getUserPreferences());
+          return preferencesParser.parse(pref, configOrNull(defaultPreferences));
         case LEGACY_GIT_CONFIG:
-          return computePreferencesFn.apply(
+          return preferencesParser.parse(
               userPreferences.asConfig(), configOrNull(defaultPreferences), null);
         case PREFERENCES_NOT_SET:
           throw new ConfigInvalidException("Invalid config " + userPreferences);
       }
     } catch (ConfigInvalidException e) {
-      return javaDefaults;
+      return preferencesParser.getJavaDefaults();
     }
-    return javaDefaults;
+    return preferencesParser.getJavaDefaults();
   }
 }
diff --git a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
index 5e6a520..c3516dd 100644
--- a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
+++ b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
@@ -15,6 +15,7 @@
 
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Multimap;
 import java.util.Collections;
 import java.util.LinkedHashSet;
@@ -64,21 +65,21 @@
     return accept(Collections.singleton(entry));
   }
 
-  public Multimap<UpdateResult, ConfigUpdateEntry> accept(Set<ConfigKey> entries) {
+  public ListMultimap<UpdateResult, ConfigUpdateEntry> accept(Set<ConfigKey> entries) {
     return createUpdate(entries, UpdateResult.APPLIED);
   }
 
-  public Multimap<UpdateResult, ConfigUpdateEntry> accept(String section) {
+  public ListMultimap<UpdateResult, ConfigUpdateEntry> accept(String section) {
     Set<ConfigKey> entries = getEntriesFromSection(oldConfig, section);
     entries.addAll(getEntriesFromSection(newConfig, section));
     return createUpdate(entries, UpdateResult.APPLIED);
   }
 
-  public Multimap<UpdateResult, ConfigUpdateEntry> reject(ConfigKey entry) {
+  public ListMultimap<UpdateResult, ConfigUpdateEntry> reject(ConfigKey entry) {
     return reject(Collections.singleton(entry));
   }
 
-  public Multimap<UpdateResult, ConfigUpdateEntry> reject(Set<ConfigKey> entries) {
+  public ListMultimap<UpdateResult, ConfigUpdateEntry> reject(Set<ConfigKey> entries) {
     return createUpdate(entries, UpdateResult.REJECTED);
   }
 
@@ -95,9 +96,9 @@
     return res;
   }
 
-  private Multimap<UpdateResult, ConfigUpdateEntry> createUpdate(
+  private ListMultimap<UpdateResult, ConfigUpdateEntry> createUpdate(
       Set<ConfigKey> entries, UpdateResult updateResult) {
-    Multimap<UpdateResult, ConfigUpdateEntry> updates = ArrayListMultimap.create();
+    ListMultimap<UpdateResult, ConfigUpdateEntry> updates = ArrayListMultimap.create();
     entries.stream()
         .filter(this::isValueUpdated)
         .map(e -> new ConfigUpdateEntry(e, getString(e, oldConfig), getString(e, newConfig)))
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index 22c3d99..e76207c 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -17,6 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.UsedAt.Project;
 import java.io.IOException;
@@ -344,6 +345,7 @@
    *     when their values are false
    * @return loaded instance
    */
+  @CanIgnoreReturnValue
   public static <T> T loadSection(Config cfg, String section, String sub, T s, T defaults, T i)
       throws ConfigInvalidException {
     try {
@@ -395,6 +397,50 @@
   }
 
   /**
+   * Merges config by inspecting Java class attributes, similar to {@link #loadSection}.
+   *
+   * <p>Config values are stored optimized: no default values are stored. The loading is performed
+   * eagerly: all values are set, except default boolean values.
+   *
+   * <p>Fields marked with final or transient modifiers are skipped.
+   *
+   * @param cfg config from which the values are loaded
+   * @param s instance of class in which the values are set
+   * @param defaults instance of class with default values
+   * @return loaded instance
+   */
+  @CanIgnoreReturnValue
+  public static <T> T mergeWithDefaults(T cfg, T s, T defaults) throws ConfigInvalidException {
+    try {
+      for (Field f : s.getClass().getDeclaredFields()) {
+        if (skipField(f)) {
+          continue;
+        }
+        Class<?> t = f.getType();
+        String n = f.getName();
+        f.setAccessible(true);
+
+        Object val = f.get(cfg);
+        if (val == null) {
+          val = f.get(defaults);
+          if (!isString(t) && !isCollectionOrMap(t)) {
+            requireNonNull(val, "Default cannot be null for: " + n);
+          }
+        }
+        if (!isBoolean(t) || (boolean) val) {
+          // To reproduce the same behavior as in the loadSection method above, values are
+          // explicitly set for all types, except the boolean type. For the boolean type, the value
+          // is set only if it is 'true' (so, the false value is omitted in the result object).
+          f.set(s, val);
+        }
+      }
+    } catch (SecurityException | IllegalArgumentException | IllegalAccessException e) {
+      throw new ConfigInvalidException("cannot load values", e);
+    }
+    return s;
+  }
+
+  /**
    * Update user config by applying the specified delta
    *
    * <p>As opposed to {@link com.google.gerrit.server.config.ConfigUtil#storeSection}, this method
diff --git a/java/com/google/gerrit/server/config/ExperimentResource.java b/java/com/google/gerrit/server/config/ExperimentResource.java
new file mode 100644
index 0000000..e22ffc7
--- /dev/null
+++ b/java/com/google/gerrit/server/config/ExperimentResource.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class ExperimentResource extends ConfigResource {
+  public static final TypeLiteral<RestView<ExperimentResource>> EXPERIMENT_KIND =
+      new TypeLiteral<>() {};
+
+  private final String name;
+
+  public ExperimentResource(String name) {
+    this.name = name;
+  }
+
+  public String getName() {
+    return name;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index fe82a88..a38a3fc 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -81,7 +81,6 @@
 import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ChangeDraftUpdate;
 import com.google.gerrit.server.CmdLineParserModule;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.DeadlineChecker;
@@ -122,6 +121,7 @@
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.FileInfoJsonModule;
+import com.google.gerrit.server.change.FilterIncludedIn;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.change.RevisionJson;
@@ -160,7 +160,7 @@
 import com.google.gerrit.server.git.validators.UploadValidationListener;
 import com.google.gerrit.server.git.validators.UploadValidators;
 import com.google.gerrit.server.group.db.GroupDbModule;
-import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
+import com.google.gerrit.server.index.change.ReindexChangesAfterRefUpdate;
 import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.mail.AutoReplyMailFilter;
 import com.google.gerrit.server.mail.ListMailFilter;
@@ -171,11 +171,11 @@
 import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
-import com.google.gerrit.server.notedb.ChangeDraftNotesUpdate;
 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.DiffOperationsForCommitValidation;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
 import com.google.gerrit.server.patch.DiffValidator;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
@@ -318,6 +318,7 @@
     factory(ProjectOwnerGroupsProvider.Factory.class);
     factory(SubmitRuleEvaluator.Factory.class);
     factory(DeleteZombieCommentsRefs.Factory.class);
+    factory(DiffOperationsForCommitValidation.Factory.class);
 
     bind(AuthBackend.class).to(UniversalAuthBackend.class).in(SINGLETON);
     DynamicSet.setOf(binder(), AuthBackend.class);
@@ -396,7 +397,8 @@
     DynamicSet.setOf(binder(), GarbageCollectorListener.class);
     DynamicSet.setOf(binder(), HeadUpdatedListener.class);
     DynamicSet.setOf(binder(), UsageDataPublishedListener.class);
-    DynamicSet.bind(binder(), GitBatchRefUpdateListener.class).to(ReindexAfterRefUpdate.class);
+    DynamicSet.bind(binder(), GitBatchRefUpdateListener.class)
+        .to(ReindexChangesAfterRefUpdate.class);
     DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
         .to(ProjectConfigEntry.UpdateChecker.class);
     DynamicSet.setOf(binder(), EventListener.class);
@@ -429,6 +431,7 @@
     DynamicSet.bind(binder(), GerritConfigListener.class)
         .toInstance(SuggestReviewers.configListener());
     DynamicSet.setOf(binder(), ExternalIncludedIn.class);
+    DynamicSet.setOf(binder(), FilterIncludedIn.class);
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
     DynamicSet.setOf(binder(), PluginPushOption.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
@@ -481,7 +484,6 @@
     bind(CommentValidator.class)
         .annotatedWith(Exports.named(CommentCumulativeSizeValidator.class.getSimpleName()))
         .to(CommentCumulativeSizeValidator.class);
-    bind(ChangeDraftUpdate.ChangeDraftUpdateFactory.class).to(ChangeDraftNotesUpdate.Factory.class);
 
     DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
diff --git a/java/com/google/gerrit/server/config/GerritInstanceNameProvider.java b/java/com/google/gerrit/server/config/GerritInstanceNameProvider.java
index 740bb01..b2e80d7 100644
--- a/java/com/google/gerrit/server/config/GerritInstanceNameProvider.java
+++ b/java/com/google/gerrit/server/config/GerritInstanceNameProvider.java
@@ -30,9 +30,8 @@
 
   @Inject
   public GerritInstanceNameProvider(
-      @GerritServerConfig Config config,
-      @CanonicalWebUrl @Nullable Provider<String> canonicalUrlProvider) {
-    this.instanceName = getInstanceName(config, canonicalUrlProvider);
+      @GerritServerConfig Config config, @CanonicalWebUrl @Nullable String canonicalUrl) {
+    this.instanceName = getInstanceName(config, canonicalUrl);
   }
 
   @Override
@@ -40,14 +39,13 @@
     return instanceName;
   }
 
-  private static String getInstanceName(
-      Config config, @Nullable Provider<String> canonicalUrlProvider) {
+  private static String getInstanceName(Config config, String canonicalUrl) {
     String instanceName = config.getString("gerrit", null, "instanceName");
-    if (instanceName != null || canonicalUrlProvider == null) {
+    if (instanceName != null) {
       return instanceName;
     }
 
-    return extractInstanceName(canonicalUrlProvider.get());
+    return extractInstanceName(canonicalUrl);
   }
 
   private static String extractInstanceName(String canonicalUrl) {
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigReloader.java b/java/com/google/gerrit/server/config/GerritServerConfigReloader.java
index 5ecf6ed..ea7eea7 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfigReloader.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfigReloader.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.ConfigUpdatedEvent.ConfigUpdateEntry;
 import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
@@ -43,17 +43,17 @@
    * Reloads the Gerrit Server Configuration from disk. Synchronized to ensure that one issued
    * reload is fully completed before a new one starts.
    */
-  public Multimap<UpdateResult, ConfigUpdateEntry> reloadConfig() {
+  public ListMultimap<UpdateResult, ConfigUpdateEntry> reloadConfig() {
     logger.atInfo().log("Starting server configuration reload");
-    Multimap<UpdateResult, ConfigUpdateEntry> updates =
+    ListMultimap<UpdateResult, ConfigUpdateEntry> updates =
         fireUpdatedConfigEvent(configProvider.updateConfig());
     logger.atInfo().log("Server configuration reload completed succesfully");
     return updates;
   }
 
-  public Multimap<UpdateResult, ConfigUpdateEntry> fireUpdatedConfigEvent(
+  public ListMultimap<UpdateResult, ConfigUpdateEntry> fireUpdatedConfigEvent(
       ConfigUpdatedEvent event) {
-    Multimap<UpdateResult, ConfigUpdateEntry> updates = ArrayListMultimap.create();
+    ListMultimap<UpdateResult, ConfigUpdateEntry> updates = ArrayListMultimap.create();
     configListeners.runEach(l -> updates.putAll(l.configUpdated(event)));
     return updates;
   }
diff --git a/java/com/google/gerrit/server/config/GitwebCgiConfig.java b/java/com/google/gerrit/server/config/GitwebCgiConfig.java
index 1ed0f16..862b092 100644
--- a/java/com/google/gerrit/server/config/GitwebCgiConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebCgiConfig.java
@@ -21,7 +21,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -48,7 +47,7 @@
     }
 
     String cfgCgi = cfg.getString("gitweb", null, "cgi");
-    Path pkgCgi = Paths.get("/usr/lib/cgi-bin/gitweb.cgi");
+    Path pkgCgi = Path.of("/usr/lib/cgi-bin/gitweb.cgi");
     String[] resourcePaths = {
       "/usr/share/gitweb/static", "/usr/share/gitweb", "/var/www/static", "/var/www",
     };
@@ -96,7 +95,7 @@
     Path js = null;
     Path logo = null;
     for (String path : resourcePaths) {
-      Path dir = Paths.get(path);
+      Path dir = Path.of(path);
       css = dir.resolve("gitweb.css");
       js = dir.resolve("gitweb.js");
       logo = dir.resolve("git-logo.png");
diff --git a/java/com/google/gerrit/server/config/GroupSetProvider.java b/java/com/google/gerrit/server/config/GroupSetProvider.java
index 025946d..1a7f14c 100644
--- a/java/com/google/gerrit/server/config/GroupSetProvider.java
+++ b/java/com/google/gerrit/server/config/GroupSetProvider.java
@@ -51,7 +51,8 @@
       }
       groupIds = builder.build();
     } finally {
-      threadContext.setContext(ctx);
+      @SuppressWarnings("unused")
+      var unused = threadContext.setContext(ctx);
     }
   }
 
diff --git a/java/com/google/gerrit/server/config/IndexResource.java b/java/com/google/gerrit/server/config/IndexResource.java
new file mode 100644
index 0000000..30d39c4
--- /dev/null
+++ b/java/com/google/gerrit/server/config/IndexResource.java
@@ -0,0 +1,34 @@
+// 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.config;
+
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.inject.TypeLiteral;
+
+public class IndexResource extends ConfigResource {
+  public static final TypeLiteral<RestView<IndexResource>> INDEX_KIND = new TypeLiteral<>() {};
+
+  private IndexDefinition<?, ?, ?> def;
+
+  public IndexResource(IndexDefinition<?, ?, ?> def) {
+    this.def = def;
+  }
+
+  public IndexDefinition<?, ?, ? extends Index<?, ?>> getIndexDefinition() {
+    return def;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/IndexVersionResource.java b/java/com/google/gerrit/server/config/IndexVersionResource.java
new file mode 100644
index 0000000..8ccb0b0
--- /dev/null
+++ b/java/com/google/gerrit/server/config/IndexVersionResource.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.inject.TypeLiteral;
+
+public class IndexVersionResource implements RestResource {
+  public static final TypeLiteral<RestView<IndexVersionResource>> INDEX_VERSION_KIND =
+      new TypeLiteral<>() {};
+
+  private final IndexDefinition<?, ?, ?> def;
+  private final Index<?, ?> index;
+
+  public IndexVersionResource(IndexDefinition<?, ?, ?> def, Index<?, ?> index) {
+    this.def = def;
+    this.index = index;
+  }
+
+  public IndexDefinition<?, ?, ?> getIndexDefinition() {
+    return def;
+  }
+
+  public Index<?, ?> getIndex() {
+    return index;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
index fbdb324..93df926 100644
--- a/java/com/google/gerrit/server/config/PreferencesParserUtil.java
+++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.mergeWithDefaults;
 import static com.google.gerrit.server.config.ConfigUtil.skipField;
 import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
 import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
@@ -30,6 +31,7 @@
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.proto.Entities.UserPreferences;
 import com.google.gerrit.server.git.UserConfigSections;
 import java.lang.reflect.Field;
 import java.util.ArrayList;
@@ -66,13 +68,31 @@
       r.my = input.my;
     } else {
       r.changeTable = parseChangeTableColumns(cfg, defaultCfg);
-      r.my = parseMyMenus(cfg, defaultCfg);
+      r.my = parseMyMenus(my(cfg), defaultCfg);
     }
     return r;
   }
 
   /**
    * Returns a {@link GeneralPreferencesInfo} that is the result of parsing {@code defaultCfg} for
+   * the server's default configs and {@code cfg} for the user's config.
+   */
+  public static GeneralPreferencesInfo parseGeneralPreferences(
+      GeneralPreferencesInfo cfg, @Nullable Config defaultCfg) throws ConfigInvalidException {
+    GeneralPreferencesInfo r =
+        mergeWithDefaults(
+            cfg,
+            new GeneralPreferencesInfo(),
+            defaultCfg != null
+                ? parseDefaultGeneralPreferences(defaultCfg, null)
+                : GeneralPreferencesInfo.defaults());
+    r.changeTable = cfg.changeTable != null ? cfg.changeTable : Lists.newArrayList();
+    r.my = parseMyMenus(cfg.my, defaultCfg);
+    return r;
+  }
+
+  /**
+   * Returns a {@link GeneralPreferencesInfo} that is the result of parsing {@code defaultCfg} for
    * the server's default configs. These configs are then overlaid to inherit values (default ->
    * input (if provided).
    */
@@ -110,6 +130,20 @@
 
   /**
    * Returns a {@link DiffPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs and {@code cfg} for the user's config.
+   */
+  public static DiffPreferencesInfo parseDiffPreferences(
+      DiffPreferencesInfo cfg, @Nullable Config defaultCfg) throws ConfigInvalidException {
+    return mergeWithDefaults(
+        cfg,
+        new DiffPreferencesInfo(),
+        defaultCfg != null
+            ? parseDefaultDiffPreferences(defaultCfg, null)
+            : DiffPreferencesInfo.defaults());
+  }
+
+  /**
+   * Returns a {@link DiffPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
    * server's default configs. These configs are then overlaid to inherit values (default -> input
    * (if provided).
    */
@@ -147,6 +181,20 @@
 
   /**
    * Returns a {@link EditPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs and {@code cfg} for the user's config.
+   */
+  public static EditPreferencesInfo parseEditPreferences(
+      EditPreferencesInfo cfg, @Nullable Config defaultCfg) throws ConfigInvalidException {
+    return mergeWithDefaults(
+        cfg,
+        new EditPreferencesInfo(),
+        defaultCfg != null
+            ? parseDefaultEditPreferences(defaultCfg, null)
+            : EditPreferencesInfo.defaults());
+  }
+
+  /**
+   * Returns a {@link EditPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
    * server's default configs. These configs are then overlaid to inherit values (default -> input
    * (if provided).
    */
@@ -171,11 +219,14 @@
     return changeTable;
   }
 
-  private static List<MenuItem> parseMyMenus(Config cfg, @Nullable Config defaultCfg) {
-    List<MenuItem> my = my(cfg);
-    if (my.isEmpty() && defaultCfg != null) {
+  private static List<MenuItem> parseMyMenus(
+      @Nullable List<MenuItem> my, @Nullable Config defaultCfg) {
+    if (defaultCfg != null && (my == null || my.isEmpty())) {
       my = my(defaultCfg);
     }
+    if (my == null) {
+      my = new ArrayList<>();
+    }
     if (my.isEmpty()) {
       my.add(new MenuItem("Dashboard", "#/dashboard/self", null));
       my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
@@ -264,4 +315,110 @@
     String val = cfg.getString(UserConfigSections.MY, subsection, key);
     return !Strings.isNullOrEmpty(val) ? val : defaultValue;
   }
+
+  /** Provides methods for parsing user configs */
+  public interface PreferencesParser<T> {
+    T parse(Config cfg, @Nullable Config defaultConfig, @Nullable T input)
+        throws ConfigInvalidException;
+
+    T parse(T cfg, @Nullable Config defaultConfig) throws ConfigInvalidException;
+
+    T fromUserPreferences(UserPreferences userPreferences);
+
+    T getJavaDefaults();
+  }
+
+  /** Provides methods for parsing GeneralPreferencesInfo configs */
+  public static class GeneralPreferencesParser
+      implements PreferencesParser<GeneralPreferencesInfo> {
+    public static GeneralPreferencesParser Instance = new GeneralPreferencesParser();
+
+    private GeneralPreferencesParser() {}
+
+    @Override
+    public GeneralPreferencesInfo parse(
+        Config cfg, @Nullable Config defaultCfg, @Nullable GeneralPreferencesInfo input)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseGeneralPreferences(cfg, defaultCfg, input);
+    }
+
+    @Override
+    public GeneralPreferencesInfo parse(GeneralPreferencesInfo cfg, @Nullable Config defaultCfg)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseGeneralPreferences(cfg, defaultCfg);
+    }
+
+    @Override
+    public GeneralPreferencesInfo fromUserPreferences(UserPreferences p) {
+      return UserPreferencesConverter.GeneralPreferencesInfoConverter.fromProto(
+          p.getGeneralPreferencesInfo());
+    }
+
+    @Override
+    public GeneralPreferencesInfo getJavaDefaults() {
+      return GeneralPreferencesInfo.defaults();
+    }
+  }
+
+  /** Provides methods for parsing EditPreferencesInfo configs */
+  public static class EditPreferencesParser implements PreferencesParser<EditPreferencesInfo> {
+    public static EditPreferencesParser Instance = new EditPreferencesParser();
+
+    private EditPreferencesParser() {}
+
+    @Override
+    public EditPreferencesInfo parse(
+        Config cfg, @Nullable Config defaultCfg, @Nullable EditPreferencesInfo input)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseEditPreferences(cfg, defaultCfg, input);
+    }
+
+    @Override
+    public EditPreferencesInfo parse(EditPreferencesInfo cfg, @Nullable Config defaultCfg)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseEditPreferences(cfg, defaultCfg);
+    }
+
+    @Override
+    public EditPreferencesInfo fromUserPreferences(UserPreferences p) {
+      return UserPreferencesConverter.EditPreferencesInfoConverter.fromProto(
+          p.getEditPreferencesInfo());
+    }
+
+    @Override
+    public EditPreferencesInfo getJavaDefaults() {
+      return EditPreferencesInfo.defaults();
+    }
+  }
+
+  /** Provides methods for parsing DiffPreferencesInfo configs */
+  public static class DiffPreferencesParser implements PreferencesParser<DiffPreferencesInfo> {
+    public static DiffPreferencesParser Instance = new DiffPreferencesParser();
+
+    private DiffPreferencesParser() {}
+
+    @Override
+    public DiffPreferencesInfo parse(
+        Config cfg, @Nullable Config defaultCfg, @Nullable DiffPreferencesInfo input)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseDiffPreferences(cfg, defaultCfg, input);
+    }
+
+    @Override
+    public DiffPreferencesInfo parse(DiffPreferencesInfo cfg, @Nullable Config defaultCfg)
+        throws ConfigInvalidException {
+      return PreferencesParserUtil.parseDiffPreferences(cfg, defaultCfg);
+    }
+
+    @Override
+    public DiffPreferencesInfo fromUserPreferences(UserPreferences p) {
+      return UserPreferencesConverter.DiffPreferencesInfoConverter.fromProto(
+          p.getDiffPreferencesInfo());
+    }
+
+    @Override
+    public DiffPreferencesInfo getJavaDefaults() {
+      return DiffPreferencesInfo.defaults();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index e11d6aa..1ff0a8b 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -44,7 +44,7 @@
   private final String displayName;
   private final String description;
   private final boolean inheritable;
-  private final String defaultValue;
+  @Nullable private final String defaultValue;
   private final ProjectConfigEntryType type;
   private final List<String> permittedValues;
 
diff --git a/java/com/google/gerrit/server/config/RepositoryConfig.java b/java/com/google/gerrit/server/config/RepositoryConfig.java
index d569c87..c008f63 100644
--- a/java/com/google/gerrit/server/config/RepositoryConfig.java
+++ b/java/com/google/gerrit/server/config/RepositoryConfig.java
@@ -58,7 +58,7 @@
   @Nullable
   public Path getBasePath(Project.NameKey project) {
     String basePath = cfg.getString(SECTION_NAME, findSubSection(project.get()), BASE_PATH_NAME);
-    return basePath != null ? Paths.get(basePath) : null;
+    return basePath != null ? Path.of(basePath) : null;
   }
 
   public ImmutableList<Path> getAllBasePaths() {
diff --git a/java/com/google/gerrit/server/config/UserPreferencesConverter.java b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
index 4a052d7..7eae7d0 100644
--- a/java/com/google/gerrit/server/config/UserPreferencesConverter.java
+++ b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -180,20 +181,24 @@
         MenuItem javaItem) {
       UserPreferences.GeneralPreferencesInfo.MenuItem.Builder builder =
           UserPreferences.GeneralPreferencesInfo.MenuItem.newBuilder();
-      builder = setIfNotNull(builder, builder::setName, javaItem.name);
-      builder = setIfNotNull(builder, builder::setUrl, javaItem.url);
-      builder = setIfNotNull(builder, builder::setTarget, javaItem.target);
-      builder = setIfNotNull(builder, builder::setId, javaItem.id);
+      builder = setIfNotNull(builder, builder::setName, trimSafe(javaItem.name));
+      builder = setIfNotNull(builder, builder::setUrl, trimSafe(javaItem.url));
+      builder = setIfNotNull(builder, builder::setTarget, trimSafe(javaItem.target));
+      builder = setIfNotNull(builder, builder::setId, trimSafe(javaItem.id));
       return builder.build();
     }
 
+    private static @Nullable String trimSafe(@Nullable String s) {
+      return s == null ? s : s.trim();
+    }
+
     private static MenuItem menuItemFromProto(
         UserPreferences.GeneralPreferencesInfo.MenuItem proto) {
       return new MenuItem(
-          proto.hasName() ? proto.getName() : null,
-          proto.hasUrl() ? proto.getUrl() : null,
-          proto.hasTarget() ? proto.getTarget() : null,
-          proto.hasId() ? proto.getId() : null);
+          proto.hasName() ? proto.getName().trim() : null,
+          proto.hasUrl() ? proto.getUrl().trim() : null,
+          proto.hasTarget() ? proto.getTarget().trim() : null,
+          proto.hasId() ? proto.getId().trim() : null);
     }
 
     private GeneralPreferencesInfoConverter() {}
diff --git a/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java b/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java
index bea6dd3..45a9ddf 100644
--- a/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java
+++ b/java/com/google/gerrit/server/config/VersionedDefaultPreferences.java
@@ -16,13 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.git.meta.VersionedMetaData;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
@@ -30,11 +28,9 @@
  * Low-level storage API to load Gerrit's default config from {@code All-Users}. Should not be used
  * directly.
  */
-public class VersionedDefaultPreferences extends VersionedMetaData {
+public class VersionedDefaultPreferences extends VersionedConfigFile {
   private static final String PREFERENCES_CONFIG = "preferences.config";
 
-  private Config cfg;
-
   public static Config get(Repository allUsersRepo, AllUsersName allUsersName)
       throws StorageException, ConfigInvalidException {
     VersionedDefaultPreferences versionedDefaultPreferences = new VersionedDefaultPreferences();
@@ -46,27 +42,13 @@
     return versionedDefaultPreferences.getConfig();
   }
 
-  @Override
-  protected String getRefName() {
-    return RefNames.REFS_USERS_DEFAULT;
+  public VersionedDefaultPreferences() {
+    super(RefNames.REFS_USERS_DEFAULT, PREFERENCES_CONFIG, "Update default preferences\n");
   }
 
+  @Override
   public Config getConfig() {
     checkState(cfg != null, "Default preferences not loaded yet.");
     return cfg;
   }
-
-  @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    cfg = readConfig(PREFERENCES_CONFIG);
-  }
-
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException {
-    if (Strings.isNullOrEmpty(commit.getMessage())) {
-      commit.setMessage("Update default preferences\n");
-    }
-    saveConfig(PREFERENCES_CONFIG, cfg);
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/config/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/config/package-info.java
index 0709b86..349fb04 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/config/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.config;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/data/BUILD b/java/com/google/gerrit/server/data/BUILD
index 1aaab96..4eb7b7f 100644
--- a/java/com/google/gerrit/server/data/BUILD
+++ b/java/com/google/gerrit/server/data/BUILD
@@ -10,5 +10,6 @@
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//lib:gson",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/data/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/data/package-info.java
index 0709b86..0383bb5 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/data/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.data;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/diff/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/diff/package-info.java
index 0709b86..874dd17 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/diff/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.diff;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/documentation/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/documentation/package-info.java
index 0709b86..64b3ea1 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/documentation/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.documentation;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 68569f0..565d471 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -18,12 +18,14 @@
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.Charsets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ChangeEditIdentityType;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -202,7 +204,13 @@
     Instant nowTimestamp = TimeUtil.now();
     String commitMessage = currentEditCommit.getFullMessage();
     ObjectId newEditCommitId =
-        createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
+        createCommit(
+            repository,
+            basePatchSetCommit,
+            newTreeId,
+            commitMessage,
+            currentEditCommit.getAuthorIdent(),
+            new PersonIdent(currentEditCommit.getCommitterIdent(), nowTimestamp));
 
     noteDbEdits.baseEditOnDifferentPatchset(
         project,
@@ -236,6 +244,26 @@
         CommitModification.builder().newCommitMessage(newCommitMessage).build());
   }
 
+  public void modifyIdentity(
+      Repository repository,
+      ChangeNotes notes,
+      PersonIdent identity,
+      ChangeEditIdentityType identityType)
+      throws AuthException, IOException, InvalidChangeOperationException,
+          PermissionBackendException, BadRequestException, ResourceConflictException {
+    CommitModification.Builder cmb = CommitModification.builder();
+    switch (identityType) {
+      case AUTHOR:
+        cmb.newAuthor(identity);
+        break;
+      case COMMITTER:
+      default:
+        cmb.newCommitter(identity);
+        break;
+    }
+    modifyCommit(repository, notes, new ModificationIntention.LatestCommit(), cmb.build());
+  }
+
   /**
    * Modifies the contents of a file of a change edit. If the change edit doesn't exist, a new one
    * will be created based on the current patch set.
@@ -367,6 +395,7 @@
         repository, notes, new ModificationIntention.PatchsetCommit(patchSet), commitModification);
   }
 
+  @CanIgnoreReturnValue
   private ChangeEdit modifyCommit(
       Repository repository,
       ChangeNotes notes,
@@ -402,16 +431,22 @@
         createNewCommitMessage(
             changeIdRequired, currentChangeId, editBehavior, commitModification, commitToModify);
     newCommitMessage = editBehavior.mergeCommitMessageIfNecessary(newCommitMessage, commitToModify);
+    Instant nowTimestamp = TimeUtil.now();
+    PersonIdent author = getAuthor(commitModification, commitToModify, nowTimestamp);
+    PersonIdent committer =
+        getCommitter(commitModification, commitToModify, basePatchsetCommit, nowTimestamp);
 
     Optional<ChangeEdit> unmodifiedEdit =
-        editBehavior.getEditIfNoModification(newTreeId, newCommitMessage);
+        editBehavior.getEditIfNoModification(
+            newTreeId, newCommitMessage,
+            author, committer);
     if (unmodifiedEdit.isPresent()) {
       return unmodifiedEdit.get();
     }
 
-    Instant nowTimestamp = TimeUtil.now();
     ObjectId newEditCommit =
-        createCommit(repository, basePatchsetCommit, newTreeId, newCommitMessage, nowTimestamp);
+        createCommit(
+            repository, basePatchsetCommit, newTreeId, newCommitMessage, author, committer);
 
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       return editBehavior.updateEditInStorage(
@@ -419,6 +454,44 @@
     }
   }
 
+  private PersonIdent getAuthor(
+      CommitModification commitModification, RevCommit commitToModify, Instant timestamp) {
+    PersonIdent currentAuthor = commitToModify.getAuthorIdent();
+    if (!commitModification.newAuthor().isPresent()) {
+      return currentAuthor;
+    }
+    PersonIdent newAuthor = commitModification.newAuthor().get();
+    String newName = newAuthor.getName();
+    String newEmail = newAuthor.getEmailAddress();
+    return new PersonIdent(
+        newName.isEmpty() ? currentAuthor.getName() : newName,
+        newEmail.isEmpty() ? currentAuthor.getEmailAddress() : newEmail,
+        timestamp,
+        zoneId);
+  }
+
+  private PersonIdent getCommitter(
+      CommitModification commitModification,
+      RevCommit commitToModify,
+      RevCommit basePatchsetCommit,
+      Instant timestamp) {
+    PersonIdent currentCommitter = commitToModify.getCommitterIdent();
+    if (!commitModification.newCommitter().isPresent()) {
+      if (commitToModify.equals(basePatchsetCommit)) {
+        return getCommitterIdent(basePatchsetCommit, timestamp);
+      }
+      return new PersonIdent(currentCommitter, timestamp);
+    }
+    PersonIdent newCommitter = commitModification.newCommitter().get();
+    String newName = newCommitter.getName();
+    String newEmail = newCommitter.getEmailAddress();
+    return new PersonIdent(
+        newName.isEmpty() ? currentCommitter.getName() : newName,
+        newEmail.isEmpty() ? currentCommitter.getEmailAddress() : newEmail,
+        timestamp,
+        zoneId);
+  }
+
   private void assertCanEdit(ChangeNotes notes)
       throws AuthException, PermissionBackendException, ResourceConflictException {
     if (!currentUser.get().isIdentifiedUser()) {
@@ -493,7 +566,8 @@
 
     if (!successful) {
       throw new MergeConflictException(
-          "The existing change edit could not be merged with another tree.");
+          "Rebasing change edit onto another patchset results in merge conflicts. Download the edit"
+              + " patchset and rebase manually to preserve changes.");
     }
     return threeWayMerger.getResultTreeId();
   }
@@ -528,14 +602,15 @@
       RevCommit basePatchsetCommit,
       ObjectId tree,
       String commitMessage,
-      Instant timestamp)
+      PersonIdent author,
+      PersonIdent committer)
       throws IOException {
     try (ObjectInserter objectInserter = repository.newObjectInserter()) {
       CommitBuilder builder = new CommitBuilder();
       builder.setTreeId(tree);
       builder.setParentIds(basePatchsetCommit.getParents());
-      builder.setAuthor(basePatchsetCommit.getAuthorIdent());
-      builder.setCommitter(getCommitterIdent(basePatchsetCommit, timestamp));
+      builder.setAuthor(author);
+      builder.setCommitter(committer);
       builder.setMessage(commitMessage);
       ObjectId newCommitId = objectInserter.insert(builder);
       objectInserter.flush();
@@ -572,7 +647,11 @@
     String mergeCommitMessageIfNecessary(String newCommitMessage, RevCommit commitToModify)
         throws MergeConflictException;
 
-    Optional<ChangeEdit> getEditIfNoModification(ObjectId newTreeId, String newCommitMessage);
+    Optional<ChangeEdit> getEditIfNoModification(
+        ObjectId newTreeId,
+        String newCommitMessage,
+        PersonIdent newAuthor,
+        PersonIdent newCommitter);
 
     ChangeEdit updateEditInStorage(
         Repository repository,
@@ -662,13 +741,29 @@
 
     @Override
     public Optional<ChangeEdit> getEditIfNoModification(
-        ObjectId newTreeId, String newCommitMessage) {
-      if (!ObjectId.isEqual(newTreeId, changeEdit.getEditCommit().getTree())) {
+        ObjectId newTreeId,
+        String newCommitMessage,
+        PersonIdent newAuthor,
+        PersonIdent newCommitter) {
+      RevCommit editCommit = changeEdit.getEditCommit();
+
+      if (!ObjectId.isEqual(newTreeId, editCommit.getTree())) {
         return Optional.empty();
       }
-      if (!Objects.equals(newCommitMessage, changeEdit.getEditCommit().getFullMessage())) {
+      if (!Objects.equals(newCommitMessage, editCommit.getFullMessage())) {
         return Optional.empty();
       }
+      if (!newAuthor.getName().equals(editCommit.getAuthorIdent().getName())
+          || !newAuthor.getEmailAddress().equals(editCommit.getAuthorIdent().getEmailAddress())) {
+        return Optional.empty();
+      }
+      if (!newCommitter.getName().equals(editCommit.getCommitterIdent().getName())
+          || !newCommitter
+              .getEmailAddress()
+              .equals(editCommit.getCommitterIdent().getEmailAddress())) {
+        return Optional.empty();
+      }
+
       // Modifications are already contained in the change edit.
       return Optional.of(changeEdit);
     }
@@ -723,7 +818,10 @@
 
     @Override
     public Optional<ChangeEdit> getEditIfNoModification(
-        ObjectId newTreeId, String newCommitMessage) {
+        ObjectId newTreeId,
+        String newCommitMessage,
+        PersonIdent newAuthor,
+        PersonIdent newCommitter) {
       return Optional.empty();
     }
 
@@ -756,6 +854,7 @@
       this.gitReferenceUpdated = gitReferenceUpdated;
     }
 
+    @CanIgnoreReturnValue
     ChangeEdit createEdit(
         Repository repository,
         ChangeNotes notes,
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index ab41a37..0189306 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -239,8 +239,10 @@
       throws IOException, ResourceConflictException {
     RevCommit parent = rw.parseCommit(basePatchSet.commitId());
     if (parent.getTree().equals(edit.getTree())
-        && edit.getFullMessage().equals(parent.getFullMessage())) {
-      throw new ResourceConflictException("identical tree and message");
+        && edit.getFullMessage().equals(parent.getFullMessage())
+        && parent.getAuthorIdent().equals(edit.getAuthorIdent())
+        && parent.getCommitterIdent().equals(edit.getCommitterIdent())) {
+      throw new ResourceConflictException("identical tree, message, author and committer");
     }
     return writeSquashedCommit(rw, inserter, parent, edit);
   }
@@ -283,7 +285,7 @@
     for (int i = 0; i < parent.getParentCount(); i++) {
       mergeCommit.addParentId(parent.getParent(i));
     }
-    mergeCommit.setAuthor(parent.getAuthorIdent());
+    mergeCommit.setAuthor(edit.getAuthorIdent());
     mergeCommit.setMessage(edit.getFullMessage());
     mergeCommit.setCommitter(edit.getCommitterIdent());
     mergeCommit.setTreeId(edit.getTree());
diff --git a/java/com/google/gerrit/server/edit/CommitModification.java b/java/com/google/gerrit/server/edit/CommitModification.java
index f9ed58e..f412e67 100644
--- a/java/com/google/gerrit/server/edit/CommitModification.java
+++ b/java/com/google/gerrit/server/edit/CommitModification.java
@@ -16,8 +16,10 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.server.edit.tree.TreeModification;
 import java.util.Optional;
+import org.eclipse.jgit.lib.PersonIdent;
 
 @AutoValue
 public abstract class CommitModification {
@@ -26,12 +28,17 @@
 
   public abstract Optional<String> newCommitMessage();
 
+  public abstract Optional<PersonIdent> newAuthor();
+
+  public abstract Optional<PersonIdent> newCommitter();
+
   public static Builder builder() {
     return new AutoValue_CommitModification.Builder();
   }
 
   @AutoValue.Builder
   public abstract static class Builder {
+    @CanIgnoreReturnValue
     public Builder addTreeModification(TreeModification treeModification) {
       treeModificationsBuilder().add(treeModification);
       return this;
@@ -43,6 +50,10 @@
 
     public abstract Builder newCommitMessage(String newCommitMessage);
 
+    public abstract Builder newAuthor(PersonIdent personIdent);
+
+    public abstract Builder newCommitter(PersonIdent personIdent);
+
     public abstract CommitModification build();
   }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/edit/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/edit/package-info.java
index 0709b86..dea8a96 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/edit/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.edit;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index 8839056..af0496a 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -19,10 +19,8 @@
 import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RawInput;
@@ -33,7 +31,6 @@
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
 import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -41,8 +38,6 @@
 
 /** A {@code TreeModification} which changes the content of a file. */
 public class ChangeFileContentModification implements TreeModification {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final String filePath;
   private final RawInput newContent;
   private final Integer newGitFileMode;
@@ -73,8 +68,7 @@
     return ImmutableSet.of(filePath);
   }
 
-  @VisibleForTesting
-  RawInput getNewContent() {
+  public RawInput getNewContent() {
     return newContent;
   }
 
@@ -121,15 +115,10 @@
           ObjectId newBlobObjectId = createNewBlobAndGetItsId();
           dirCacheEntry.setObjectId(newBlobObjectId);
         }
-        // Previously, these two exceptions were swallowed. To improve the
-        // situation, we log them now. However, we should think of a better
-        // approach.
       } catch (IOException e) {
         String message =
             String.format("Could not change the content of %s", dirCacheEntry.getPathString());
-        logger.atSevere().withCause(e).log("%s", message);
-      } catch (InvalidObjectIdException e) {
-        logger.atSevere().withCause(e).log("Invalid object id in submodule link");
+        throw new IllegalStateException(message, e);
       }
     }
 
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index dfc1ffb..b3ce564 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -18,9 +18,11 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.UsedAt;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
 import org.eclipse.jgit.dircache.DirCacheEditor;
@@ -39,26 +41,41 @@
 
   private final ObjectId baseTreeId;
   private final ImmutableList<? extends ObjectId> baseParents;
+  private final Optional<ObjectInserter> objectInserter;
   private final List<TreeModification> treeModifications = new ArrayList<>();
 
   public static TreeCreator basedOn(RevCommit baseCommit) {
     requireNonNull(baseCommit, "baseCommit is required");
-    return new TreeCreator(baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()));
+    return new TreeCreator(
+        baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()), Optional.empty());
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public static TreeCreator basedOn(RevCommit baseCommit, ObjectInserter objectInserter) {
+    requireNonNull(baseCommit, "baseCommit is required");
+    return new TreeCreator(
+        baseCommit.getTree(),
+        ImmutableList.copyOf(baseCommit.getParents()),
+        Optional.of(objectInserter));
   }
 
   public static TreeCreator basedOnTree(
       ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
     requireNonNull(baseTreeId, "baseTreeId is required");
-    return new TreeCreator(baseTreeId, baseParents);
+    return new TreeCreator(baseTreeId, baseParents, Optional.empty());
   }
 
   public static TreeCreator basedOnEmptyTree() {
-    return new TreeCreator(ObjectId.zeroId(), ImmutableList.of());
+    return new TreeCreator(ObjectId.zeroId(), ImmutableList.of(), Optional.empty());
   }
 
-  private TreeCreator(ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
+  private TreeCreator(
+      ObjectId baseTreeId,
+      ImmutableList<? extends ObjectId> baseParents,
+      Optional<ObjectInserter> objectInserter) {
     this.baseTreeId = requireNonNull(baseTreeId, "baseTree is required");
     this.baseParents = baseParents;
+    this.objectInserter = objectInserter;
   }
 
   /**
@@ -141,17 +158,22 @@
     return pathEdits;
   }
 
+  private ObjectId writeAndGetId(Repository repository, DirCache tree) throws IOException {
+    ObjectInserter oi = objectInserter.orElseGet(() -> repository.newObjectInserter());
+    try {
+      ObjectId treeId = tree.writeTree(oi);
+      oi.flush();
+      return treeId;
+    } finally {
+      if (objectInserter.isEmpty()) {
+        oi.close();
+      }
+    }
+  }
+
   private static void applyPathEdits(DirCache tree, List<DirCacheEditor.PathEdit> pathEdits) {
     DirCacheEditor dirCacheEditor = tree.editor();
     pathEdits.forEach(dirCacheEditor::add);
     dirCacheEditor.finish();
   }
-
-  private static ObjectId writeAndGetId(Repository repository, DirCache tree) throws IOException {
-    try (ObjectInserter objectInserter = repository.newObjectInserter()) {
-      ObjectId treeId = tree.writeTree(objectInserter);
-      objectInserter.flush();
-      return treeId;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/edit/tree/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/edit/tree/package-info.java
index 0709b86..4070c14 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/edit/tree/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.edit.tree;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index de355ea..e0bc112 100644
--- a/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.patch.DiffOperationsForCommitValidation;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -36,6 +37,13 @@
   public RevCommit commit;
   public IdentifiedUser user;
 
+  /**
+   * Use this for computing the modified files of the received commits. Using {@link
+   * com.google.gerrit.server.patch.DiffOperations} from commit validators is not safe, see javadoc
+   * on {@link DiffOperationsForCommitValidation}.
+   */
+  public DiffOperationsForCommitValidation diffOperations;
+
   public CommitReceivedEvent() {
     super(TYPE);
   }
@@ -48,7 +56,8 @@
       Config repoConfig,
       ObjectReader reader,
       ObjectId commitId,
-      IdentifiedUser user)
+      IdentifiedUser user,
+      DiffOperationsForCommitValidation diffOperations)
       throws IOException {
     this();
     this.command = command;
@@ -59,6 +68,7 @@
     this.revWalk = new RevWalk(reader);
     this.commit = revWalk.parseCommit(commitId);
     this.user = user;
+    this.diffOperations = diffOperations;
     revWalk.parseBody(commit);
   }
 
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 46fe994..2827f59 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -76,7 +76,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -601,7 +600,7 @@
   }
 
   private void addHashTags(ChangeAttribute changeAttribute, ChangeNotes notes) {
-    Set<String> hashtags = notes.load().getHashtags();
+    ImmutableSet<String> hashtags = notes.load().getHashtags();
     if (!hashtags.isEmpty()) {
       changeAttribute.hashtags = new ArrayList<>(hashtags.size());
       changeAttribute.hashtags.addAll(hashtags);
diff --git a/java/com/google/gerrit/server/events/EventTypes.java b/java/com/google/gerrit/server/events/EventTypes.java
index 4cc7198..d08a3c2 100644
--- a/java/com/google/gerrit/server/events/EventTypes.java
+++ b/java/com/google/gerrit/server/events/EventTypes.java
@@ -72,7 +72,7 @@
    *
    * @return ImmutableMap of event types, Event classes.
    */
-  public static Map<String, Class<?>> getRegisteredEvents() {
+  public static ImmutableMap<String, Class<?>> getRegisteredEvents() {
     return ImmutableMap.copyOf(typesByString);
   }
 }
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 4133c90..89aebde 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -53,6 +53,7 @@
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.config.GerritInstanceId;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
@@ -72,7 +73,9 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.stream.Collectors;
+import org.apache.commons.lang3.ObjectUtils;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -101,8 +104,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static class StreamEventsApiListenerModule extends AbstractModule {
-
-    private Config config;
+    private final Config config;
 
     public StreamEventsApiListenerModule(Config config) {
       this.config = config;
@@ -148,6 +150,8 @@
   private final ChangeNotes.Factory changeNotesFactory;
   private final boolean enableDraftCommentEvents;
 
+  private final String gerritInstanceId;
+
   @Inject
   StreamEventsApiListener(
       PluginItemContext<EventDispatcher> dispatcher,
@@ -156,7 +160,8 @@
       GitRepositoryManager repoManager,
       PatchSetUtil psUtil,
       ChangeNotes.Factory changeNotesFactory,
-      @GerritServerConfig Config config) {
+      @GerritServerConfig Config config,
+      @Nullable @GerritInstanceId String gerritInstanceId) {
     this.dispatcher = dispatcher;
     this.eventFactory = eventFactory;
     this.projectCache = projectCache;
@@ -165,6 +170,7 @@
     this.changeNotesFactory = changeNotesFactory;
     this.enableDraftCommentEvents =
         config.getBoolean("event", "stream-events", "enableDraftCommentEvents", false);
+    this.gerritInstanceId = gerritInstanceId;
   }
 
   private ChangeNotes getNotes(ChangeInfo info) {
@@ -345,6 +351,12 @@
 
   @Override
   public void onNewProjectCreated(NewProjectCreatedListener.Event ev) {
+    if (!Objects.equals(ev.getInstanceId(), gerritInstanceId)) {
+      logger.atFine().log(
+          "Ignoring project-created event for project %s (instanceId: %s)",
+          ev.getProjectName(), ObjectUtils.firstNonNull(ev.getInstanceId(), "Not defined"));
+      return;
+    }
     ProjectCreatedEvent event = new ProjectCreatedEvent();
     event.projectName = ev.getProjectName();
     event.headName = ev.getHeadName();
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/events/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/events/package-info.java
index 0709b86..cf589fe 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/events/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.events;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index 32ec401..cd91745 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -25,4 +25,39 @@
 
   /** Features, enabled by default in the current release. */
   public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = ImmutableSet.of();
+
+  /**
+   * If true, gerrit checks implicit merges on each merge operations.
+   *
+   * <p>If only this option is set (without {@link
+   * #GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE}) - then the outcome of the check is
+   * only logged and doesn't block merge operation. Any exceptions during the check are logged and
+   * doesn't block merge operation.
+   */
+  public static String GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE =
+      "GerritBackendFeature__check_implicit_merges_on_merge";
+
+  /**
+   * If true, gerrit rejects implicit merges on merge.
+   *
+   * <p>Should work together with {@link #GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE}.
+   *
+   * <p>If {@link #GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE} is set to true
+   * then implicit merges are rejected even if rejectImplicitMerges in project config is set to
+   * false.
+   *
+   * <p>If {@link #GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE} is set to false
+   * then implicit merges are rejected only if rejectImplicitMerges in project config is set to
+   * true.
+   */
+  public static String GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE =
+      "GerritBackendFeature__reject_implicit_merges_on_merge";
+
+  /** If true, gerrit ignores rejectImplicitMerges setting from the project config on merge. */
+  public static String GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE =
+      "GerritBackendFeature__always_reject_implicit_merges_on_merge";
+
+  /** Whether we allow fix suggestions in HumanComments. */
+  public static final String ALLOW_FIX_SUGGESTIONS_IN_COMMENTS =
+      "GerritBackendFeature__allow_fix_suggestions_in_comments";
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/experiments/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/experiments/package-info.java
index 0709b86..eeaeac6 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/experiments/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.experiments;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/extensions/events/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/extensions/events/package-info.java
index 0709b86..c2f060e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.extensions.events;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/extensions/webui/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/extensions/webui/package-info.java
index 0709b86..e5d7b48 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/extensions/webui/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.extensions.webui;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/fixes/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/fixes/package-info.java
index 0709b86..f36b397 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/fixes/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.fixes;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/fixes/testing/BUILD b/java/com/google/gerrit/server/fixes/testing/BUILD
index 765e8bf..cb7894c 100644
--- a/java/com/google/gerrit/server/fixes/testing/BUILD
+++ b/java/com/google/gerrit/server/fixes/testing/BUILD
@@ -12,6 +12,7 @@
         "//java/com/google/gerrit/truth",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/fixes/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/fixes/testing/package-info.java
index 0709b86..b794642 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/fixes/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.fixes.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/git/ChangesByProjectCache.java b/java/com/google/gerrit/server/git/ChangesByProjectCache.java
index df91891..1e71497 100644
--- a/java/com/google/gerrit/server/git/ChangesByProjectCache.java
+++ b/java/com/google/gerrit/server/git/ChangesByProjectCache.java
@@ -30,8 +30,8 @@
   }
 
   public static class Module extends AbstractModule {
-    private UseIndex useIndex;
-    private @GerritServerConfig Config config;
+    private final UseIndex useIndex;
+    private final Config config;
 
     public Module(UseIndex useIndex, @GerritServerConfig Config config) {
       this.useIndex = useIndex;
diff --git a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
index 094287b..ed16006 100644
--- a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
@@ -18,13 +18,13 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.Weigher;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.git.ChangesByProjectCache.UseIndex;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
@@ -94,7 +94,7 @@
     if (projectChanges != null) {
       return projectChanges
           .getUpdatedChangeDatas(
-              project, repo, cdFactory, ChangeNotes.Factory.scanChangeIds(repo), "Updating")
+              project, cdFactory, ChangeNotes.Factory.scanChangeIds(repo), "Updating")
           .stream();
     }
     if (UseIndex.TRUE.equals(useIndex)) {
@@ -114,19 +114,18 @@
     }
     return projectChanges.getUpdatedChangeDatas(
         project,
-        repo,
         cdFactory,
         ChangeNotes.Factory.scanChangeIds(repo),
         ours == projectChanges ? "Scanning" : "Updating");
   }
 
-  private Collection<ChangeData> queryChangeDatasAndLoad(Project.NameKey project) {
-    Collection<ChangeData> cds = queryChangeDatas(project);
+  private List<ChangeData> queryChangeDatasAndLoad(Project.NameKey project) {
+    List<ChangeData> cds = queryChangeDatas(project);
     cache.put(project, new CachedProjectChanges(cds));
     return cds;
   }
 
-  private Collection<ChangeData> queryChangeDatas(Project.NameKey project) {
+  private List<ChangeData> queryChangeDatas(Project.NameKey project) {
     try (TraceTimer timer =
         TraceContext.newTimer(
             "Querying changes of project", Metadata.builder().projectName(project.get()).build())) {
@@ -151,7 +150,6 @@
 
     public Collection<ChangeData> getUpdatedChangeDatas(
         Project.NameKey project,
-        Repository repo,
         ChangeData.Factory cdFactory,
         Map<Change.Id, ObjectId> metaObjectIdByChange,
         String operation) {
@@ -180,6 +178,7 @@
       }
     }
 
+    @CanIgnoreReturnValue
     public CachedProjectChanges update(ChangeData old, ChangeData updated) {
       if (old != null) {
         if (old.isPrivateOrThrow()) {
@@ -195,6 +194,7 @@
       return insert(updated);
     }
 
+    @CanIgnoreReturnValue
     public CachedProjectChanges insert(ChangeData cd) {
       if (cd.isPrivateOrThrow()) {
         privateChangeById.put(
diff --git a/java/com/google/gerrit/server/git/GarbageCollection.java b/java/com/google/gerrit/server/git/GarbageCollection.java
index 30330eb..1029b17 100644
--- a/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GarbageCollectionResult;
 import com.google.gerrit.common.data.GarbageCollectionResult.GcError;
@@ -64,15 +65,18 @@
     this.listeners = listeners;
   }
 
+  @CanIgnoreReturnValue
   public GarbageCollectionResult run(List<Project.NameKey> projectNames) {
     return run(projectNames, null);
   }
 
+  @CanIgnoreReturnValue
   public GarbageCollectionResult run(List<Project.NameKey> projectNames, PrintWriter writer) {
     return run(projectNames, gcConfig.isAggressive(), writer);
   }
 
   /** Runs GC on the given projects, serially. Progress is written to writer if non-null. */
+  @CanIgnoreReturnValue
   public GarbageCollectionResult run(
       List<Project.NameKey> projectNames, boolean aggressive, @Nullable PrintWriter writer) {
     GarbageCollectionResult result = new GarbageCollectionResult();
diff --git a/java/com/google/gerrit/server/git/GarbageCollectionQueue.java b/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
index 5df9ab5..671faf9 100644
--- a/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
+++ b/java/com/google/gerrit/server/git/GarbageCollectionQueue.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Project;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -26,6 +27,7 @@
 public class GarbageCollectionQueue {
   private final Set<Project.NameKey> projectsScheduledForGc = new HashSet<>();
 
+  @CanIgnoreReturnValue
   public synchronized Set<Project.NameKey> addAll(Collection<Project.NameKey> projects) {
     Set<Project.NameKey> added = Sets.newLinkedHashSetWithExpectedSize(projects.size());
     for (Project.NameKey p : projects) {
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 72d8bd9..b87eeea 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -75,11 +75,11 @@
 public class GroupCollector {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static List<String> getDefaultGroups(ObjectId commit) {
+  public static ImmutableList<String> getDefaultGroups(ObjectId commit) {
     return ImmutableList.of(commit.name());
   }
 
-  public static List<String> getGroups(RevisionResource rsrc) {
+  public static ImmutableList<String> getGroups(RevisionResource rsrc) {
     if (rsrc.getEdit().isPresent()) {
       // Groups for an edit are just the base revision's groups, since they have
       // the same parent.
diff --git a/java/com/google/gerrit/server/git/InMemoryInserter.java b/java/com/google/gerrit/server/git/InMemoryInserter.java
index 8d12f2b..1328b59 100644
--- a/java/com/google/gerrit/server/git/InMemoryInserter.java
+++ b/java/com/google/gerrit/server/git/InMemoryInserter.java
@@ -17,6 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.Collection;
@@ -60,10 +61,12 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public ObjectId insert(int type, byte[] data, int off, int len) {
     return insert(InsertedObject.create(type, data, off, len));
   }
 
+  @CanIgnoreReturnValue
   public ObjectId insert(InsertedObject obj) {
     inserted.put(obj.id(), obj);
     return obj.id();
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 0d6885e..1adbb67 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -27,6 +27,7 @@
 import com.google.auto.factory.Provided;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
@@ -240,6 +241,8 @@
     if (m.merge(mergeTip, originalCommit)) {
       filesWithGitConflicts = null;
       tree = m.getResultTreeId();
+      logger.atFine().log(
+          "CherryPick treeId=%s (no conflicts, inserter: %s)", tree.name(), m.getObjectInserter());
       if (tree.equals(mergeTip.getTree()) && !ignoreIdenticalTree) {
         throw new MergeIdenticalTreeException("identical tree");
       }
@@ -260,6 +263,32 @@
 
       // For merging with conflict markers we need a ResolveMerger, double-check that we have one.
       checkState(m instanceof ResolveMerger, "allow conflicts is not supported");
+
+      if (m.getResultTreeId() != null) {
+        // Merging with conflicts below uses the same DirCache instance that has been used by the
+        // Merger to attempt the merge without conflicts.
+        //
+        // The Merger uses the DirCache to do the updates, and in particular to write the result
+        // tree. DirCache caches a single DirCacheTree instance that is used to write the result
+        // tree, but it writes the result tree only if there were no conflicts.
+        //
+        // Merging with conflicts uses the same DirCache instance to write the tree with conflicts
+        // that has been used by the Merger. This means if the Merger unexpectedly wrote a result
+        // tree although there had been conflicts, then merging with conflicts uses the same
+        // DirCacheTree instance to write the tree with conflicts. However DirCacheTree#writeTree
+        // writes a tree only once and then that tree is cached. Further invocations of
+        // DirCacheTree#writeTree have no effect and return the previously created tree. This means
+        // merging with conflicts can only successfully create the tree with conflicts if the Merger
+        // didn't write a result tree yet. Hence this is checked here and we log a warning if the
+        // result tree was already written.
+        logger.atWarning().log(
+            "result tree has already been written: %s (merge: %s, conflicts: %s, failed: %s)",
+            m,
+            m.getResultTreeId().name(),
+            ((ResolveMerger) m).getUnmergedPaths(),
+            ((ResolveMerger) m).getFailingPaths());
+      }
+
       Map<String, MergeResult<? extends Sequence>> mergeResults =
           ((ResolveMerger) m).getMergeResults();
 
@@ -272,6 +301,8 @@
       tree =
           mergeWithConflicts(
               rw, inserter, dc, "HEAD", mergeTip, "CHANGE", originalCommit, mergeResults);
+      logger.atFine().log(
+          "AutoMerge treeId=%s (with conflicts, inserter: %s)", tree.name(), inserter);
     }
 
     CommitBuilder cherryPickCommit = new CommitBuilder();
@@ -283,6 +314,7 @@
     matchAuthorToCommitterDate(project, cherryPickCommit);
     CodeReviewCommit commit = rw.parseCommit(inserter.insert(cherryPickCommit));
     commit.setFilesWithGitConflicts(filesWithGitConflicts);
+    logger.atFine().log("CherryPick commitId=%s", commit.name());
     return commit;
   }
 
@@ -462,8 +494,32 @@
       tree = m.getResultTreeId();
     } else {
       List<String> conflicts = ImmutableList.of();
+      Map<String, ResolveMerger.MergeFailureReason> failed = ImmutableMap.of();
       if (m instanceof ResolveMerger) {
         conflicts = ((ResolveMerger) m).getUnmergedPaths();
+        failed = ((ResolveMerger) m).getFailingPaths();
+      }
+
+      if (m.getResultTreeId() != null) {
+        // Merging with conflicts below uses the same DirCache instance that has been used by the
+        // Merger to attempt the merge without conflicts.
+        //
+        // The Merger uses the DirCache to do the updates, and in particular to write the result
+        // tree. DirCache caches a single DirCacheTree instance that is used to write the result
+        // tree, but it writes the result tree only if there were no conflicts.
+        //
+        // Merging with conflicts uses the same DirCache instance to write the tree with conflicts
+        // that has been used by the Merger. This means if the Merger unexpectedly wrote a result
+        // tree although there had been conflicts, then merging with conflicts uses the same
+        // DirCacheTree instance to write the tree with conflicts. However DirCacheTree#writeTree
+        // writes a tree only once and then that tree is cached. Further invocations of
+        // DirCacheTree#writeTree have no effect and return the previously created tree. This means
+        // merging with conflicts can only successfully create the tree with conflicts if the Merger
+        // didn't write a result tree yet. Hence this is checked here and we log a warning if the
+        // result tree was already written.
+        logger.atWarning().log(
+            "result tree has already been written: %s (merge: %s, conflicts: %s, failed: %s)",
+            m, m.getResultTreeId().name(), conflicts, failed);
       }
 
       if (!allowConflicts) {
@@ -841,7 +897,14 @@
     }
   }
 
-  private static CodeReviewCommit failed(
+  /**
+   * Marks all commits that are reachable from the given commit {@code n} as failed by setting the
+   * provided {@code failure} status code on them.
+   *
+   * <p>If the same commits are retrieved from the same {@link CodeReviewRevWalk} instance later the
+   * status code that we set here can be read there.
+   */
+  private static void failed(
       CodeReviewRevWalk rw,
       CodeReviewCommit mergeTip,
       CodeReviewCommit n,
@@ -854,7 +917,6 @@
     while ((failed = rw.next()) != null) {
       failed.setStatusCode(failure);
     }
-    return failed;
   }
 
   public CodeReviewCommit writeMergeCommit(
@@ -993,6 +1055,12 @@
 
           @Override
           public void close() {}
+
+          @Override
+          public String toString() {
+            return String.format(
+                "%s (wrapped inserter: %s)", super.toString(), inserter.toString());
+          }
         },
         repoConfig);
   }
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index ab5c988..716e48f 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -24,6 +24,7 @@
 import com.google.common.base.Ticker;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.server.CancellationMetrics;
 import com.google.gerrit.server.cancellation.RequestStateProvider;
 import com.google.inject.assistedinject.Assisted;
@@ -297,6 +298,7 @@
    *
    * @see #waitFor(Future, long, TimeUnit, long, TimeUnit)
    */
+  @CanIgnoreReturnValue
   public <T> T waitFor(Future<T> workerFuture) {
     try {
       return waitFor(
@@ -329,6 +331,7 @@
    * @throws TimeoutException if this thread or a worker thread was interrupted, the worker was
    *     cancelled, or timed out waiting for a worker to call {@link #end()}.
    */
+  @CanIgnoreReturnValue
   public <T> T waitFor(
       Future<T> workerFuture,
       long taskTimeoutTime,
@@ -360,6 +363,7 @@
    *
    * @see #waitForNonFinalTask(Future, long, TimeUnit, long, TimeUnit)
    */
+  @CanIgnoreReturnValue
   public <T> T waitForNonFinalTask(Future<T> workerFuture) {
     try {
       return waitForNonFinalTask(workerFuture, 0, null, 0, null);
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 83024e3..d8afbbb 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -155,13 +155,14 @@
                 .byProject(key);
         Map<Change.Id, CachedChange> result = new HashMap<>(cds.size());
         for (ChangeData cd : cds) {
-          if (result.containsKey(cd.getId())) {
+          final Change.Id cdUniqueId = cd.virtualId();
+          if (result.containsKey(cdUniqueId)) {
             logger.atWarning().log(
                 "Duplicate changes returned from change query by project %s: %s, %s",
-                key, cd.change(), result.get(cd.getId()).change());
+                key, cd.change(), result.get(cdUniqueId).change());
           }
           result.put(
-              cd.getId(),
+              cdUniqueId,
               new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.reviewers()));
         }
         return List.copyOf(result.values());
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 86d6c7c..d51ee5e 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.CaseFormat;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.LifecycleListener;
@@ -46,6 +47,7 @@
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
 import java.util.concurrent.RunnableScheduledFuture;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
@@ -90,8 +92,8 @@
     private final WorkQueue workQueue;
 
     @Inject
-    Lifecycle(WorkQueue workQeueue) {
-      this.workQueue = workQeueue;
+    Lifecycle(WorkQueue workQueue) {
+      this.workQueue = workQueue;
     }
 
     @Override
@@ -480,8 +482,9 @@
 
     @Override
     protected <V> RunnableScheduledFuture<V> decorateTask(
-        Callable<V> callable, RunnableScheduledFuture<V> task) {
-      throw new UnsupportedOperationException("Callable not implemented");
+        Callable<V> callable, RunnableScheduledFuture<V> r) {
+      FutureTask<V> ft = new FutureTask<>(callable);
+      return decorateTask(ft, r);
     }
 
     void remove(Task<?> task) {
@@ -621,6 +624,7 @@
     }
 
     @Override
+    @CanIgnoreReturnValue
     public boolean cancel(boolean mayInterruptIfRunning) {
       if (task.cancel(mayInterruptIfRunning)) {
         // Tiny abuse of runningState: if the task needs to know it
@@ -695,7 +699,7 @@
         try {
           executor.onStart(this);
           runningState.set(State.RUNNING);
-          Thread.currentThread().setName(oldThreadName + "[" + task.toString() + "]");
+          Thread.currentThread().setName(oldThreadName + "[" + this + "]");
           task.run();
         } finally {
           Thread.currentThread().setName(oldThreadName);
diff --git a/java/com/google/gerrit/server/git/meta/VersionedConfigFile.java b/java/com/google/gerrit/server/git/meta/VersionedConfigFile.java
new file mode 100644
index 0000000..de8dabd
--- /dev/null
+++ b/java/com/google/gerrit/server/git/meta/VersionedConfigFile.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.meta;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.RefNames;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Versioned configuration file living in git
+ *
+ * <p>This class is a low-level API that allows callers to read the config directly from a
+ * repository and make updates to it.
+ */
+public class VersionedConfigFile extends VersionedMetaData {
+  protected final String ref;
+  protected final String fileName;
+  protected final String defaultOnSaveMessage;
+  protected Config cfg;
+
+  public VersionedConfigFile(String fileName) {
+    this(RefNames.REFS_CONFIG, fileName);
+  }
+
+  public VersionedConfigFile(String ref, String fileName) {
+    this(ref, fileName, "Updated configuration\n");
+  }
+
+  public VersionedConfigFile(String ref, String fileName, String defaultOnSaveMessage) {
+    this.ref = ref;
+    this.fileName = fileName;
+    this.defaultOnSaveMessage = defaultOnSaveMessage;
+  }
+
+  public Config getConfig() {
+    if (cfg == null) {
+      cfg = new Config();
+    }
+    return cfg;
+  }
+
+  protected String getFileName() {
+    return fileName;
+  }
+
+  @Override
+  protected String getRefName() {
+    return ref;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    cfg = readConfig(fileName);
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    if (Strings.isNullOrEmpty(commit.getMessage())) {
+      commit.setMessage(defaultOnSaveMessage);
+    }
+    saveConfig(fileName, cfg);
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index 4f0bde8..5e8f99a 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.git.GitUpdateFailureException;
@@ -204,6 +205,7 @@
    * @throws IOException if there is a storage problem and the update cannot be executed as
    *     requested or if it failed because of a concurrent update to the same reference
    */
+  @CanIgnoreReturnValue
   public RevCommit commit(MetaDataUpdate update) throws IOException {
     try (BatchMetaDataUpdate batch = openUpdate(update)) {
       batch.write(update.getCommitBuilder());
@@ -241,6 +243,7 @@
    * @throws IOException if there is a storage problem and the update cannot be executed as
    *     requested or if it failed because of a concurrent update to the same reference
    */
+  @CanIgnoreReturnValue
   public RevCommit commitToNewRef(MetaDataUpdate update, String refName) throws IOException {
     try (BatchMetaDataUpdate batch = openUpdate(update)) {
       batch.write(update.getCommitBuilder());
@@ -253,10 +256,13 @@
 
     void write(VersionedMetaData config, CommitBuilder commit) throws IOException;
 
+    @CanIgnoreReturnValue
     RevCommit createRef(String refName) throws IOException;
 
+    @CanIgnoreReturnValue
     RevCommit commit() throws IOException;
 
+    @CanIgnoreReturnValue
     RevCommit commitAt(ObjectId revision) throws IOException;
 
     @Override
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/git/meta/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/git/meta/package-info.java
index 0709b86..48fd8a6 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/git/meta/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.git.meta;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/git/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/git/package-info.java
index 0709b86..7b95aed 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/git/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.git;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 08849348..cb1af07 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.quota.QuotaGroupDefinitions.REPOSITORY_SIZE_GROUP;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.common.Nullable;
@@ -69,7 +70,6 @@
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.Collection;
-import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
@@ -422,12 +422,14 @@
     int totalChanges = 0;
     if (result.magicPush()) {
       pushType = PushType.CREATE_REPLACE;
-      Set<Change.Id> created = result.changes().get(ReceiveCommitsResult.ChangeStatus.CREATED);
-      Set<Change.Id> replaced = result.changes().get(ReceiveCommitsResult.ChangeStatus.REPLACED);
+      ImmutableSet<Change.Id> created =
+          result.changes().get(ReceiveCommitsResult.ChangeStatus.CREATED);
+      ImmutableSet<Change.Id> replaced =
+          result.changes().get(ReceiveCommitsResult.ChangeStatus.REPLACED);
       metrics.changes.record(pushType, created.size() + replaced.size());
       totalChanges = replaced.size() + created.size();
     } else {
-      Set<Change.Id> autoclosed =
+      ImmutableSet<Change.Id> autoclosed =
           result.changes().get(ReceiveCommitsResult.ChangeStatus.AUTOCLOSED);
       if (!autoclosed.isEmpty()) {
         pushType = PushType.AUTOCLOSE;
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index 5c1cf52..4194275 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -28,6 +28,7 @@
         "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
index f680b7b..6a43719 100644
--- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.patch.DiffOperationsForCommitValidation;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.ssh.SshInfo;
@@ -108,6 +109,7 @@
   Result validateCommit(
       Repository repository,
       ObjectReader objectReader,
+      DiffOperationsForCommitValidation diffOperationsForCommitValidation,
       ReceiveCommand cmd,
       RevCommit commit,
       ImmutableListMultimap<String, String> pushOptions,
@@ -116,7 +118,16 @@
       @Nullable Change change)
       throws IOException {
     return validateCommit(
-        repository, objectReader, cmd, commit, pushOptions, isMerged, rejectCommits, change, false);
+        repository,
+        objectReader,
+        diffOperationsForCommitValidation,
+        cmd,
+        commit,
+        pushOptions,
+        isMerged,
+        rejectCommits,
+        change,
+        false);
   }
 
   /**
@@ -134,6 +145,7 @@
   Result validateCommit(
       Repository repository,
       ObjectReader objectReader,
+      DiffOperationsForCommitValidation diffOperationsForCommitValidation,
       ReceiveCommand cmd,
       RevCommit commit,
       ImmutableListMultimap<String, String> pushOptions,
@@ -153,7 +165,8 @@
               new Config(repository.getConfig()),
               objectReader,
               commit,
-              user)) {
+              user,
+              diffOperationsForCommitValidation)) {
         CommitValidators validators;
         if (isMerged) {
           validators =
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index f60a0c7..a5e20c6 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -161,6 +161,7 @@
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.patch.DiffOperationsForCommitValidation;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -183,10 +184,12 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdates;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.RepoOnlyOp;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.SubmissionExecutor;
 import com.google.gerrit.server.update.SubmissionListener;
@@ -365,6 +368,7 @@
   private final AccountResolver accountResolver;
   private final AllProjectsName allProjectsName;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final BatchUpdates batchUpdates;
   private final CancellationMetrics cancellationMetrics;
   private final ChangeEditUtil editUtil;
   private final ChangeIndexer indexer;
@@ -380,6 +384,7 @@
   private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
   private final CreateRefControl createRefControl;
   private final DeadlineChecker.Factory deadlineCheckerFactory;
+  private final DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final DynamicSet<PluginPushOption> pluginPushOptions;
   private final PluginSetContext<ReceivePackInitializer> initializers;
@@ -452,6 +457,7 @@
       AccountResolver accountResolver,
       AllProjectsName allProjectsName,
       BatchUpdate.Factory batchUpdateFactory,
+      BatchUpdates batchUpdates,
       CancellationMetrics cancellationMetrics,
       ProjectConfig.Factory projectConfigFactory,
       @GerritServerConfig Config config,
@@ -467,6 +473,7 @@
       CreateGroupPermissionSyncer createGroupPermissionSyncer,
       CreateRefControl createRefControl,
       DeadlineChecker.Factory deadlineCheckerFactory,
+      DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       DynamicSet<PluginPushOption> pluginPushOptions,
       PluginSetContext<ReceivePackInitializer> initializers,
@@ -508,6 +515,7 @@
     this.accountResolver = accountResolver;
     this.allProjectsName = allProjectsName;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.batchUpdates = batchUpdates;
     this.cancellationMetrics = cancellationMetrics;
     this.changeFormatter = changeFormatterProvider.get();
     this.changeUtil = changeUtil;
@@ -519,6 +527,7 @@
     this.createRefControl = createRefControl;
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
     this.deadlineCheckerFactory = deadlineCheckerFactory;
+    this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory;
     this.editUtil = editUtil;
     this.hashtagsFactory = hashtagsFactory;
     this.setTopicFactory = setTopicFactory;
@@ -736,85 +745,94 @@
       return;
     }
 
-    List<ReceiveCommand> magicCommands = new ArrayList<>();
-    List<ReceiveCommand> regularCommands = new ArrayList<>();
+    try (ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk globalRevWalk = new RevWalk(reader)) {
+      globalRevWalk.setRetainBody(false);
 
-    for (ReceiveCommand cmd : commands) {
-      if (MagicBranch.isMagicBranch(cmd.getRefName())) {
-        magicCommands.add(cmd);
-      } else {
-        regularCommands.add(cmd);
-      }
-    }
+      List<ReceiveCommand> magicCommands = new ArrayList<>();
+      List<ReceiveCommand> regularCommands = new ArrayList<>();
 
-    if (!magicCommands.isEmpty() && !regularCommands.isEmpty()) {
-      rejectRemaining(commands, "cannot combine normal pushes and magic pushes");
-      return;
-    }
-
-    try {
-      if (!magicCommands.isEmpty()) {
-        parseMagicBranch(Iterables.getLast(magicCommands));
-        // Using the submit option submits the created change(s) immediately without checking labels
-        // nor submit rules. Hence we shouldn't record such pushes as "magic" which implies that
-        // code review is being done.
-        String pushKind = magicBranch != null && magicBranch.submit ? "direct_submit" : "magic";
-        metrics.pushCount.increment(pushKind, project.getName(), getUpdateType(magicCommands));
-      }
-      Optional<String> justification =
-          pushOptions.get(DIRECT_PUSH_JUSTIFICATION_OPTION).stream().findFirst();
-      try (RefUpdateContext ctx = RefUpdateContext.openDirectPush(justification)) {
-        if (!regularCommands.isEmpty()) {
-          metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
-        }
-
-        if (!regularCommands.isEmpty()) {
-          handleRegularCommands(regularCommands, progress);
-          return;
-        }
-      }
-
-      boolean first = true;
-      for (ReceiveCommand cmd : magicCommands) {
-        if (first) {
-          first = false;
+      for (ReceiveCommand cmd : commands) {
+        if (MagicBranch.isMagicBranch(cmd.getRefName())) {
+          magicCommands.add(cmd);
         } else {
-          reject(cmd, "duplicate request");
-        }
-      }
-    } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
-      logger.atSevere().withCause(err).log("Failed to process refs in %s", project.getName());
-      return;
-    }
-
-    Task newProgress = progress.beginSubTask("new", UNKNOWN);
-    Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
-
-    ImmutableList<CreateRequest> newChanges = ImmutableList.of();
-    try {
-      if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
-        try {
-          newChanges = selectNewAndReplacedChangesFromMagicBranch(newProgress);
-        } catch (IOException e) {
-          throw new StorageException("Failed to select new changes in " + project.getName(), e);
+          regularCommands.add(cmd);
         }
       }
 
-      // Commit validation has already happened, so any changes without Change-Id are for the
-      // deprecated feature.
-      warnAboutMissingChangeId(newChanges);
-      preparePatchSetsForReplace(newChanges);
-      insertChangesAndPatchSets(newChanges, replaceProgress);
-    } finally {
-      newProgress.end();
-      replaceProgress.end();
+      if (!magicCommands.isEmpty() && !regularCommands.isEmpty()) {
+        rejectRemaining(commands, "cannot combine normal pushes and magic pushes");
+        return;
+      }
+
+      try {
+        if (!magicCommands.isEmpty()) {
+          parseMagicBranch(globalRevWalk, Iterables.getLast(magicCommands));
+          // Using the submit option submits the created change(s) immediately without checking
+          // labels
+          // nor submit rules. Hence we shouldn't record such pushes as "magic" which implies that
+          // code review is being done.
+          String pushKind = magicBranch != null && magicBranch.submit ? "direct_submit" : "magic";
+          metrics.pushCount.increment(pushKind, project.getName(), getUpdateType(magicCommands));
+        }
+        Optional<String> justification =
+            pushOptions.get(DIRECT_PUSH_JUSTIFICATION_OPTION).stream().findFirst();
+        try (RefUpdateContext ctx = RefUpdateContext.openDirectPush(justification)) {
+          if (!regularCommands.isEmpty()) {
+            metrics.pushCount.increment(
+                "direct", project.getName(), getUpdateType(regularCommands));
+          }
+
+          if (!regularCommands.isEmpty()) {
+            handleRegularCommands(globalRevWalk, ins, regularCommands, progress);
+            return;
+          }
+        }
+
+        boolean first = true;
+        for (ReceiveCommand cmd : magicCommands) {
+          if (first) {
+            first = false;
+          } else {
+            reject(cmd, "duplicate request");
+          }
+        }
+      } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
+        logger.atSevere().withCause(err).log("Failed to process refs in %s", project.getName());
+        return;
+      }
+
+      Task newProgress = progress.beginSubTask("new", UNKNOWN);
+      Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
+
+      ImmutableList<CreateRequest> newChanges = ImmutableList.of();
+      try {
+        if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
+          try {
+            newChanges =
+                selectNewAndReplacedChangesFromMagicBranch(globalRevWalk, ins, newProgress);
+          } catch (IOException e) {
+            throw new StorageException("Failed to select new changes in " + project.getName(), e);
+          }
+        }
+
+        // Commit validation has already happened, so any changes without Change-Id are for the
+        // deprecated feature.
+        warnAboutMissingChangeId(globalRevWalk, newChanges);
+        preparePatchSetsForReplace(globalRevWalk, newChanges);
+        insertChangesAndPatchSets(globalRevWalk, ins, newChanges, replaceProgress);
+      } finally {
+        newProgress.end();
+        replaceProgress.end();
+      }
+
+      queueSuccessMessages(newChanges);
+
+      logger.atFine().log(
+          "Command results: %s",
+          lazy(() -> commands.stream().map(ReceiveCommits::commandToString).collect(joining(","))));
     }
-
-    queueSuccessMessages(newChanges);
-
-    logger.atFine().log(
-        "Command results: %s",
-        lazy(() -> commands.stream().map(ReceiveCommits::commandToString).collect(joining(","))));
   }
 
   private String getUpdateType(List<ReceiveCommand> commands) {
@@ -837,20 +855,23 @@
     }
   }
 
-  private void handleRegularCommands(List<ReceiveCommand> cmds, MultiProgressMonitor progress)
+  private void handleRegularCommands(
+      RevWalk globalRevWalk,
+      ObjectInserter ins,
+      List<ReceiveCommand> cmds,
+      MultiProgressMonitor progress)
       throws PermissionBackendException, IOException, NoSuchProjectException {
     try (TraceTimer traceTimer =
         newTimer("handleRegularCommands", Metadata.builder().resourceCount(cmds.size()))) {
       result.magicPush(false);
       for (ReceiveCommand cmd : cmds) {
-        parseRegularCommand(cmd);
+        parseRegularCommand(globalRevWalk, ins, cmd);
       }
 
       Map<BranchNameKey, ReceiveCommand> branches;
       try (BatchUpdate bu =
               batchUpdateFactory.create(
                   project.getNameKey(), user.materializedCopy(), TimeUtil.now());
-          ObjectInserter ins = repo.newObjectInserter();
           ObjectReader reader = ins.newReader();
           RevWalk rw = new RevWalk(reader);
           MergeOpRepoManager orm = ormProvider.get()) {
@@ -867,7 +888,7 @@
         logger.atFine().log("Added %d additional ref updates", added);
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(false, superprojectUpdateSubmissionListeners);
+            new SubmissionExecutor(batchUpdates, false, superprojectUpdateSubmissionListeners);
 
         submissionExecutor.execute(ImmutableList.of(bu));
 
@@ -894,7 +915,7 @@
                     Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
                     try (RefUpdateContext ctx =
                         RefUpdateContext.open(RefUpdateType.AUTO_CLOSE_CHANGES)) {
-                      autoCloseChanges(c, closeProgress);
+                      autoCloseChanges(globalRevWalk, ins, c, closeProgress);
                     }
                     closeProgress.end();
                     break;
@@ -1023,7 +1044,10 @@
   }
 
   private void insertChangesAndPatchSets(
-      ImmutableList<CreateRequest> newChanges, Task replaceProgress) {
+      RevWalk globalRevWalk,
+      ObjectInserter ins,
+      ImmutableList<CreateRequest> newChanges,
+      Task replaceProgress) {
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       try (TraceTimer traceTimer =
           newTimer(
@@ -1042,17 +1066,21 @@
             // TODO: Retry lock failures on new change insertions. The retry will
             //  likely have to move to a higher layer to be able to achieve that
             //  due to state that needs to be reset with each retry attempt.
-            insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress);
+            insertChangesAndPatchSets(
+                globalRevWalk, ins, magicBranchCmd, newChanges, replaceProgress);
           } else {
-            retryHelper
-                .changeUpdate(
-                    "insertPatchSets",
-                    updateFactory -> {
-                      insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress);
-                      return null;
-                    })
-                .defaultTimeoutMultiplier(5)
-                .call();
+            @SuppressWarnings("unused")
+            var unused =
+                retryHelper
+                    .changeUpdate(
+                        "insertPatchSets",
+                        updateFactory -> {
+                          insertChangesAndPatchSets(
+                              globalRevWalk, ins, magicBranchCmd, newChanges, replaceProgress);
+                          return null;
+                        })
+                    .defaultTimeoutMultiplier(5)
+                    .call();
           }
         } catch (ResourceConflictException e) {
           addError(e.getMessage());
@@ -1085,12 +1113,15 @@
   }
 
   private void insertChangesAndPatchSets(
-      ReceiveCommand magicBranchCmd, List<CreateRequest> newChanges, Task replaceProgress)
+      RevWalk globalRevWalk,
+      ObjectInserter ins,
+      ReceiveCommand magicBranchCmd,
+      List<CreateRequest> newChanges,
+      Task replaceProgress)
       throws RestApiException, IOException {
     try (BatchUpdate bu =
             batchUpdateFactory.create(
                 project.getNameKey(), user.materializedCopy(), TimeUtil.now());
-        ObjectInserter ins = repo.newObjectInserter();
         ObjectReader reader = ins.newReader();
         RevWalk rw = new RevWalk(reader)) {
       bu.setRepository(repo, rw, ins);
@@ -1101,7 +1132,7 @@
 
       logger.atFine().log("Adding %d replace requests", newChanges.size());
       for (ReplaceRequest replace : replaceByChange.values()) {
-        replace.addOps(bu, replaceProgress);
+        replace.addOps(globalRevWalk, bu, replaceProgress);
         if (magicBranch != null) {
           bu.setNotifyHandling(replace.ontoChange, magicBranch.getNotifyHandling(replace.notes));
           if (magicBranch.shouldPublishComments()) {
@@ -1130,7 +1161,7 @@
 
       logger.atFine().log("Adding %d create requests", newChanges.size());
       for (CreateRequest create : newChanges) {
-        create.addOps(bu);
+        create.addOps(globalRevWalk, bu);
       }
 
       logger.atFine().log("Executing batch");
@@ -1255,7 +1286,7 @@
   /*
    * Interpret a normal push.
    */
-  private void parseRegularCommand(ReceiveCommand cmd)
+  private void parseRegularCommand(RevWalk globalRevWalk, ObjectInserter ins, ReceiveCommand cmd)
       throws PermissionBackendException, NoSuchProjectException, IOException {
     try (TraceTimer traceTimer = newTimer("parseRegularCommand")) {
       if (cmd.getResult() != NOT_ATTEMPTED) {
@@ -1297,11 +1328,11 @@
 
       switch (cmd.getType()) {
         case CREATE:
-          parseCreate(cmd);
+          parseCreate(globalRevWalk, ins, cmd);
           break;
 
         case UPDATE:
-          parseUpdate(cmd);
+          parseUpdate(globalRevWalk, ins, cmd);
           break;
 
         case DELETE:
@@ -1309,7 +1340,7 @@
           break;
 
         case UPDATE_NONFASTFORWARD:
-          parseRewind(cmd);
+          parseRewind(globalRevWalk, ins, cmd);
           break;
 
         default:
@@ -1322,13 +1353,14 @@
       }
 
       if (isConfig(cmd)) {
-        validateConfigPush(cmd);
+        validateConfigPush(globalRevWalk, cmd);
       }
     }
   }
 
   /** Validates a push to refs/meta/config, and reject the command if it fails. */
-  private void validateConfigPush(ReceiveCommand cmd) throws PermissionBackendException {
+  private void validateConfigPush(RevWalk globalRevWalk, ReceiveCommand cmd)
+      throws PermissionBackendException {
     try (TraceTimer traceTimer = newTimer("validateConfigPush")) {
       logger.atFine().log("Processing %s command", cmd.getRefName());
       if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
@@ -1346,7 +1378,7 @@
         case UPDATE_NONFASTFORWARD:
           try {
             ProjectConfig cfg = projectConfigFactory.create(project.getNameKey());
-            cfg.load(project.getNameKey(), receivePack.getRevWalk(), cmd.getNewId());
+            cfg.load(project.getNameKey(), globalRevWalk, cmd.getNewId());
             if (!cfg.getValidationErrors().isEmpty()) {
               addError("Invalid project configuration:");
               for (ValidationError err : cfg.getValidationErrors()) {
@@ -1456,7 +1488,7 @@
     }
   }
 
-  private void parseCreate(ReceiveCommand cmd)
+  private void parseCreate(RevWalk globalRevWalk, ObjectInserter ins, ReceiveCommand cmd)
       throws PermissionBackendException, NoSuchProjectException, IOException {
     try (TraceTimer traceTimer = newTimer("parseCreate")) {
       if (repo.resolve(cmd.getRefName()) != null) {
@@ -1467,7 +1499,7 @@
       }
       RevObject obj;
       try {
-        obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
+        obj = globalRevWalk.parseAny(cmd.getNewId());
       } catch (IOException e) {
         throw new StorageException(
             String.format(
@@ -1476,7 +1508,7 @@
       }
       logger.atFine().log("Creating %s", cmd);
 
-      if (isHead(cmd) && !isCommit(cmd)) {
+      if (isHead(cmd) && !isCommit(globalRevWalk, cmd)) {
         return;
       }
 
@@ -1496,23 +1528,27 @@
 
       if (validRefOperation(cmd)) {
         validateRegularPushCommits(
-            BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
+            globalRevWalk, ins, BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
       }
     }
   }
 
-  private void parseUpdate(ReceiveCommand cmd) throws PermissionBackendException {
+  private void parseUpdate(RevWalk globalRevWalk, ObjectInserter ins, ReceiveCommand cmd)
+      throws PermissionBackendException {
     try (TraceTimer traceTimer = TraceContext.newTimer("parseUpdate")) {
       logger.atFine().log("Updating %s", cmd);
       Optional<AuthException> err = checkRefPermission(cmd, RefPermission.UPDATE);
       if (!err.isPresent()) {
-        if (isHead(cmd) && !isCommit(cmd)) {
+        if (isHead(cmd) && !isCommit(globalRevWalk, cmd)) {
           reject(cmd, "head must point to commit");
           return;
         }
         if (validRefOperation(cmd)) {
           validateRegularPushCommits(
-              BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
+              globalRevWalk,
+              ins,
+              BranchNameKey.create(project.getNameKey(), cmd.getRefName()),
+              cmd);
         }
       } else {
         rejectProhibited(cmd, err.get());
@@ -1520,10 +1556,10 @@
     }
   }
 
-  private boolean isCommit(ReceiveCommand cmd) {
+  private boolean isCommit(RevWalk globalRevWalk, ReceiveCommand cmd) {
     RevObject obj;
     try {
-      obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
+      obj = globalRevWalk.parseAny(cmd.getNewId());
     } catch (IOException e) {
       throw new StorageException(
           String.format(
@@ -1551,17 +1587,19 @@
 
       Optional<AuthException> err = checkRefPermission(cmd, RefPermission.DELETE);
       if (!err.isPresent()) {
-        validRefOperation(cmd);
+        @SuppressWarnings("unused")
+        var unused = validRefOperation(cmd);
       } else {
         rejectProhibited(cmd, err.get());
       }
     }
   }
 
-  private void parseRewind(ReceiveCommand cmd) throws PermissionBackendException {
+  private void parseRewind(RevWalk globalRevWalk, ObjectInserter ins, ReceiveCommand cmd)
+      throws PermissionBackendException {
     try (TraceTimer traceTimer = newTimer("parseRewind")) {
       try {
-        receivePack.getRevWalk().parseCommit(cmd.getNewId());
+        globalRevWalk.parseCommit(cmd.getNewId());
       } catch (IOException e) {
         throw new StorageException(
             String.format(
@@ -1573,7 +1611,8 @@
       if (!validRefOperation(cmd)) {
         return;
       }
-      validateRegularPushCommits(BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
+      validateRegularPushCommits(
+          globalRevWalk, ins, BranchNameKey.create(project.getNameKey(), cmd.getRefName()), cmd);
       if (cmd.getResult() != NOT_ATTEMPTED) {
         return;
       }
@@ -1947,7 +1986,8 @@
    *
    * <p>Assumes we are handling a magic branch here.
    */
-  private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException, IOException {
+  private void parseMagicBranch(RevWalk globalRevWalk, ReceiveCommand cmd)
+      throws PermissionBackendException, IOException {
     try (TraceTimer traceTimer = newTimer("parseMagicBranch")) {
       logger.atFine().log("Found magic branch %s", cmd.getRefName());
       MagicBranchInput magicBranch = new MagicBranchInput(user, projectState, cmd, labelTypes);
@@ -2076,10 +2116,9 @@
         }
       }
 
-      RevWalk walk = receivePack.getRevWalk();
       RevCommit tip;
       try {
-        tip = walk.parseCommit(magicBranch.cmd.getNewId());
+        tip = globalRevWalk.parseCommit(magicBranch.cmd.getNewId());
         logger.atFine().log("Tip of push: %s", tip.name());
       } catch (IOException ex) {
         magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
@@ -2100,8 +2139,8 @@
             reject(cmd, magicBranch.dest.branch() + " not found");
             return;
           }
-          RevCommit branchTip = receivePack.getRevWalk().parseCommit(refTip.getObjectId());
-          if (!walk.isMergedInto(tip, branchTip)) {
+          RevCommit branchTip = globalRevWalk.parseCommit(refTip.getObjectId());
+          if (!globalRevWalk.isMergedInto(tip, branchTip)) {
             reject(cmd, "not merged into branch");
             return;
           }
@@ -2122,7 +2161,7 @@
           magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
           for (ObjectId id : magicBranch.base) {
             try {
-              magicBranch.baseCommit.add(walk.parseCommit(id));
+              magicBranch.baseCommit.add(globalRevWalk.parseCommit(id));
             } catch (IncorrectObjectTypeException notCommit) {
               reject(cmd, "base must be a commit");
               return;
@@ -2137,7 +2176,7 @@
         } else if (newChangeForAllNotInTarget) {
           Ref refTip = receivePackRefCache.exactRef(magicBranch.dest.branch());
           if (refTip != null) {
-            RevCommit branchTip = receivePack.getRevWalk().parseCommit(refTip.getObjectId());
+            RevCommit branchTip = globalRevWalk.parseCommit(refTip.getObjectId());
             magicBranch.baseCommit = Collections.singletonList(branchTip);
             logger.atFine().log("Set baseCommit = %s", magicBranch.baseCommit.get(0).name());
           } else {
@@ -2159,7 +2198,7 @@
             String.format("Error walking to %s in project %s", destBranch, project.getName()), e);
       }
 
-      if (validateConnected(magicBranch.cmd, magicBranch.dest, tip)) {
+      if (validateConnected(globalRevWalk, magicBranch.cmd, magicBranch.dest, tip)) {
         this.magicBranch = magicBranch;
         this.result.magicPush(true);
       }
@@ -2178,10 +2217,10 @@
   // branch.  If they aren't, we want to abort. We do this check by
   // looking to see if we can compute a merge base between the new
   // commits and the target branch head.
-  private boolean validateConnected(ReceiveCommand cmd, BranchNameKey dest, RevCommit tip) {
+  private boolean validateConnected(
+      RevWalk globalRevWalk, ReceiveCommand cmd, BranchNameKey dest, RevCommit tip) {
     try (TraceTimer traceTimer =
         newTimer("validateConnected", Metadata.builder().branchName(dest.branch()))) {
-      RevWalk walk = receivePack.getRevWalk();
       try {
         Ref targetRef = receivePackRefCache.exactRef(dest.branch());
         if (targetRef == null || targetRef.getObjectId() == null) {
@@ -2194,21 +2233,21 @@
           return true;
         }
 
-        RevCommit h = walk.parseCommit(targetRef.getObjectId());
+        RevCommit h = globalRevWalk.parseCommit(targetRef.getObjectId());
         logger.atFine().log("Current branch tip: %s", h.name());
-        RevFilter oldRevFilter = walk.getRevFilter();
+        RevFilter oldRevFilter = globalRevWalk.getRevFilter();
         try {
-          walk.reset();
-          walk.setRevFilter(RevFilter.MERGE_BASE);
-          walk.markStart(tip);
-          walk.markStart(h);
-          if (walk.next() == null) {
+          globalRevWalk.reset();
+          globalRevWalk.setRevFilter(RevFilter.MERGE_BASE);
+          globalRevWalk.markStart(tip);
+          globalRevWalk.markStart(h);
+          if (globalRevWalk.next() == null) {
             reject(cmd, "no common ancestry");
             return false;
           }
         } finally {
-          walk.reset();
-          walk.setRevFilter(oldRevFilter);
+          globalRevWalk.reset();
+          globalRevWalk.setRevFilter(oldRevFilter);
         }
       } catch (IOException e) {
         cmd.setResult(REJECTED_MISSING_OBJECT);
@@ -2236,7 +2275,11 @@
    * @return True if the command succeeded, false if it was rejected.
    */
   private boolean requestReplaceAndValidateComments(
-      ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit)
+      RevWalk globalRevWalk,
+      ReceiveCommand cmd,
+      boolean checkMergedInto,
+      Change change,
+      RevCommit newCommit)
       throws IOException {
     try (TraceTimer traceTimer = newTimer("requestReplaceAndValidateComments")) {
       if (change.isClosed()) {
@@ -2247,7 +2290,8 @@
         return false;
       }
 
-      ReplaceRequest req = new ReplaceRequest(change.getId(), newCommit, cmd, checkMergedInto);
+      ReplaceRequest req =
+          new ReplaceRequest(globalRevWalk, change.getId(), newCommit, cmd, checkMergedInto);
       if (replaceByChange.containsKey(req.ontoChange)) {
         reject(cmd, "duplicate request");
         return false;
@@ -2287,10 +2331,11 @@
     }
   }
 
-  private void warnAboutMissingChangeId(ImmutableList<CreateRequest> newChanges) {
+  private void warnAboutMissingChangeId(
+      RevWalk globalRevWalk, ImmutableList<CreateRequest> newChanges) {
     for (CreateRequest create : newChanges) {
       try {
-        receivePack.getRevWalk().parseBody(create.commit);
+        globalRevWalk.parseBody(create.commit);
       } catch (IOException e) {
         throw new StorageException("Can't parse commit", e);
       }
@@ -2304,8 +2349,8 @@
     }
   }
 
-  private ImmutableList<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress)
-      throws IOException {
+  private ImmutableList<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(
+      RevWalk globalRevWalk, ObjectInserter ins, Task newProgress) throws IOException {
     try (TraceTimer traceTimer = newTimer("selectNewAndReplacedChangesFromMagicBranch")) {
       logger.atFine().log("Finding new and replaced changes");
       List<CreateRequest> newChanges = new ArrayList<>();
@@ -2317,7 +2362,7 @@
           commitValidatorFactory.create(projectState, magicBranch.dest, user);
 
       try {
-        RevCommit start = setUpWalkForSelectingChanges();
+        RevCommit start = setUpWalkForSelectingChanges(globalRevWalk);
         if (start == null) {
           return ImmutableList.of();
         }
@@ -2345,15 +2390,15 @@
         }
 
         for (; ; ) {
-          RevCommit c = receivePack.getRevWalk().next();
+          RevCommit c = globalRevWalk.next();
           if (c == null) {
             break;
           }
           total++;
-          receivePack.getRevWalk().parseBody(c);
+          globalRevWalk.parseBody(c);
           String name = c.name();
           groupCollector.visit(c);
-          Collection<PatchSet.Id> existingPatchSets =
+          ImmutableList<PatchSet.Id> existingPatchSets =
               receivePackRefCache.patchSetIdsFromObjectId(c);
 
           if (rejectImplicitMerges) {
@@ -2404,7 +2449,9 @@
           BranchCommitValidator.Result validationResult =
               validator.validateCommit(
                   repo,
-                  receivePack.getRevWalk().getObjectReader(),
+                  globalRevWalk.getObjectReader(),
+                  diffOperationsForCommitValidationFactory.create(
+                      new RepoView(repo, globalRevWalk, ins), ins),
                   magicBranch.cmd,
                   c,
                   ImmutableListMultimap.copyOf(pushOptions),
@@ -2440,7 +2487,7 @@
             total, alreadyTracked, newChanges.size(), pending.size());
 
         if (rejectImplicitMerges) {
-          rejectImplicitMerges(mergedParents);
+          rejectImplicitMerges(globalRevWalk, mergedParents);
         }
 
         for (Iterator<ChangeLookup> itr = pending.values().iterator(); itr.hasNext(); ) {
@@ -2490,7 +2537,7 @@
               }
             }
             if (requestReplaceAndValidateComments(
-                magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
+                globalRevWalk, magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
               continue;
             }
             return ImmutableList.of();
@@ -2535,7 +2582,7 @@
       }
 
       SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
-      List<Integer> newIds = seq.nextChangeIds(newChanges.size());
+      ImmutableList<Integer> newIds = seq.nextChangeIds(newChanges.size());
       for (int i = 0; i < newChanges.size(); i++) {
         CreateRequest create = newChanges.get(i);
         create.setChangeId(newIds.get(i));
@@ -2566,34 +2613,34 @@
     }
   }
 
-  private RevCommit setUpWalkForSelectingChanges() throws IOException {
+  private RevCommit setUpWalkForSelectingChanges(RevWalk globalRevWalk) throws IOException {
     try (TraceTimer traceTimer = newTimer("setUpWalkForSelectingChanges")) {
-      RevWalk rw = receivePack.getRevWalk();
-      RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId());
+      RevCommit start = globalRevWalk.parseCommit(magicBranch.cmd.getNewId());
 
-      rw.reset();
-      rw.sort(RevSort.TOPO);
-      rw.sort(RevSort.REVERSE, true);
-      receivePack.getRevWalk().markStart(start);
+      globalRevWalk.reset();
+      globalRevWalk.sort(RevSort.TOPO);
+      globalRevWalk.sort(RevSort.REVERSE, true);
+      globalRevWalk.markStart(start);
       if (magicBranch.baseCommit != null) {
-        markExplicitBasesUninteresting();
+        markExplicitBasesUninteresting(globalRevWalk);
       } else if (magicBranch.merged) {
         logger.atFine().log("Marking parents of merged commit %s uninteresting", start.name());
         for (RevCommit c : start.getParents()) {
-          rw.markUninteresting(c);
+          globalRevWalk.markUninteresting(c);
         }
       } else {
-        markHeadsAsUninteresting(rw, magicBranch.dest != null ? magicBranch.dest.branch() : null);
+        markHeadsAsUninteresting(
+            globalRevWalk, magicBranch.dest != null ? magicBranch.dest.branch() : null);
       }
       return start;
     }
   }
 
-  private void markExplicitBasesUninteresting() throws IOException {
+  private void markExplicitBasesUninteresting(RevWalk globalRevWalk) throws IOException {
     try (TraceTimer traceTimer = newTimer("markExplicitBasesUninteresting")) {
       logger.atFine().log("Marking %d base commits uninteresting", magicBranch.baseCommit.size());
       for (RevCommit c : magicBranch.baseCommit) {
-        receivePack.getRevWalk().markUninteresting(c);
+        globalRevWalk.markUninteresting(c);
       }
       Ref targetRef = receivePackRefCache.exactRef(magicBranch.dest.branch());
       if (targetRef != null) {
@@ -2602,36 +2649,36 @@
             magicBranch.dest.branch(), targetRef.getObjectId().name());
         receivePack
             .getRevWalk()
-            .markUninteresting(receivePack.getRevWalk().parseCommit(targetRef.getObjectId()));
+            .markUninteresting(globalRevWalk.parseCommit(targetRef.getObjectId()));
       }
     }
   }
 
-  private void rejectImplicitMerges(Set<RevCommit> mergedParents) throws IOException {
+  private void rejectImplicitMerges(RevWalk globalRevWalk, Set<RevCommit> mergedParents)
+      throws IOException {
     try (TraceTimer traceTimer = newTimer("rejectImplicitMerges")) {
       if (!mergedParents.isEmpty()) {
         Ref targetRef = receivePackRefCache.exactRef(magicBranch.dest.branch());
         if (targetRef != null) {
-          RevWalk rw = receivePack.getRevWalk();
-          RevCommit tip = rw.parseCommit(targetRef.getObjectId());
+          RevCommit tip = globalRevWalk.parseCommit(targetRef.getObjectId());
           boolean containsImplicitMerges = true;
           for (RevCommit p : mergedParents) {
-            containsImplicitMerges &= !rw.isMergedInto(p, tip);
+            containsImplicitMerges &= !globalRevWalk.isMergedInto(p, tip);
           }
 
           if (containsImplicitMerges) {
-            rw.reset();
+            globalRevWalk.reset();
             for (RevCommit p : mergedParents) {
-              rw.markStart(p);
+              globalRevWalk.markStart(p);
             }
-            rw.markUninteresting(tip);
+            globalRevWalk.markUninteresting(tip);
             RevCommit c;
-            while ((c = rw.next()) != null) {
-              rw.parseBody(c);
+            while ((c = globalRevWalk.next()) != null) {
+              globalRevWalk.parseBody(c);
               messages.add(
                   new CommitValidationMessage(
                       "Implicit Merge of "
-                          + abbreviateName(c, rw.getObjectReader())
+                          + abbreviateName(c, globalRevWalk.getObjectReader())
                           + " "
                           + c.getShortMessage(),
                       ValidationMessage.Type.ERROR));
@@ -2645,7 +2692,8 @@
 
   // Mark all branch tips as uninteresting in the given revwalk,
   // so we get only the new commits when walking rw.
-  private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) throws IOException {
+  private void markHeadsAsUninteresting(RevWalk globalRevWalk, @Nullable String forRef)
+      throws IOException {
     try (TraceTimer traceTimer =
         newTimer("markHeadsAsUninteresting", Metadata.builder().branchName(forRef))) {
       int i = 0;
@@ -2655,7 +2703,7 @@
               Collections.singletonList(receivePackRefCache.exactRef(forRef)))) {
         if (ref != null && ref.getObjectId() != null) {
           try {
-            rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
+            globalRevWalk.markUninteresting(globalRevWalk.parseCommit(ref.getObjectId()));
             i++;
           } catch (IOException e) {
             logger.atWarning().withCause(e).log(
@@ -2710,7 +2758,7 @@
     Change.Id changeId;
     ReceiveCommand cmd;
     ChangeInserter ins;
-    List<String> groups = ImmutableList.of();
+    ImmutableList<String> groups = ImmutableList.of();
 
     Change change;
 
@@ -2742,12 +2790,11 @@
       }
     }
 
-    private void addOps(BatchUpdate bu) throws RestApiException {
+    private void addOps(RevWalk globalRevWalk, BatchUpdate bu) throws RestApiException {
       try (TraceTimer traceTimer = newTimer(CreateRequest.class, "addOps")) {
         checkState(changeId != null, "must call setChangeId before addOps");
         try {
-          RevWalk rw = receivePack.getRevWalk();
-          rw.parseBody(commit);
+          globalRevWalk.parseBody(commit);
           final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
           Account.Id me = user.getAccountId();
           List<FooterLine> footerLines = commit.getFooterLines();
@@ -2852,7 +2899,8 @@
     }
   }
 
-  private void preparePatchSetsForReplace(ImmutableList<CreateRequest> newChanges) {
+  private void preparePatchSetsForReplace(
+      RevWalk globalRevWalk, ImmutableList<CreateRequest> newChanges) {
     try (TraceTimer traceTimer =
         newTimer(
             "preparePatchSetsForReplace", Metadata.builder().resourceCount(newChanges.size()))) {
@@ -2860,7 +2908,9 @@
         readChangesForReplace();
         for (ReplaceRequest req : replaceByChange.values()) {
           if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
-            req.validateNewPatchSet();
+            // TODO: Is it OK to ignore the return value?
+            @SuppressWarnings("unused")
+            var unused = req.validateNewPatchSet(globalRevWalk);
           }
         }
       } catch (IOException | PermissionBackendException e) {
@@ -2906,11 +2956,15 @@
     ReceiveCommand cmd;
     PatchSetInfo info;
     PatchSet.Id priorPatchSet;
-    List<String> groups = ImmutableList.of();
+    ImmutableList<String> groups = ImmutableList.of();
     ReplaceOp replaceOp;
 
     ReplaceRequest(
-        Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto)
+        RevWalk globalRevWalk,
+        Change.Id toChange,
+        RevCommit newCommit,
+        ReceiveCommand cmd,
+        boolean checkMergedInto)
         throws IOException {
       this.ontoChange = toChange;
       this.newCommitId = newCommit.copy();
@@ -2918,7 +2972,7 @@
       this.checkMergedInto = checkMergedInto;
 
       try {
-        revCommit = receivePack.getRevWalk().parseCommit(newCommitId);
+        revCommit = globalRevWalk.parseCommit(newCommitId);
       } catch (IOException e) {
         revCommit = null;
       }
@@ -2927,7 +2981,7 @@
         try {
           PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
           if (psId != null) {
-            revisions.forcePut(receivePack.getRevWalk().parseCommit(ref.getObjectId()), psId);
+            revisions.forcePut(globalRevWalk.parseCommit(ref.getObjectId()), psId);
           }
         } catch (IOException err) {
           logger.atWarning().withCause(err).log(
@@ -2944,17 +2998,18 @@
      * <ul>
      *   <li>May add error or warning messages to the progress monitor
      *   <li>Will reject {@code cmd} prior to returning false
-     *   <li>May reset {@code receivePack.getRevWalk()}; do not call in the middle of a walk.
+     *   <li>May reset t; do not call in the middle of a walk.
      * </ul>
      *
      * @return whether the new commit is valid
      */
-    boolean validateNewPatchSet() throws IOException, PermissionBackendException {
+    boolean validateNewPatchSet(RevWalk globalRevWalk)
+        throws IOException, PermissionBackendException {
       try (TraceTimer traceTimer = newTimer("validateNewPatchSet")) {
-        if (!validateNewPatchSetNoteDb()) {
+        if (!validateNewPatchSetNoteDb(globalRevWalk)) {
           return false;
         }
-        sameTreeWarning();
+        sameTreeWarning(globalRevWalk);
 
         if (magicBranch != null) {
           validateMagicBranchWipStatusChange();
@@ -2967,22 +3022,24 @@
           }
         }
 
-        newPatchSet();
+        newPatchSet(globalRevWalk);
         return true;
       }
     }
 
-    boolean validateNewPatchSetForAutoClose() throws IOException, PermissionBackendException {
-      if (!validateNewPatchSetNoteDb()) {
+    boolean validateNewPatchSetForAutoClose(RevWalk globalRevWalk)
+        throws IOException, PermissionBackendException {
+      if (!validateNewPatchSetNoteDb(globalRevWalk)) {
         return false;
       }
 
-      newPatchSet();
+      newPatchSet(globalRevWalk);
       return true;
     }
 
     /** Validates the new PS against permissions and notedb status. */
-    private boolean validateNewPatchSetNoteDb() throws IOException, PermissionBackendException {
+    private boolean validateNewPatchSetNoteDb(RevWalk globalRevWalk)
+        throws IOException, PermissionBackendException {
       try (TraceTimer traceTimer = newTimer("validateNewPatchSetNoteDb")) {
         if (notes == null) {
           reject(inputCommand, "change " + ontoChange + " not found");
@@ -3007,7 +3064,7 @@
           return false;
         }
 
-        RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
+        RevCommit newCommit = globalRevWalk.parseCommit(newCommitId);
 
         // Not allowed to create a new patch set if the current patch set is locked.
         if (psUtil.isPatchSetLocked(notes)) {
@@ -3024,11 +3081,15 @@
           reject(inputCommand, "change " + ontoChange + " closed");
           return false;
         } else if (revisions.containsKey(newCommit)) {
-          reject(inputCommand, "commit already exists (in the change)");
+          reject(
+              inputCommand,
+              String.format(
+                  "commit %s already exists in change %s",
+                  newCommit.name().substring(0, 10), change.getId()));
           return false;
         }
 
-        List<PatchSet.Id> existingPatchSetsWithSameCommit =
+        ImmutableList<PatchSet.Id> existingPatchSetsWithSameCommit =
             receivePackRefCache.patchSetIdsFromObjectId(newCommit);
         if (!existingPatchSetsWithSameCommit.isEmpty()) {
           // TODO(hiesel, hanwen): Remove this check entirely when Gerrit requires change IDs
@@ -3045,7 +3106,7 @@
             // Don't allow a change to directly depend upon itself. This is a
             // very common error due to users making a new commit rather than
             // amending when trying to address review comments.
-            if (receivePack.getRevWalk().isMergedInto(prior, newCommit)) {
+            if (globalRevWalk.isMergedInto(prior, newCommit)) {
               reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
               return false;
             }
@@ -3071,20 +3132,19 @@
     }
 
     /** prints a warning if the new PS has the same tree as the previous commit. */
-    private void sameTreeWarning() throws IOException {
+    private void sameTreeWarning(RevWalk globalRevWalk) throws IOException {
       try (TraceTimer traceTimer = newTimer("sameTreeWarning")) {
-        RevWalk rw = receivePack.getRevWalk();
-        RevCommit newCommit = rw.parseCommit(newCommitId);
+        RevCommit newCommit = globalRevWalk.parseCommit(newCommitId);
         RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
 
         if (newCommit.getTree().equals(priorCommit.getTree())) {
-          rw.parseBody(newCommit);
-          rw.parseBody(priorCommit);
+          globalRevWalk.parseBody(newCommit);
+          globalRevWalk.parseBody(priorCommit);
           boolean messageEq =
               Objects.equals(newCommit.getFullMessage(), priorCommit.getFullMessage());
           boolean parentsEq = parentsEqual(newCommit, priorCommit);
           boolean authorEq = authorEqual(newCommit, priorCommit);
-          ObjectReader reader = receivePack.getRevWalk().getObjectReader();
+          ObjectReader reader = globalRevWalk.getObjectReader();
 
           if (messageEq && parentsEq && authorEq) {
             addMessage(
@@ -3158,11 +3218,11 @@
     }
 
     /** Updates 'this' to add a new patchset. */
-    private void newPatchSet() throws IOException {
+    private void newPatchSet(RevWalk globalRevWalk) throws IOException {
       try (TraceTimer traceTimer = newTimer("newPatchSet")) {
-        RevCommit newCommit = receivePack.getRevWalk().parseCommit(newCommitId);
+        RevCommit newCommit = globalRevWalk.parseCommit(newCommitId);
         psId = nextPatchSetId(notes.getChange().currentPatchSetId());
-        info = patchSetInfoFactory.get(receivePack.getRevWalk(), newCommit, psId);
+        info = patchSetInfoFactory.get(globalRevWalk, newCommit, psId);
         cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
       }
     }
@@ -3175,7 +3235,7 @@
       return next;
     }
 
-    void addOps(BatchUpdate bu, @Nullable Task progress) throws IOException {
+    void addOps(RevWalk globalRevWalk, BatchUpdate bu, @Nullable Task progress) throws IOException {
       try (TraceTimer traceTimer = newTimer("addOps")) {
         if (magicBranch != null && magicBranch.edit) {
           bu.addOp(notes.getChangeId(), new ReindexOnlyOp());
@@ -3185,10 +3245,9 @@
           bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
           return;
         }
-        RevWalk rw = receivePack.getRevWalk();
         // TODO(dborowitz): Move to ReplaceOp#updateRepo.
-        RevCommit newCommit = rw.parseCommit(newCommitId);
-        rw.parseBody(newCommit);
+        RevCommit newCommit = globalRevWalk.parseCommit(newCommitId);
+        globalRevWalk.parseBody(newCommit);
 
         RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
         replaceOp =
@@ -3221,7 +3280,6 @@
                 Optional<ReceiveCommand> autoMerge =
                     autoMerger.createAutoMergeCommitIfNecessary(
                         ctx.getRepoView(),
-                        ctx.getRevWalk(),
                         ctx.getInserter(),
                         ctx.getRevWalk().parseCommit(newCommitId));
                 if (autoMerge.isPresent()) {
@@ -3342,7 +3400,8 @@
    *
    * <p>On validation failure, the command is rejected.
    */
-  private void validateRegularPushCommits(BranchNameKey branch, ReceiveCommand cmd)
+  private void validateRegularPushCommits(
+      RevWalk globalRevWalk, ObjectInserter ins, BranchNameKey branch, ReceiveCommand cmd)
       throws PermissionBackendException {
     try (TraceTimer traceTimer =
         newTimer("validateRegularPushCommits", Metadata.builder().branchName(branch.branch()))) {
@@ -3369,19 +3428,18 @@
       }
 
       BranchCommitValidator validator = commitValidatorFactory.create(projectState, branch, user);
-      RevWalk walk = receivePack.getRevWalk();
-      walk.reset();
-      walk.sort(RevSort.NONE);
+      globalRevWalk.reset();
+      globalRevWalk.sort(RevSort.NONE);
       try {
-        RevObject parsedObject = walk.parseAny(cmd.getNewId());
+        RevObject parsedObject = globalRevWalk.parseAny(cmd.getNewId());
         if (!(parsedObject instanceof RevCommit)) {
           return;
         }
-        walk.markStart((RevCommit) parsedObject);
-        markHeadsAsUninteresting(walk, cmd.getRefName());
+        globalRevWalk.markStart((RevCommit) parsedObject);
+        markHeadsAsUninteresting(globalRevWalk, cmd.getRefName());
         int limit = receiveConfig.maxBatchCommits;
         int n = 0;
-        for (RevCommit c; (c = walk.next()) != null; ) {
+        for (RevCommit c; (c = globalRevWalk.next()) != null; ) {
           // Even if skipValidation is set, we still get here when at least one plugin
           // commit validator requires to validate all commits. In this case, however,
           // we don't need to check the commit limit.
@@ -3400,7 +3458,9 @@
           BranchCommitValidator.Result validationResult =
               validator.validateCommit(
                   repo,
-                  walk.getObjectReader(),
+                  globalRevWalk.getObjectReader(),
+                  diffOperationsForCommitValidationFactory.create(
+                      new RepoView(repo, globalRevWalk, ins), ins),
                   cmd,
                   c,
                   ImmutableListMultimap.copyOf(pushOptions),
@@ -3422,7 +3482,8 @@
     }
   }
 
-  private void autoCloseChanges(ReceiveCommand cmd, Task progress) {
+  private void autoCloseChanges(
+      RevWalk globalRevWalk, ObjectInserter ins, ReceiveCommand cmd, Task progress) {
     try (TraceTimer traceTimer = newTimer("autoCloseChanges")) {
       logger.atFine().log("Starting auto-closing of changes");
       String refName = cmd.getRefName();
@@ -3431,140 +3492,149 @@
       // TODO(dborowitz): Combine this BatchUpdate with the main one in
       // handleRegularCommands
       try {
-        retryHelper
-            .changeUpdate(
-                "autoCloseChanges",
-                updateFactory -> {
-                  try (BatchUpdate bu =
-                          updateFactory.create(projectState.getNameKey(), user, TimeUtil.now());
-                      ObjectInserter ins = repo.newObjectInserter();
-                      ObjectReader reader = ins.newReader();
-                      RevWalk rw = new RevWalk(reader)) {
-                    if (ObjectId.zeroId().equals(cmd.getOldId())) {
-                      // The user is creating a new branch. The branch can't contain any changes, so
-                      // auto-closing doesn't apply. Exiting here early to spare any further,
-                      // potentially expensive computation that loop over all commits.
-                      return null;
-                    }
+        @SuppressWarnings("unused")
+        var unused =
+            retryHelper
+                .changeUpdate(
+                    "autoCloseChanges",
+                    updateFactory -> {
+                      try (BatchUpdate bu =
+                              updateFactory.create(
+                                  projectState.getNameKey(), user, TimeUtil.now());
+                          ObjectReader reader = ins.newReader();
+                          RevWalk rw = new RevWalk(reader)) {
+                        if (ObjectId.zeroId().equals(cmd.getOldId())) {
+                          // The user is creating a new branch. The branch can't contain any
+                          // changes, so
+                          // auto-closing doesn't apply. Exiting here early to spare any further,
+                          // potentially expensive computation that loop over all commits.
+                          return null;
+                        }
 
-                    bu.setRepository(repo, rw, ins);
-                    // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
+                        bu.setRepository(repo, rw, ins);
+                        // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
 
-                    RevCommit newTip = rw.parseCommit(cmd.getNewId());
-                    BranchNameKey branch = BranchNameKey.create(project.getNameKey(), refName);
+                        RevCommit newTip = rw.parseCommit(cmd.getNewId());
+                        BranchNameKey branch = BranchNameKey.create(project.getNameKey(), refName);
 
-                    rw.reset();
-                    rw.sort(RevSort.REVERSE);
-                    rw.markStart(newTip);
-                    rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
+                        rw.reset();
+                        rw.sort(RevSort.REVERSE);
+                        rw.markStart(newTip);
+                        rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
 
-                    Map<Change.Key, ChangeData> changeDataByKey = null;
-                    List<ReplaceRequest> replaceAndClose = new ArrayList<>();
+                        Map<Change.Key, ChangeData> changeDataByKey = null;
+                        List<ReplaceRequest> replaceAndClose = new ArrayList<>();
 
-                    int existingPatchSets = 0;
-                    int newPatchSets = 0;
-                    SubmissionId submissionId = null;
-                    COMMIT:
-                    for (RevCommit c; (c = rw.next()) != null; ) {
-                      rw.parseBody(c);
+                        int existingPatchSets = 0;
+                        int newPatchSets = 0;
+                        SubmissionId submissionId = null;
+                        COMMIT:
+                        for (RevCommit c; (c = rw.next()) != null; ) {
+                          rw.parseBody(c);
 
-                      // Check if change refs point to this commit. Usually there are 0-1 change
-                      // refs pointing to this commit.
-                      for (PatchSet.Id psId :
-                          receivePackRefCache.patchSetIdsFromObjectId(c.copy())) {
-                        Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
-                        if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
-                          if (submissionId == null) {
-                            submissionId = new SubmissionId(notes.get().getChange());
+                          // Check if change refs point to this commit. Usually there are 0-1 change
+                          // refs pointing to this commit.
+                          for (PatchSet.Id psId :
+                              receivePackRefCache.patchSetIdsFromObjectId(c.copy())) {
+                            Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
+                            if (notes.isPresent()
+                                && notes.get().getChange().getDest().equals(branch)) {
+                              if (submissionId == null) {
+                                submissionId = new SubmissionId(notes.get().getChange());
+                              }
+                              existingPatchSets++;
+                              bu.addOp(
+                                  notes.get().getChangeId(),
+                                  setPrivateOpFactory.create(false, null));
+                              bu.addOp(
+                                  psId.changeId(),
+                                  mergedByPushOpFactory.create(
+                                      requestScopePropagator,
+                                      psId,
+                                      submissionId,
+                                      refName,
+                                      newTip.getId().getName()));
+                              continue COMMIT;
+                            }
                           }
-                          existingPatchSets++;
+
+                          for (String changeId : changeUtil.getChangeIdsFromFooter(c)) {
+                            if (changeDataByKey == null) {
+                              changeDataByKey =
+                                  retryHelper
+                                      .changeIndexQuery(
+                                          "queryOpenChangesByKeyByBranch",
+                                          q -> openChangesByKeyByBranch(q, branch))
+                                      .call();
+                            }
+
+                            ChangeData onto = changeDataByKey.get(Change.key(changeId.trim()));
+                            if (onto != null) {
+                              newPatchSets++;
+                              // Hold onto this until we're done with the walk, as the call to
+                              // req.validate below calls isMergedInto which resets the walk.
+                              ChangeNotes ontoNotes = onto.notes();
+                              ReplaceRequest req =
+                                  new ReplaceRequest(
+                                      globalRevWalk, ontoNotes.getChangeId(), c, cmd, false);
+                              req.notes = ontoNotes;
+                              replaceAndClose.add(req);
+                              continue COMMIT;
+                            }
+                          }
+                        }
+
+                        for (ReplaceRequest req : replaceAndClose) {
+                          Change.Id id = req.notes.getChangeId();
+                          if (!req.validateNewPatchSetForAutoClose(globalRevWalk)) {
+                            logger.atFine().log("Not closing %s because validation failed", id);
+                            continue;
+                          }
+                          if (submissionId == null) {
+                            submissionId = new SubmissionId(req.notes.getChange());
+                          }
+                          req.addOps(globalRevWalk, bu, null);
+                          bu.addOp(id, setPrivateOpFactory.create(false, null));
                           bu.addOp(
-                              notes.get().getChangeId(), setPrivateOpFactory.create(false, null));
-                          bu.addOp(
-                              psId.changeId(),
-                              mergedByPushOpFactory.create(
-                                  requestScopePropagator,
-                                  psId,
-                                  submissionId,
-                                  refName,
-                                  newTip.getId().getName()));
-                          continue COMMIT;
-                        }
-                      }
-
-                      for (String changeId : changeUtil.getChangeIdsFromFooter(c)) {
-                        if (changeDataByKey == null) {
-                          changeDataByKey =
-                              retryHelper
-                                  .changeIndexQuery(
-                                      "queryOpenChangesByKeyByBranch",
-                                      q -> openChangesByKeyByBranch(q, branch))
-                                  .call();
+                              id,
+                              mergedByPushOpFactory
+                                  .create(
+                                      requestScopePropagator,
+                                      req.psId,
+                                      submissionId,
+                                      refName,
+                                      newTip.getId().getName())
+                                  .setPatchSetProvider(req.replaceOp::getPatchSet));
+                          bu.addOp(id, new ChangeProgressOp(progress));
+                          ids.add(id);
                         }
 
-                        ChangeData onto = changeDataByKey.get(Change.key(changeId.trim()));
-                        if (onto != null) {
-                          newPatchSets++;
-                          // Hold onto this until we're done with the walk, as the call to
-                          // req.validate below calls isMergedInto which resets the walk.
-                          ChangeNotes ontoNotes = onto.notes();
-                          ReplaceRequest req =
-                              new ReplaceRequest(ontoNotes.getChangeId(), c, cmd, false);
-                          req.notes = ontoNotes;
-                          replaceAndClose.add(req);
-                          continue COMMIT;
-                        }
+                        logger.atFine().log(
+                            "Auto-closing %d changes with existing patch sets and %d with new patch"
+                                + " sets",
+                            existingPatchSets, newPatchSets);
+                        bu.execute();
+                      } catch (IOException | StorageException | PermissionBackendException e) {
+                        throw new StorageException("Failed to auto-close changes", e);
                       }
-                    }
 
-                    for (ReplaceRequest req : replaceAndClose) {
-                      Change.Id id = req.notes.getChangeId();
-                      if (!req.validateNewPatchSetForAutoClose()) {
-                        logger.atFine().log("Not closing %s because validation failed", id);
-                        continue;
-                      }
-                      if (submissionId == null) {
-                        submissionId = new SubmissionId(req.notes.getChange());
-                      }
-                      req.addOps(bu, null);
-                      bu.addOp(id, setPrivateOpFactory.create(false, null));
-                      bu.addOp(
-                          id,
-                          mergedByPushOpFactory
-                              .create(
-                                  requestScopePropagator,
-                                  req.psId,
-                                  submissionId,
-                                  refName,
-                                  newTip.getId().getName())
-                              .setPatchSetProvider(req.replaceOp::getPatchSet));
-                      bu.addOp(id, new ChangeProgressOp(progress));
-                      ids.add(id);
-                    }
+                      // If we are here, we didn't throw UpdateException. Record the result.
+                      // The ordering is indeterminate due to the HashSet; unfortunately, Change.Id
+                      // doesn't
+                      // fit into TreeSet.
+                      ids.stream()
+                          .forEach(
+                              id ->
+                                  result.addChange(
+                                      ReceiveCommitsResult.ChangeStatus.AUTOCLOSED, id));
 
-                    logger.atFine().log(
-                        "Auto-closing %d changes with existing patch sets and %d with new patch"
-                            + " sets",
-                        existingPatchSets, newPatchSets);
-                    bu.execute();
-                  } catch (IOException | StorageException | PermissionBackendException e) {
-                    throw new StorageException("Failed to auto-close changes", e);
-                  }
-
-                  // If we are here, we didn't throw UpdateException. Record the result.
-                  // The ordering is indeterminate due to the HashSet; unfortunately, Change.Id
-                  // doesn't
-                  // fit into TreeSet.
-                  ids.stream()
-                      .forEach(
-                          id -> result.addChange(ReceiveCommitsResult.ChangeStatus.AUTOCLOSED, id));
-
-                  return null;
-                })
-            // Use a multiple of the default timeout to account for inner retries that may otherwise
-            // eat up the whole timeout so that no time is left to retry this outer action.
-            .defaultTimeoutMultiplier(5)
-            .call();
+                      return null;
+                    })
+                // Use a multiple of the default timeout to account for inner retries that may
+                // otherwise
+                // eat up the whole timeout so that no time is left to retry this outer action.
+                .defaultTimeoutMultiplier(5)
+                .call();
       } catch (RestApiException e) {
         logger.atSevere().withCause(e).log("Can't insert patchset");
       } catch (UpdateException e) {
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsResult.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsResult.java
index ecbdcbc..c2c3682 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsResult.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsResult.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Change;
 import java.util.Arrays;
 import java.util.EnumMap;
@@ -59,11 +60,13 @@
     }
 
     /** Record a change ID update as having completed. */
+    @CanIgnoreReturnValue
     public Builder addChange(ChangeStatus key, Change.Id id) {
       changes.get(key).add(id);
       return this;
     }
 
+    @CanIgnoreReturnValue
     public abstract Builder magicPush(boolean isMagicPush);
 
     public ReceiveCommitsResult build() {
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 254e57b..c0ffde3 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -352,13 +352,14 @@
 
     approvalCopierResult =
         approvalsUtil.copyApprovalsToNewPatchSet(
-            ctx.getNotes(), newPatchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
+            ctx.getNotes(), newPatchSet, ctx.getRepoView(), update);
 
     mailMessage = insertChangeMessage(update, ctx, reviewMessage);
     if (mergedByPushOp == null) {
       resetChange(ctx);
     } else {
-      mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet)).updateChange(ctx);
+      @SuppressWarnings("unused")
+      var unused = mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet)).updateChange(ctx);
     }
 
     return true;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/git/receive/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/git/receive/package-info.java
index 0709b86..110fb4e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/git/receive/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.git.receive;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/git/receive/testing/BUILD b/java/com/google/gerrit/server/git/receive/testing/BUILD
index 06407ae..fc18cbc 100644
--- a/java/com/google/gerrit/server/git/receive/testing/BUILD
+++ b/java/com/google/gerrit/server/git/receive/testing/BUILD
@@ -10,5 +10,6 @@
         "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
index 4d2805d..47df03a 100644
--- a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
+++ b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
@@ -19,12 +19,12 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.stream.StreamSupport;
@@ -61,7 +61,7 @@
 
   @Override
   protected void writeOne(CharSequence line) throws IOException {
-    List<String> lineParts =
+    ImmutableList<String> lineParts =
         StreamSupport.stream(Splitter.on(' ').split(line).spliterator(), false)
             .map(String::trim)
             .collect(toImmutableList());
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/git/receive/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/git/receive/testing/package-info.java
index 0709b86..90f34d7 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/git/receive/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.git.receive.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/git/validators/CommentSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentSizeValidator.java
index 58b0cb1..f31f148 100644
--- a/java/com/google/gerrit/server/git/validators/CommentSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentSizeValidator.java
@@ -46,7 +46,7 @@
   private boolean exceedsSizeLimit(CommentForValidation comment) {
     switch (comment.getSource()) {
       case HUMAN:
-        return comment.getApproximateSize() > commentSizeLimit;
+        return commentSizeLimit > 0 && comment.getApproximateSize() > commentSizeLimit;
       case ROBOT:
         return robotCommentSizeLimit > 0 && comment.getApproximateSize() > robotCommentSizeLimit;
     }
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidationListener.java b/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
index fbc582b..9f68c0d 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
@@ -23,6 +23,11 @@
  *
  * <p>Invoked by Gerrit when a new commit is received, has passed basic Gerrit validation and can be
  * then subject to extra validation checks.
+ *
+ * <p>Do not use {@link com.google.gerrit.server.patch.DiffOperations} from {@code
+ * CommitValidationListener} implementations to get the modified files for the received commit,
+ * instead use {@link com.google.gerrit.server.patch.DiffOperationsForCommitValidation} that is
+ * provided in {@link CommitReceivedEvent#diffOperations}.
  */
 @ExtensionPoint
 public interface CommitValidationListener {
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 6adaae2..5c7d524 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -25,6 +25,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -36,6 +37,10 @@
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -52,9 +57,7 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.DiffOptions;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
@@ -63,6 +66,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.gerrit.server.ssh.HostKey;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.server.util.MagicBranch;
@@ -112,9 +116,10 @@
     private final AccountCache accountCache;
     private final ProjectCache projectCache;
     private final ProjectConfig.Factory projectConfigFactory;
-    private final DiffOperations diffOperations;
     private final Config config;
     private final ChangeUtil changeUtil;
+    private final MetricMaker metricMaker;
+    private final ApprovalQueryBuilder approvalQueryBuilder;
 
     @Inject
     Factory(
@@ -130,8 +135,9 @@
         AccountCache accountCache,
         ProjectCache projectCache,
         ProjectConfig.Factory projectConfigFactory,
-        DiffOperations diffOperations,
-        ChangeUtil changeUtil) {
+        ChangeUtil changeUtil,
+        MetricMaker metricMaker,
+        ApprovalQueryBuilder approvalQueryBuilder) {
       this.gerritIdent = gerritIdent;
       this.urlFormatter = urlFormatter;
       this.config = config;
@@ -144,8 +150,9 @@
       this.accountCache = accountCache;
       this.projectCache = projectCache;
       this.projectConfigFactory = projectConfigFactory;
-      this.diffOperations = diffOperations;
       this.changeUtil = changeUtil;
+      this.metricMaker = metricMaker;
+      this.approvalQueryBuilder = approvalQueryBuilder;
     }
 
     public CommitValidators forReceiveCommits(
@@ -166,7 +173,7 @@
           .add(new ProjectStateValidationListener(projectState))
           .add(new AmendedGerritMergeCommitValidationListener(perm, gerritIdent))
           .add(new AuthorUploaderValidator(user, perm, urlFormatter.get()))
-          .add(new FileCountValidator(config, urlFormatter.get(), diffOperations))
+          .add(new FileCountValidator(config, urlFormatter.get(), metricMaker))
           .add(new CommitterUploaderValidator(user, perm, urlFormatter.get()))
           .add(new SignedOffByValidator(user, perm, projectState))
           .add(
@@ -178,7 +185,7 @@
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker, accountCache))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
           .add(new GroupCommitValidator(allUsers))
-          .add(new LabelConfigValidator(diffOperations));
+          .add(new LabelConfigValidator(approvalQueryBuilder));
       return new CommitValidators(validators.build());
     }
 
@@ -198,7 +205,7 @@
           .add(new ProjectStateValidationListener(projectState))
           .add(new AmendedGerritMergeCommitValidationListener(perm, gerritIdent))
           .add(new AuthorUploaderValidator(user, perm, urlFormatter.get()))
-          .add(new FileCountValidator(config, urlFormatter.get(), diffOperations))
+          .add(new FileCountValidator(config, urlFormatter.get(), metricMaker))
           .add(new SignedOffByValidator(user, perm, projectState))
           .add(
               new ChangeIdValidator(
@@ -208,7 +215,7 @@
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker, accountCache))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
           .add(new GroupCommitValidator(allUsers))
-          .add(new LabelConfigValidator(diffOperations));
+          .add(new LabelConfigValidator(approvalQueryBuilder));
       return new CommitValidators(validators.build());
     }
 
@@ -246,6 +253,7 @@
     this.validators = validators;
   }
 
+  @CanIgnoreReturnValue
   public List<CommitValidationMessage> validate(CommitReceivedEvent receiveEvent)
       throws CommitValidationException {
     List<CommitValidationMessage> messages = new ArrayList<>();
@@ -444,11 +452,20 @@
 
     private final int maxFileCount;
     private final UrlFormatter urlFormatter;
-    private final DiffOperations diffOperations;
+    private final Counter2<Integer, String> metricCountManyFilesPerChange;
 
-    FileCountValidator(Config config, UrlFormatter urlFormatter, DiffOperations diffOperations) {
+    FileCountValidator(Config config, UrlFormatter urlFormatter, MetricMaker metricMaker) {
       this.urlFormatter = urlFormatter;
-      this.diffOperations = diffOperations;
+      this.metricCountManyFilesPerChange =
+          metricMaker.newCounter(
+              "validation/file_count",
+              new Description("Count commits with many files per change."),
+              Field.ofInteger("file_count", (meta, value) -> {})
+                  .description("number of files in the patchset")
+                  .build(),
+              Field.ofString("host_repo", (meta, value) -> {})
+                  .description("host and repository of the change in the format 'host/repo'")
+                  .build());
       maxFileCount = config.getInt("change", null, "maxFiles", 100_000);
     }
 
@@ -482,6 +499,9 @@
           logger.atWarning().log(
               "Warning: Change with %d files on host %s, project %s, ref %s",
               changedFiles, host, project, refName);
+
+          this.metricCountManyFilesPerChange.increment(
+              Math.toIntExact(changedFiles), String.format("%s/%s", host, project));
         }
       } catch (DiffNotAvailableException e) {
         // This happens e.g. for cherrypicks.
@@ -496,11 +516,14 @@
     private int countChangedFiles(CommitReceivedEvent receiveEvent)
         throws DiffNotAvailableException {
       // For merge commits this will compare against auto-merge.
-      Map<String, FileDiffOutput> modifiedFiles =
-          diffOperations.listModifiedFilesAgainstParent(
-              receiveEvent.getProjectNameKey(), receiveEvent.commit, 0, DiffOptions.DEFAULTS);
+      Map<String, ModifiedFile> modifiedFiles =
+          receiveEvent.diffOperations.loadModifiedFilesAgainstParentIfNecessary(
+              receiveEvent.getProjectNameKey(),
+              receiveEvent.commit,
+              0,
+              /* enableRenameDetection= */ true);
       // We don't want to count the COMMIT_MSG and MERGE_LIST files.
-      List<FileDiffOutput> modifiedFilesList =
+      List<ModifiedFile> modifiedFilesList =
           modifiedFiles.values().stream()
               .filter(p -> !Patch.isMagic(p.newPath().orElse("")))
               .collect(Collectors.toList());
@@ -977,7 +1000,7 @@
     }
   }
 
-  private static CommitValidationMessage invalidEmail(
+  public static CommitValidationMessage invalidEmail(
       String type, PersonIdent who, IdentifiedUser currentUser, UrlFormatter urlFormatter) {
     StringBuilder sb = new StringBuilder();
 
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 514dee1..710e688 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -101,7 +101,7 @@
       PatchSet.Id patchSetId,
       IdentifiedUser caller)
       throws MergeValidationException {
-    List<MergeValidationListener> validators =
+    ImmutableList<MergeValidationListener> validators =
         ImmutableList.of(
             new PluginMergeValidationListener(mergeValidationListeners),
             projectConfigValidatorFactory.create(),
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index 9ac3c89..b11400b 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -89,6 +90,7 @@
    * when the first validator fails. Will not process any more validators after the first failure
    * was encountered.
    */
+  @CanIgnoreReturnValue
   public List<ValidationMessage> validateForRefOperation() throws RefOperationValidationException {
     List<ValidationMessage> messages = new ArrayList<>();
     boolean withException = false;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/git/validators/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/git/validators/package-info.java
index 0709b86..74c8cec 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/git/validators/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.git.validators;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index 682fd15..d378d51 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -23,6 +23,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -292,6 +293,7 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public RevCommit commit(MetaDataUpdate update) throws IOException {
     RevCommit c = super.commit(update);
     loadedGroup = Optional.of(loadedGroup.get().toBuilder().setRefState(c.toObjectId()).build());
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 14f8825..31538d3 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -263,6 +264,7 @@
    * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
    * @return the created {@link InternalGroup}
    */
+  @CanIgnoreReturnValue
   public InternalGroup createGroup(InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws DuplicateKeyException, IOException, ConfigInvalidException {
     try (TraceTimer ignored =
@@ -364,6 +366,7 @@
   }
 
   @VisibleForTesting
+  @CanIgnoreReturnValue
   public UpdateResult updateGroupInNoteDb(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
     try (RefUpdateContext ctx = RefUpdateContext.open(GROUPS_UPDATE)) {
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/group/db/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/group/db/package-info.java
index 0709b86..3c93d74 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/group/db/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.group.db;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/group/db/testing/BUILD b/java/com/google/gerrit/server/group/db/testing/BUILD
index a4f49e9..c34f96f 100644
--- a/java/com/google/gerrit/server/group/db/testing/BUILD
+++ b/java/com/google/gerrit/server/group/db/testing/BUILD
@@ -12,5 +12,6 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:jgit-junit",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/group/db/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/group/db/testing/package-info.java
index 0709b86..856ad1c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/group/db/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.group.db.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/group/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/group/package-info.java
index 0709b86..39ad2d9 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/group/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.group;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index bb2b20d..fcbda9f 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -12,6 +12,7 @@
         "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 1f3dbcb..e181c2b 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -18,6 +18,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -55,6 +56,7 @@
    * @param uuid the group UUID to add.
    * @return the created group
    */
+  @CanIgnoreReturnValue
   public GroupDescription.Basic create(AccountGroup.UUID uuid) {
     checkState(uuid.get().startsWith(PREFIX), "test group UUID must have prefix '" + PREFIX + "'");
     if (groups.containsKey(uuid)) {
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/group/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/group/testing/package-info.java
index 0709b86..d0529cc 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/group/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.group.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/index/AbstractIndexModule.java b/java/com/google/gerrit/server/index/AbstractIndexModule.java
index 9e9da91..e24a2b1 100644
--- a/java/com/google/gerrit/server/index/AbstractIndexModule.java
+++ b/java/com/google/gerrit/server/index/AbstractIndexModule.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.index;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.lifecycle.LifecycleModule;
@@ -25,7 +26,6 @@
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
-import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -37,10 +37,11 @@
   public static final String INDEX_MODULE = "index-module";
 
   private final int threads;
-  private final Map<String, Integer> singleVersions;
+  private final ImmutableMap<String, Integer> singleVersions;
   private final boolean slave;
 
-  protected AbstractIndexModule(Map<String, Integer> singleVersions, int threads, boolean slave) {
+  protected AbstractIndexModule(
+      ImmutableMap<String, Integer> singleVersions, int threads, boolean slave) {
     this.singleVersions = singleVersions;
     this.threads = threads;
     this.slave = slave;
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index c0bd62f..c048e3c 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -19,12 +19,13 @@
 
 import com.google.common.base.Ticker;
 import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.SchemaDefinitions;
@@ -34,6 +35,7 @@
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
@@ -42,11 +44,13 @@
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.account.AccountIndexerImpl;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.index.change.AllChangesIndexer;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexDefinition;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.change.StalenessChecker;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndexDefinition;
 import com.google.gerrit.server.index.group.GroupIndexRewriter;
@@ -65,7 +69,6 @@
 import com.google.inject.Singleton;
 import com.google.inject.multibindings.OptionalBinder;
 import java.util.Collection;
-import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
@@ -77,7 +80,7 @@
  */
 @SuppressWarnings("ProvidesMethodOutsideOfModule")
 public class IndexModule extends LifecycleModule {
-  public static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
+  public static final ImmutableList<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
       ImmutableList.of(
           AccountSchemaDefinitions.INSTANCE,
           ChangeSchemaDefinitions.INSTANCE,
@@ -130,6 +133,8 @@
     bind(ChangeIndexCollection.class);
     listener().to(ChangeIndexCollection.class);
     factory(ChangeIndexer.Factory.class);
+    factory(StalenessChecker.Factory.class);
+    factory(AllChangesIndexer.Factory.class);
 
     bind(GroupIndexRewriter.class);
     // GroupIndexCollection is already bound very high up in SchemaModule.
@@ -174,9 +179,10 @@
 
     ImmutableList<IndexDefinition<?, ?, ?>> result =
         ImmutableList.of(accounts, groups, changes, projects);
-    Set<String> expected =
+    ImmutableSet<String> expected =
         FluentIterable.from(ALL_SCHEMA_DEFS).transform(SchemaDefinitions::getName).toSet();
-    Set<String> actual = FluentIterable.from(result).transform(IndexDefinition::getName).toSet();
+    ImmutableSet<String> actual =
+        FluentIterable.from(result).transform(IndexDefinition::getName).toSet();
     if (!expected.equals(actual)) {
       throw new ProvisionException(
           "need index definitions for all schemas: " + expected + " != " + actual);
@@ -255,6 +261,13 @@
     return MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "Index-Batch", true));
   }
 
+  @Provides
+  @Singleton
+  StalenessChecker getChangeStalenessChecker(
+      ChangeIndexCollection indexes, GitRepositoryManager repoManager, IndexConfig indexConfig) {
+    return new StalenessChecker(indexes, repoManager, indexConfig);
+  }
+
   @Singleton
   private static class ShutdownIndexExecutors implements LifecycleListener {
     private final ListeningExecutorService interactiveExecutor;
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 352d376..f81a9ce 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.index;
 
+import static com.google.gerrit.server.index.change.ChangeField.CHANGENUM_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.CHANGE_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
@@ -76,15 +77,23 @@
     // Ensure we request enough fields to construct a ChangeData. We need both
     // change ID and project, which can either come via the Change field or
     // separate fields.
-    Set<String> fs = opts.fields();
+    ImmutableSet<String> fs = opts.fields();
     if (fs.contains(CHANGE_SPEC.getName())) {
       // A Change is always sufficient.
       return fs;
     }
-    if (fs.contains(PROJECT_SPEC.getName()) && fs.contains(NUMERIC_ID_STR_SPEC.getName())) {
+
+    Set<String> requiredFields =
+        CHANGENUM_SPEC.getName() != null
+            ? ImmutableSet.of(
+                NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName(), CHANGENUM_SPEC.getName())
+            : ImmutableSet.of(NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName());
+
+    if (fs.containsAll(requiredFields)) {
       return fs;
     }
-    return Sets.union(fs, ImmutableSet.of(NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName()));
+
+    return Sets.union(fs, ImmutableSet.copyOf(requiredFields));
   }
 
   /**
@@ -93,7 +102,7 @@
    * is temporary and should be removed after the migration is done.
    */
   public static Set<String> groupFields(QueryOptions opts) {
-    Set<String> fs = opts.fields();
+    ImmutableSet<String> fs = opts.fields();
     return fs.contains(GroupField.UUID_FIELD_SPEC.getName())
         ? fs
         : Sets.union(fs, ImmutableSet.of(GroupField.UUID_FIELD_SPEC.getName()));
@@ -115,7 +124,7 @@
    * doesn't support.
    */
   public static Set<String> projectFields(QueryOptions opts) {
-    Set<String> fs = opts.fields();
+    ImmutableSet<String> fs = opts.fields();
     return fs.contains(ProjectField.NAME_SPEC.getName())
         ? fs
         : Sets.union(fs, ImmutableSet.of(ProjectField.NAME_SPEC.getName()));
diff --git a/java/com/google/gerrit/server/index/IndexVersionReindexer.java b/java/com/google/gerrit/server/index/IndexVersionReindexer.java
new file mode 100644
index 0000000..84be97e
--- /dev/null
+++ b/java/com/google/gerrit/server/index/IndexVersionReindexer.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.SiteIndexer;
+import com.google.inject.Inject;
+import java.util.concurrent.Future;
+
+public class IndexVersionReindexer {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private ListeningExecutorService executor;
+
+  @Inject
+  IndexVersionReindexer(@IndexExecutor(BATCH) ListeningExecutorService executor) {
+    this.executor = executor;
+  }
+
+  public <K, V, I extends Index<K, V>> Future<SiteIndexer.Result> reindex(
+      IndexDefinition<K, V, I> def, int version, boolean reuse, boolean notifyListeners) {
+    I index = def.getIndexCollection().getWriteIndex(version);
+    SiteIndexer<K, V, I> siteIndexer = def.getSiteIndexer(reuse);
+    return executor.submit(
+        () -> {
+          String name = def.getName();
+          logger.atInfo().log("Starting reindex of %s version %d", name, version);
+          SiteIndexer.Result result = siteIndexer.indexAll(index, notifyListeners);
+          if (result.success()) {
+            logger.atInfo().log("Reindex %s version %s complete", name, version);
+          } else {
+            logger.atInfo().log(
+                "Reindex %s version %s failed. Successfully indexed %s, failed to index %s",
+                name, version, result.doneCount(), result.failedCount());
+          }
+          return result;
+        });
+  }
+}
diff --git a/java/com/google/gerrit/server/index/OnlineReindexer.java b/java/com/google/gerrit/server/index/OnlineReindexer.java
index eef394d..98abf46 100644
--- a/java/com/google/gerrit/server/index/OnlineReindexer.java
+++ b/java/com/google/gerrit/server/index/OnlineReindexer.java
@@ -42,18 +42,21 @@
   private final PluginSetContext<OnlineUpgradeListener> listeners;
   private I index;
   private final AtomicBoolean running = new AtomicBoolean();
+  private final boolean reuseExistingDocuments;
 
   public OnlineReindexer(
       IndexDefinition<K, V, I> def,
       int oldVersion,
       int newVersion,
-      PluginSetContext<OnlineUpgradeListener> listeners) {
+      PluginSetContext<OnlineUpgradeListener> listeners,
+      boolean reuseExistingDocuments) {
     this.name = def.getName();
     this.indexes = def.getIndexCollection();
     this.batchIndexer = def.getSiteIndexer();
     this.oldVersion = oldVersion;
     this.newVersion = newVersion;
     this.listeners = listeners;
+    this.reuseExistingDocuments = reuseExistingDocuments;
   }
 
   /** Starts the background process. */
@@ -106,7 +109,7 @@
         "Starting online reindex of %s from schema version %s to %s",
         name, version(indexes.getSearchIndex()), version(index));
 
-    if (oldVersion != newVersion) {
+    if (!reuseExistingDocuments && oldVersion != newVersion) {
       index.deleteAll();
     }
     SiteIndexer.Result result = batchIndexer.indexAll(index);
diff --git a/java/com/google/gerrit/server/index/SingleVersionModule.java b/java/com/google/gerrit/server/index/SingleVersionModule.java
index 50dc4e9..bad5ffe 100644
--- a/java/com/google/gerrit/server/index/SingleVersionModule.java
+++ b/java/com/google/gerrit/server/index/SingleVersionModule.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.index;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.index.Index;
@@ -30,7 +31,6 @@
 import com.google.inject.util.Providers;
 import java.util.Collection;
 import java.util.Map;
-import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -41,9 +41,9 @@
 public class SingleVersionModule extends LifecycleModule {
   public static final String SINGLE_VERSIONS = "IndexModule/SingleVersions";
 
-  private final Map<String, Integer> singleVersions;
+  private final ImmutableMap<String, Integer> singleVersions;
 
-  public SingleVersionModule(Map<String, Integer> singleVersions) {
+  public SingleVersionModule(ImmutableMap<String, Integer> singleVersions) {
     this.singleVersions = singleVersions;
   }
 
@@ -58,7 +58,7 @@
   /** Listener to Gerrit's lifecycle events to specify which index versions to use. */
   @Singleton
   public static class SingleVersionListener implements LifecycleListener {
-    private final Set<String> disabled;
+    private final ImmutableSet<String> disabled;
     private final Collection<IndexDefinition<?, ?, ?>> defs;
     private final Map<String, Integer> singleVersions;
 
diff --git a/java/com/google/gerrit/server/index/VersionManager.java b/java/com/google/gerrit/server/index/VersionManager.java
index cdb69c6..2c38caf 100644
--- a/java/com/google/gerrit/server/index/VersionManager.java
+++ b/java/com/google/gerrit/server/index/VersionManager.java
@@ -59,6 +59,7 @@
   }
 
   protected final boolean onlineUpgrade;
+  protected final boolean reuseExistingDocuments;
   protected final String runReindexMsg;
   protected final SitePaths sitePaths;
 
@@ -72,7 +73,8 @@
       SitePaths sitePaths,
       PluginSetContext<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs,
-      boolean onlineUpgrade) {
+      boolean onlineUpgrade,
+      boolean reuseExistingDocuments) {
     this.sitePaths = sitePaths;
     this.listeners = listeners;
     this.defs = Maps.newHashMapWithExpectedSize(defs.size());
@@ -82,6 +84,7 @@
 
     this.reindexers = Maps.newHashMapWithExpectedSize(defs.size());
     this.onlineUpgrade = onlineUpgrade;
+    this.reuseExistingDocuments = reuseExistingDocuments;
     this.runReindexMsg =
         "No index versions for index '%s' ready; run java -jar "
             + sitePaths.gerrit_war.toAbsolutePath()
@@ -190,7 +193,7 @@
       if (!reindexers.containsKey(def.getName())) {
         int latest = write.get(0).version;
         OnlineReindexer<K, V, I> reindexer =
-            new OnlineReindexer<>(def, search.version, latest, listeners);
+            new OnlineReindexer<>(def, search.version, latest, listeners, reuseExistingDocuments);
         reindexers.put(def.getName(), reindexer);
       }
     }
@@ -206,7 +209,7 @@
           search != null, "no search index ready for %s; should have failed at startup", name);
       int searchVersion = search.getSchema().getVersion();
 
-      List<Index<?, ?>> write = ImmutableList.copyOf(indexes.getWriteIndexes());
+      ImmutableList<Index<?, ?>> write = ImmutableList.copyOf(indexes.getWriteIndexes());
       checkState(
           !write.isEmpty(),
           "no write indexes set for %s; should have been initialized at startup",
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexer.java b/java/com/google/gerrit/server/index/account/AccountIndexer.java
index a055113..03982e7 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexer.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.index.account;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 
 /** Interface for indexing a Gerrit account. */
@@ -32,5 +33,6 @@
    * @param id account id to index.
    * @return whether the account was reindexed
    */
+  @CanIgnoreReturnValue
   boolean reindexIfStale(Account.Id id);
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index fd264a1..d8e2a7b 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -88,7 +88,10 @@
   @Deprecated static final Schema<AccountState> V12 = schema(V11);
 
   // Upgrade Lucene to 8.x requires reindexing.
-  static final Schema<AccountState> V13 = schema(V12);
+  @Deprecated static final Schema<AccountState> V13 = schema(V12);
+
+  // Upgrade Lucene to 9.x requires reindexing.
+  static final Schema<AccountState> V14 = schema(V13);
 
   /**
    * Name of the account index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/server/index/account/ReindexAccountsAfterRefUpdate.java b/java/com/google/gerrit/server/index/account/ReindexAccountsAfterRefUpdate.java
new file mode 100644
index 0000000..4a5e3fa
--- /dev/null
+++ b/java/com/google/gerrit/server/index/account/ReindexAccountsAfterRefUpdate.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.account;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/**
+ * Listener for ref update events that reindexes accounts in case the updated Git reference was used
+ * to compute contents of an index document.
+ *
+ * <p>Will reindex accounts when the account's NoteDb ref changes.
+ */
+public class ReindexAccountsAfterRefUpdate implements GitBatchRefUpdateListener {
+  private final AllUsersName allUsersName;
+  private final Provider<AccountIndexer> accountIndexer;
+
+  @Inject
+  ReindexAccountsAfterRefUpdate(
+      AllUsersName allUsersName, Provider<AccountIndexer> accountIndexer) {
+    this.allUsersName = allUsersName;
+    this.accountIndexer = accountIndexer;
+  }
+
+  @Override
+  public void onGitBatchRefUpdate(Event event) {
+    if (!allUsersName.get().equals(event.getProjectName())) {
+      return;
+    }
+    for (UpdatedRef ref : event.getUpdatedRefs()) {
+      if (RefNames.isRefsUsers(ref.getRefName()) && !RefNames.isRefsEdit(ref.getRefName())) {
+        Account.Id accountId = Account.Id.fromRef(ref.getRefName());
+        if (accountId != null) {
+          accountIndexer.get().index(accountId);
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
index 699dfbe..f98f893 100644
--- a/java/com/google/gerrit/server/index/account/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -42,7 +42,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -137,7 +136,7 @@
       }
     }
 
-    Set<ExternalId> extIds = externalIds.byAccount(id);
+    ImmutableSet<ExternalId> extIds = externalIds.byAccount(id);
 
     ListMultimap<ObjectId, ObjectId> extIdStates =
         parseExternalIdStates(
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/index/account/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/index/account/package-info.java
index 0709b86..88821eb 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/index/account/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.index.account;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 3935108..19a0223 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -43,7 +43,8 @@
 import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -52,6 +53,7 @@
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -65,6 +67,13 @@
  */
 public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    AllChangesIndexer create();
+
+    AllChangesIndexer create(boolean reuseExistingDocuments);
+  }
+
   private MultiProgressMonitor mpm;
   private VolatileTask doneTask;
   private Task failedTask;
@@ -84,25 +93,54 @@
   private final GitRepositoryManager repoManager;
   private final ListeningExecutorService executor;
   private final ChangeIndexer.Factory indexerFactory;
+  private final StalenessChecker.Factory stalenessCheckerFactory;
   private final ChangeNotes.Factory notesFactory;
   private final ProjectCache projectCache;
   private final Set<Project.NameKey> projectsToSkip;
+  private final boolean reuseExistingDocuments;
 
-  @Inject
+  @AssistedInject
   AllChangesIndexer(
       MultiProgressMonitor.Factory multiProgressMonitorFactory,
       ChangeData.Factory changeDataFactory,
       GitRepositoryManager repoManager,
       @IndexExecutor(BATCH) ListeningExecutorService executor,
       ChangeIndexer.Factory indexerFactory,
+      StalenessChecker.Factory stalenessCheckerFactory,
       ChangeNotes.Factory notesFactory,
       ProjectCache projectCache,
       @GerritServerConfig Config config) {
+    this(
+        multiProgressMonitorFactory,
+        changeDataFactory,
+        repoManager,
+        executor,
+        indexerFactory,
+        stalenessCheckerFactory,
+        notesFactory,
+        projectCache,
+        config,
+        config.getBoolean("index", null, "reuseExistingDocuments", false));
+  }
+
+  @AssistedInject
+  AllChangesIndexer(
+      MultiProgressMonitor.Factory multiProgressMonitorFactory,
+      ChangeData.Factory changeDataFactory,
+      GitRepositoryManager repoManager,
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      ChangeIndexer.Factory indexerFactory,
+      StalenessChecker.Factory stalenessCheckerFactory,
+      ChangeNotes.Factory notesFactory,
+      ProjectCache projectCache,
+      @GerritServerConfig Config config,
+      @Assisted boolean reuseExistingDocuments) {
     this.multiProgressMonitorFactory = multiProgressMonitorFactory;
     this.changeDataFactory = changeDataFactory;
     this.repoManager = repoManager;
     this.executor = executor;
     this.indexerFactory = indexerFactory;
+    this.stalenessCheckerFactory = stalenessCheckerFactory;
     this.notesFactory = notesFactory;
     this.projectCache = projectCache;
     this.projectsToSkip =
@@ -110,6 +148,7 @@
             .stream()
             .map(p -> Project.NameKey.parse(p))
             .collect(Collectors.toSet());
+    this.reuseExistingDocuments = reuseExistingDocuments;
   }
 
   @AutoValue
@@ -138,6 +177,11 @@
 
   @Override
   public Result indexAll(ChangeIndex index) {
+    return indexAll(index, true);
+  }
+
+  @Override
+  public Result indexAll(ChangeIndex index, boolean notifyListeners) {
     // The simplest approach to distribute indexing would be to let each thread grab a project
     // and index it fully. But if a site has one big project and 100s of small projects, then
     // in the beginning all CPUs would be busy reindexing projects. But soon enough all small
@@ -160,7 +204,7 @@
     failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
     List<ListenableFuture<?>> futures;
     try {
-      futures = new SliceScheduler(index, ok).schedule();
+      futures = new SliceScheduler(index, ok, notifyListeners).schedule();
     } catch (ProjectsCollectionFailure e) {
       logger.atSevere().log("%s", e.getMessage());
       return Result.create(sw, false, 0, 0);
@@ -218,20 +262,27 @@
   }
 
   private class ProjectSliceIndexer implements Callable<Void> {
-    private final ChangeIndexer indexer;
     private final ProjectSlice projectSlice;
     private final ProgressMonitor done;
     private final ProgressMonitor failed;
+    private final Consumer<ChangeData> indexAction;
 
     private ProjectSliceIndexer(
         ChangeIndexer indexer,
         ProjectSlice projectSlice,
         ProgressMonitor done,
         ProgressMonitor failed) {
-      this.indexer = indexer;
       this.projectSlice = projectSlice;
       this.done = done;
       this.failed = failed;
+      if (reuseExistingDocuments) {
+        indexAction =
+            cd -> {
+              var unused = indexer.reindexIfStale(cd);
+            };
+      } else {
+        indexAction = cd -> indexer.index(cd);
+      }
     }
 
     @Override
@@ -271,7 +322,7 @@
         return;
       }
       try {
-        indexer.index(changeDataFactory.create(r.notes()));
+        indexAction.accept(changeDataFactory.create(r.notes()));
         done.update(1);
         verboseWriter.format(
             "Reindexed change %d (project: %s)\n", r.id().get(), r.notes().getProjectName().get());
@@ -313,6 +364,7 @@
   private class SliceScheduler {
     final ChangeIndex index;
     final AtomicBoolean ok;
+    final boolean notifyListeners;
     final AtomicInteger changeCount = new AtomicInteger(0);
     final AtomicInteger projectsFailed = new AtomicInteger(0);
     final List<ListenableFuture<?>> sliceIndexerFutures = new ArrayList<>();
@@ -320,9 +372,10 @@
     VolatileTask projTask = mpm.beginVolatileSubTask("project-slices");
     Task slicingProjects;
 
-    public SliceScheduler(ChangeIndex index, AtomicBoolean ok) {
+    public SliceScheduler(ChangeIndex index, AtomicBoolean ok, boolean notifyListeners) {
       this.index = index;
       this.ok = ok;
+      this.notifyListeners = notifyListeners;
     }
 
     private List<ListenableFuture<?>> schedule() throws ProjectsCollectionFailure {
@@ -330,7 +383,7 @@
       int projectCount = projects.size();
       slicingProjects = mpm.beginSubTask("Slicing projects", projectCount);
       for (Project.NameKey name : projects) {
-        sliceCreationFutures.add(executor.submit(new ProjectSliceCreator(name)));
+        sliceCreationFutures.add(executor.submit(new ProjectSliceCreator(name, notifyListeners)));
       }
 
       try {
@@ -361,9 +414,11 @@
 
     private class ProjectSliceCreator implements Callable<Void> {
       final Project.NameKey name;
+      final boolean notifyListeners;
 
-      public ProjectSliceCreator(Project.NameKey name) {
+      public ProjectSliceCreator(Project.NameKey name, boolean notifyListeners) {
         this.name = name;
+        this.notifyListeners = notifyListeners;
       }
 
       @Override
@@ -385,13 +440,16 @@
 
             for (int slice = 0; slice < slices; slice++) {
               ProjectSlice projectSlice = ProjectSlice.create(name, slice, slices, metaIdByChange);
+              ChangeIndexer indexer;
+              if (reuseExistingDocuments) {
+                indexer =
+                    indexerFactory.create(
+                        executor, index, stalenessCheckerFactory.create(index), notifyListeners);
+              } else {
+                indexer = indexerFactory.create(executor, index, notifyListeners);
+              }
               ListenableFuture<?> future =
-                  executor.submit(
-                      reindexProjectSlice(
-                          indexerFactory.create(executor, index),
-                          projectSlice,
-                          doneTask,
-                          failedTask));
+                  executor.submit(reindexProjectSlice(indexer, projectSlice, doneTask, failedTask));
               String description = "project " + name + " (" + slice + "/" + slices + ")";
               addErrorListener(future, description, projTask, ok);
               sliceIndexerFutures.add(future);
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 2b91d9a..045482a 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_CHANGE_NUMBER;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
@@ -133,6 +134,15 @@
   public static final IndexedField<ChangeData, String>.SearchSpec NUMERIC_ID_STR_SPEC =
       NUMERIC_ID_STR_FIELD.exact("legacy_id_str");
 
+  public static final IndexedField<ChangeData, Integer> CHANGENUM_FIELD =
+      IndexedField.<ChangeData>integerBuilder("ChangeNumber")
+          .stored()
+          .required()
+          .build(cd -> cd.getId().get());
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec CHANGENUM_SPEC =
+      CHANGENUM_FIELD.integer(FIELD_CHANGE_NUMBER);
+
   /** Newer style Change-Id key. */
   public static final IndexedField<ChangeData, String> CHANGE_ID_FIELD =
       IndexedField.<ChangeData>stringBuilder("ChangeId")
@@ -910,7 +920,7 @@
     return SchemaUtil.getPersonParts(cd.getAuthor());
   }
 
-  public static Set<String> getAuthorNameAndEmail(ChangeData cd) {
+  public static ImmutableSet<String> getAuthorNameAndEmail(ChangeData cd) {
     return getNameAndEmail(cd.getAuthor());
   }
 
@@ -918,11 +928,11 @@
     return SchemaUtil.getPersonParts(cd.getCommitter());
   }
 
-  public static Set<String> getCommitterNameAndEmail(ChangeData cd) {
+  public static ImmutableSet<String> getCommitterNameAndEmail(ChangeData cd) {
     return getNameAndEmail(cd.getCommitter());
   }
 
-  private static Set<String> getNameAndEmail(PersonIdent person) {
+  private static ImmutableSet<String> getNameAndEmail(PersonIdent person) {
     if (person == null) {
       return ImmutableSet.of();
     }
@@ -1740,14 +1750,14 @@
     return converter.toProto(object);
   }
 
-  private static <V extends MessageLite, T> List<V> entitiesToProtos(
+  private static <V extends MessageLite, T> ImmutableList<V> entitiesToProtos(
       ProtoConverter<V, T> converter, Collection<T> objects) {
     return objects.stream()
         .map(object -> entityToProto(converter, object))
         .collect(toImmutableList());
   }
 
-  private static <V extends MessageLite, T> List<T> decodeProtosToEntities(
+  private static <V extends MessageLite, T> ImmutableList<T> decodeProtosToEntities(
       Iterable<V> raw, ProtoConverter<V, T> converter) {
     return StreamSupport.stream(raw.spliterator(), false)
         .map(proto -> decodeProtoToEntity(proto, converter))
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java b/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
index dde9d86..342796b 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
@@ -14,20 +14,34 @@
 
 package com.google.gerrit.server.index.change;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 
 /** Bundle of service classes that make up the change index. */
 public class ChangeIndexDefinition extends IndexDefinition<Change.Id, ChangeData, ChangeIndex> {
 
+  private final AllChangesIndexer.Factory allChangesIndexerFactory;
+
   @Inject
   ChangeIndexDefinition(
       ChangeIndexCollection indexCollection,
       ChangeIndex.Factory indexFactory,
-      @Nullable AllChangesIndexer allChangesIndexer) {
-    super(ChangeSchemaDefinitions.INSTANCE, indexCollection, indexFactory, allChangesIndexer);
+      AllChangesIndexer.Factory allChangesIndexerFactory) {
+    super(ChangeSchemaDefinitions.INSTANCE, indexCollection, indexFactory, null);
+    this.allChangesIndexerFactory = allChangesIndexerFactory;
+  }
+
+  @Override
+  public SiteIndexer<Change.Id, ChangeData, ChangeIndex> getSiteIndexer() {
+    return allChangesIndexerFactory.create();
+  }
+
+  @Override
+  public SiteIndexer<Change.Id, ChangeData, ChangeIndex> getSiteIndexer(
+      boolean reuseExistingDocuments) {
+    return allChangesIndexerFactory.create(reuseExistingDocuments);
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index bb4b24c..2331255 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.closed;
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
@@ -58,10 +59,10 @@
 @Singleton
 public class ChangeIndexRewriter implements IndexRewriter<ChangeData> {
   /** Set of all open change statuses. */
-  public static final Set<Change.Status> OPEN_STATUSES;
+  public static final ImmutableSet<Change.Status> OPEN_STATUSES;
 
   /** Set of all closed change statuses. */
-  public static final Set<Change.Status> CLOSED_STATUSES;
+  public static final ImmutableSet<Change.Status> CLOSED_STATUSES;
 
   static {
     EnumSet<Change.Status> open = EnumSet.noneOf(Change.Status.class);
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index faa5629..fc666ad 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -21,6 +21,7 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -69,7 +70,19 @@
   public interface Factory {
     ChangeIndexer create(ListeningExecutorService executor, ChangeIndex index);
 
+    ChangeIndexer create(
+        ListeningExecutorService executor, ChangeIndex index, boolean notifyListeners);
+
+    ChangeIndexer create(
+        ListeningExecutorService executor,
+        ChangeIndex index,
+        StalenessChecker stalenessChecker,
+        boolean notifyListeners);
+
     ChangeIndexer create(ListeningExecutorService executor, ChangeIndexCollection indexes);
+
+    ChangeIndexer create(
+        ListeningExecutorService executor, ChangeIndexCollection indexes, boolean notifyListeners);
   }
 
   @Nullable private final ChangeIndexCollection indexes;
@@ -83,6 +96,7 @@
   private final StalenessChecker stalenessChecker;
   private final boolean autoReindexIfStale;
   private final IsFirstInsertForEntry isFirstInsertForEntry;
+  private final boolean notifyListeners;
 
   private final Map<Change.Id, IndexTask> queuedIndexTasks = new ConcurrentHashMap<>();
   private final Set<ReindexIfStaleTask> queuedReindexIfStaleTasks =
@@ -100,6 +114,33 @@
       @Assisted ListeningExecutorService executor,
       @Assisted ChangeIndex index,
       IsFirstInsertForEntry isFirstInsertForEntry) {
+    this(
+        cfg,
+        changeDataFactory,
+        notesFactory,
+        context,
+        indexedListeners,
+        stalenessChecker,
+        batchExecutor,
+        executor,
+        index,
+        true,
+        isFirstInsertForEntry);
+  }
+
+  @AssistedInject
+  ChangeIndexer(
+      @GerritServerConfig Config cfg,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory,
+      ThreadLocalRequestContext context,
+      PluginSetContext<ChangeIndexedListener> indexedListeners,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      @Assisted ListeningExecutorService executor,
+      @Assisted ChangeIndex index,
+      @Assisted boolean notifyListeners,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
@@ -111,6 +152,34 @@
     this.index = index;
     this.indexes = null;
     this.isFirstInsertForEntry = isFirstInsertForEntry;
+    this.notifyListeners = notifyListeners;
+  }
+
+  @AssistedInject
+  ChangeIndexer(
+      @GerritServerConfig Config cfg,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory,
+      ThreadLocalRequestContext context,
+      PluginSetContext<ChangeIndexedListener> indexedListeners,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      IsFirstInsertForEntry isFirstInsertForEntry,
+      @Assisted ListeningExecutorService executor,
+      @Assisted ChangeIndex index,
+      @Assisted StalenessChecker stalenessChecker,
+      @Assisted boolean notifyListeners) {
+    this.executor = executor;
+    this.changeDataFactory = changeDataFactory;
+    this.notesFactory = notesFactory;
+    this.context = context;
+    this.indexedListeners = indexedListeners;
+    this.batchExecutor = batchExecutor;
+    this.autoReindexIfStale = autoReindexIfStale(cfg);
+    this.isFirstInsertForEntry = isFirstInsertForEntry;
+    this.index = index;
+    this.indexes = null;
+    this.stalenessChecker = stalenessChecker;
+    this.notifyListeners = notifyListeners;
   }
 
   @AssistedInject
@@ -125,6 +194,33 @@
       @Assisted ListeningExecutorService executor,
       @Assisted ChangeIndexCollection indexes,
       IsFirstInsertForEntry isFirstInsertForEntry) {
+    this(
+        cfg,
+        changeDataFactory,
+        notesFactory,
+        context,
+        indexedListeners,
+        stalenessChecker,
+        batchExecutor,
+        executor,
+        indexes,
+        true,
+        isFirstInsertForEntry);
+  }
+
+  @AssistedInject
+  ChangeIndexer(
+      @GerritServerConfig Config cfg,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory,
+      ThreadLocalRequestContext context,
+      PluginSetContext<ChangeIndexedListener> indexedListeners,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      @Assisted ListeningExecutorService executor,
+      @Assisted ChangeIndexCollection indexes,
+      @Assisted boolean notifyListeners,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
@@ -135,6 +231,7 @@
     this.autoReindexIfStale = autoReindexIfStale(cfg);
     this.index = null;
     this.indexes = indexes;
+    this.notifyListeners = notifyListeners;
     this.isFirstInsertForEntry = isFirstInsertForEntry;
   }
 
@@ -261,19 +358,27 @@
   }
 
   private void fireChangeScheduledForIndexingEvent(String projectName, int id) {
-    indexedListeners.runEach(l -> l.onChangeScheduledForIndexing(projectName, id));
+    if (notifyListeners) {
+      indexedListeners.runEach(l -> l.onChangeScheduledForIndexing(projectName, id));
+    }
   }
 
   private void fireChangeIndexedEvent(String projectName, int id) {
-    indexedListeners.runEach(l -> l.onChangeIndexed(projectName, id));
+    if (notifyListeners) {
+      indexedListeners.runEach(l -> l.onChangeIndexed(projectName, id));
+    }
   }
 
   private void fireChangeScheduledForDeletionFromIndexEvent(int id) {
-    indexedListeners.runEach(l -> l.onChangeScheduledForDeletionFromIndex(id));
+    if (notifyListeners) {
+      indexedListeners.runEach(l -> l.onChangeScheduledForDeletionFromIndex(id));
+    }
   }
 
   private void fireChangeDeletedFromIndexEvent(int id) {
-    indexedListeners.runEach(l -> l.onChangeDeleted(id));
+    if (notifyListeners) {
+      indexedListeners.runEach(l -> l.onChangeDeleted(id));
+    }
   }
 
   /**
@@ -349,7 +454,7 @@
    * @param id ID of the change to index.
    * @return future for reindexing the change; returns true if the change was stale.
    */
-  public ListenableFuture<Boolean> reindexIfStale(Project.NameKey project, Change.Id id) {
+  public ListenableFuture<Boolean> asyncReindexIfStale(Project.NameKey project, Change.Id id) {
     ReindexIfStaleTask task = new ReindexIfStaleTask(project, id);
     if (queuedReindexIfStaleTasks.add(task)) {
       return submit(task, batchExecutor);
@@ -357,6 +462,41 @@
     return Futures.immediateFuture(false);
   }
 
+  /**
+   * Synchronously check if a change is stale, and reindex if it is.
+   *
+   * @param cd the change data to be checked for staleness.
+   * @return true if the change was stale, false if it was up-to-date
+   */
+  public boolean reindexIfStale(ChangeData cd) {
+    return reindexIfStale(cd.project(), cd.getId());
+  }
+
+  /**
+   * Synchronously check if a change is stale, and reindex if it is.
+   *
+   * @param project the project to which the change belongs.
+   * @param id ID of the change to index.
+   * @return true if the change was stale, false if it was up-to-date
+   */
+  public boolean reindexIfStale(Project.NameKey project, Change.Id id) {
+    try {
+      StalenessCheckResult stalenessCheckResult = stalenessChecker.check(id);
+      if (stalenessCheckResult.isStale()) {
+        logger.atInfo().log("Reindexing stale document %s", stalenessCheckResult);
+        indexImpl(changeDataFactory.create(project, id));
+        return true;
+      }
+    } catch (Exception e) {
+      if (!isCausedByRepositoryNotFoundException(e)) {
+        throw e;
+      }
+      logger.atFine().log(
+          "Change %s belongs to deleted project %s, aborting reindexing the change.", id, project);
+    }
+    return false;
+  }
+
   private void autoReindexIfStale(ChangeData cd) {
     autoReindexIfStale(cd.project(), cd.getId());
   }
@@ -365,7 +505,7 @@
     if (autoReindexIfStale) {
       // Don't retry indefinitely; if this fails the change will be stale.
       @SuppressWarnings("unused")
-      Future<?> possiblyIgnoredError = reindexIfStale(project, id);
+      Future<?> possiblyIgnoredError = asyncReindexIfStale(project, id);
     }
   }
 
@@ -410,7 +550,8 @@
         try {
           return callImpl();
         } finally {
-          context.setContext(oldCtx);
+          @SuppressWarnings("unused")
+          var unused = context.setContext(oldCtx);
         }
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Failed to execute %s", this);
@@ -503,6 +644,7 @@
 
     @Nullable
     @Override
+    @CanIgnoreReturnValue
     public ChangeData call() {
       logger.atFine().log("Delete change %d from index.", id.get());
       // Don't bother setting a RequestContext to provide the DB.
@@ -543,22 +685,7 @@
     @Override
     public Boolean callImpl() throws Exception {
       remove();
-      try {
-        StalenessCheckResult stalenessCheckResult = stalenessChecker.check(id);
-        if (stalenessCheckResult.isStale()) {
-          logger.atInfo().log("Reindexing stale document %s", stalenessCheckResult);
-          indexImpl(changeDataFactory.create(project, id));
-          return true;
-        }
-      } catch (Exception e) {
-        if (!isCausedByRepositoryNotFoundException(e)) {
-          throw e;
-        }
-        logger.atFine().log(
-            "Change %s belongs to deleted project %s, aborting reindexing the change.",
-            id.get(), project.get());
-      }
-      return false;
+      return reindexIfStale(project, id);
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 5474e6b..4921b3f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -250,6 +250,7 @@
   /** Upgrade Lucene to 8.x requires reindexing. */
   @Deprecated static final Schema<ChangeData> V83 = schema(V82);
 
+  @Deprecated
   static final Schema<ChangeData> V84 =
       new Schema.Builder<ChangeData>()
           .add(V83)
@@ -257,6 +258,17 @@
           .addSearchSpecs(ChangeField.CUSTOM_KEYED_VALUES_SPEC)
           .build();
 
+  /** Upgrade Lucene to 9.x requires reindexing. */
+  @Deprecated static final Schema<ChangeData> V85 = schema(V84);
+
+  /** Add ChangeNumber field */
+  static final Schema<ChangeData> V86 =
+      new Schema.Builder<ChangeData>()
+          .add(V85)
+          .addIndexedFields(ChangeField.CHANGENUM_FIELD)
+          .addSearchSpecs(ChangeField.CHANGENUM_SPEC)
+          .build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexChangesAfterRefUpdate.java
similarity index 82%
rename from java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
rename to java/com/google/gerrit/server/index/change/ReindexChangesAfterRefUpdate.java
index f6a4ce1..aad6916 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexChangesAfterRefUpdate.java
@@ -21,7 +21,6 @@
 import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -32,7 +31,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.QueueProvider.QueueType;
 import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
@@ -45,42 +43,37 @@
 import org.eclipse.jgit.lib.Config;
 
 /**
- * Listener for ref update events that reindexes entities in case the updated Git reference was used
+ * Listener for ref update events that reindexes changes in case the updated Git reference was used
  * to compute contents of an index document.
  *
  * <p>Reindexes any open changes that has a destination branch that was updated to ensure that
  * 'mergeable' is still current.
- *
- * <p>Will reindex accounts when the account's NoteDb ref changes.
  */
-public class ReindexAfterRefUpdate implements GitBatchRefUpdateListener {
+public class ReindexChangesAfterRefUpdate implements GitBatchRefUpdateListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final OneOffRequestContext requestContext;
   private final Provider<InternalChangeQuery> queryProvider;
-  private final ChangeIndexer.Factory indexerFactory;
-  private final ChangeIndexCollection indexes;
+  private final ChangeIndexer.Factory changeIndexerFactory;
+  private final ChangeIndexCollection changeIndexes;
   private final AllUsersName allUsersName;
-  private final Provider<AccountIndexer> indexer;
   private final ListeningExecutorService executor;
   private final boolean enabled;
 
   @Inject
-  ReindexAfterRefUpdate(
+  ReindexChangesAfterRefUpdate(
       @GerritServerConfig Config cfg,
       OneOffRequestContext requestContext,
       Provider<InternalChangeQuery> queryProvider,
-      ChangeIndexer.Factory indexerFactory,
-      ChangeIndexCollection indexes,
+      ChangeIndexer.Factory changeIndexerFactory,
+      ChangeIndexCollection changeIndexes,
       AllUsersName allUsersName,
-      Provider<AccountIndexer> indexer,
       @IndexExecutor(QueueType.BATCH) ListeningExecutorService executor) {
     this.requestContext = requestContext;
     this.queryProvider = queryProvider;
-    this.indexerFactory = indexerFactory;
-    this.indexes = indexes;
+    this.changeIndexerFactory = changeIndexerFactory;
+    this.changeIndexes = changeIndexes;
     this.allUsersName = allUsersName;
-    this.indexer = indexer;
     this.executor = executor;
     this.enabled = MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex();
   }
@@ -88,14 +81,6 @@
   @Override
   public void onGitBatchRefUpdate(GitBatchRefUpdateListener.Event event) {
     if (allUsersName.get().equals(event.getProjectName())) {
-      for (UpdatedRef ref : event.getUpdatedRefs()) {
-        if (RefNames.isRefsUsers(ref.getRefName()) && !RefNames.isRefsEdit(ref.getRefName())) {
-          Account.Id accountId = Account.Id.fromRef(ref.getRefName());
-          if (accountId != null) {
-            indexer.get().index(accountId);
-          }
-        }
-      }
       if (event.getUpdatedRefs().stream()
           .noneMatch(ru -> ru.getRefName().equals(RefNames.REFS_CONFIG))) {
         // The update is in All-Users and not on refs/meta/config. So it's not a change. Return
@@ -113,13 +98,15 @@
       }
       Futures.addCallback(
           executor.submit(new GetChanges(event.getProjectName(), ref)),
-          new FutureCallback<List<Change>>() {
+          new FutureCallback<>() {
             @Override
             public void onSuccess(List<Change> changes) {
               for (Change c : changes) {
                 @SuppressWarnings("unused")
                 Future<?> possiblyIgnoredError =
-                    indexerFactory.create(executor, indexes).indexAsync(c.getProject(), c.getId());
+                    changeIndexerFactory
+                        .create(executor, changeIndexes)
+                        .indexAsync(c.getProject(), c.getId());
               }
             }
 
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index eb4af01..83f6189 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -36,8 +37,8 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
@@ -50,34 +51,58 @@
  * Checker that compares values stored in the change index to metadata in NoteDb to detect index
  * documents that should have been updated (= stale).
  */
-@Singleton
 public class StalenessChecker {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  public interface Factory {
+    StalenessChecker create(ChangeIndex index);
+  }
+
   public static final ImmutableSet<String> FIELDS =
       ImmutableSet.of(
           ChangeField.CHANGE_SPEC.getName(),
           ChangeField.REF_STATE_SPEC.getName(),
           ChangeField.REF_STATE_PATTERN_SPEC.getName());
 
-  private final ChangeIndexCollection indexes;
+  @Nullable private final ChangeIndexCollection indexes;
+  @Nullable private final ChangeIndex index;
   private final GitRepositoryManager repoManager;
   private final IndexConfig indexConfig;
 
-  @Inject
-  StalenessChecker(
+  public StalenessChecker(
       ChangeIndexCollection indexes, GitRepositoryManager repoManager, IndexConfig indexConfig) {
     this.indexes = indexes;
+    this.index = null;
     this.repoManager = repoManager;
     this.indexConfig = indexConfig;
   }
 
+  @AssistedInject
+  StalenessChecker(
+      GitRepositoryManager repoManager, IndexConfig indexConfig, @Assisted ChangeIndex index) {
+    this.indexes = null;
+    this.repoManager = repoManager;
+    this.indexConfig = indexConfig;
+    this.index = index;
+  }
+
   /**
    * Returns a {@link StalenessCheckResult} with structured information about staleness of the
    * provided {@link com.google.gerrit.entities.Change.Id}.
    */
   public StalenessCheckResult check(Change.Id id) {
-    ChangeIndex i = indexes.getSearchIndex();
+    if (index != null) {
+      return check(id, index);
+    }
+    return check(id, indexes.getSearchIndex());
+  }
+
+  /**
+   * Returns a {@link StalenessCheckResult} with structured information about staleness of the
+   * provided {@link com.google.gerrit.entities.Change.Id} in the provided {@link
+   * com.google.gerrit.server.index.change.ChangeIndex}.
+   */
+  private StalenessCheckResult check(Change.Id id, ChangeIndex i) {
     if (i == null) {
       return StalenessCheckResult
           .notStale(); // No index; caller couldn't do anything if it is stale.
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/index/change/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/index/change/package-info.java
index 0709b86..f9aeaea 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/index/change/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.index.change;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index 3773d435..52668c5 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
 import com.google.common.base.Stopwatch;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -74,7 +75,7 @@
     ProgressMonitor progress = new TextProgressMonitor(newPrintWriter(progressOut));
     progress.start(2);
     Stopwatch sw = Stopwatch.createStarted();
-    List<AccountGroup.UUID> uuids;
+    ImmutableList<AccountGroup.UUID> uuids;
     try {
       uuids = collectGroups(progress);
     } catch (IOException | ConfigInvalidException e) {
@@ -138,7 +139,7 @@
     return SiteIndexer.Result.create(sw, ok.get(), done.get(), failed.get());
   }
 
-  private List<AccountGroup.UUID> collectGroups(ProgressMonitor progress)
+  private ImmutableList<AccountGroup.UUID> collectGroups(ProgressMonitor progress)
       throws IOException, ConfigInvalidException {
     progress.beginTask("Collecting groups", ProgressMonitor.UNKNOWN);
     try {
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexer.java b/java/com/google/gerrit/server/index/group/GroupIndexer.java
index d6b9186..f5e72a7 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexer.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexer.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.index.group;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.AccountGroup;
 
 /** Interface for indexing an internal Gerrit group. */
@@ -32,5 +33,6 @@
    * @param uuid group UUID to index.
    * @return whether the group was reindexed
    */
+  @CanIgnoreReturnValue
   boolean reindexIfStale(AccountGroup.UUID uuid);
 }
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index 1b87d27..33006b8 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -72,7 +72,10 @@
   @Deprecated static final Schema<InternalGroup> V9 = schema(V8);
 
   // Upgrade Lucene to 8.x requires reindexing.
-  static final Schema<InternalGroup> V10 = schema(V9);
+  @Deprecated static final Schema<InternalGroup> V10 = schema(V9);
+
+  // Upgrade Lucene to 9.x requires reindexing.
+  static final Schema<InternalGroup> V11 = schema(V10);
 
   /** Singleton instance of the schema definitions. This is one per JVM. */
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/index/group/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/index/group/package-info.java
index 0709b86..f6b0ca3 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/index/group/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.index.group;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/index/options/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/index/options/package-info.java
index 0709b86..b0c2e95 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/index/options/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.index.options;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/index/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/index/package-info.java
index 0709b86..a5ff950 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/index/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.index;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/index/project/StalenessChecker.java b/java/com/google/gerrit/server/index/project/StalenessChecker.java
index b2e24e4..5f0d901 100644
--- a/java/com/google/gerrit/server/index/project/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/project/StalenessChecker.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.gerrit.entities.Project;
@@ -73,7 +74,7 @@
       return StalenessCheckResult.stale("Document %s missing from index", project);
     }
 
-    SetMultimap<Project.NameKey, RefState> indexedRefStates =
+    ImmutableSetMultimap<Project.NameKey, RefState> indexedRefStates =
         RefState.parseStates(result.get().getValue(ProjectField.REF_STATE_SPEC));
 
     SetMultimap<Project.NameKey, RefState> currentRefStates =
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/index/project/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/index/project/package-info.java
index 0709b86..7b62055 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/index/project/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.index.project;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/ioutil/BUILD b/java/com/google/gerrit/server/ioutil/BUILD
index 6b9ecdf..a783308 100644
--- a/java/com/google/gerrit/server/ioutil/BUILD
+++ b/java/com/google/gerrit/server/ioutil/BUILD
@@ -11,5 +11,6 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:jgit-archive",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/ioutil/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/ioutil/package-info.java
index 0709b86..6edaa8b3 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/ioutil/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.ioutil;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
index 7204c07..53f75b3 100644
--- a/java/com/google/gerrit/server/logging/BUILD
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -16,6 +16,7 @@
         "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
     ],
diff --git a/java/com/google/gerrit/server/logging/CallerFinder.java b/java/com/google/gerrit/server/logging/CallerFinder.java
deleted file mode 100644
index 4cb4b7f..0000000
--- a/java/com/google/gerrit/server/logging/CallerFinder.java
+++ /dev/null
@@ -1,273 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.logging;
-
-import static com.google.common.flogger.LazyArgs.lazy;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.LazyArg;
-import java.util.Optional;
-
-/**
- * Utility to compute the caller of a method.
- *
- * <p>In the logs we see for each entry from where it was triggered (class/method/line) but in case
- * the logging is done in a utility method or inside of a module this doesn't tell us from where the
- * action was actually triggered. To get this information we could included the stacktrace into the
- * logs (by calling {@link
- * com.google.common.flogger.LoggingApi#withStackTrace(com.google.common.flogger.StackSize)} but
- * sometimes there are too many uninteresting stacks so that this would blow up the logs too much.
- * In this case CallerFinder can be used to find the first interesting caller from the current
- * stacktrace by specifying the class that interesting callers invoke as target.
- *
- * <p>Example:
- *
- * <p>Index queries are executed by the {@code query(List<String>, List<Predicate<T>>)} method in
- * {@link com.google.gerrit.index.query.QueryProcessor}. At this place the index query is logged but
- * from the log we want to see which code triggered this index query.
- *
- * <p>E.g. the stacktrace could look like this:
- *
- * <pre>{@code
- * GroupQueryProcessor(QueryProcessor<T>).query(List<String>, List<Predicate<T>>) line: 216
- * GroupQueryProcessor(QueryProcessor<T>).query(List<Predicate<T>>) line: 188
- * GroupQueryProcessor(QueryProcessor<T>).query(Predicate<T>) line: 171
- * InternalGroupQuery(InternalQuery<T>).query(Predicate<T>) line: 81
- * InternalGroupQuery.getOnlyGroup(Predicate<InternalGroup>, String) line: 67
- * InternalGroupQuery.byName(NameKey) line: 50
- * GroupCacheImpl$ByNameLoader.load(String) line: 166
- * GroupCacheImpl$ByNameLoader.load(Object) line: 1
- * LocalCache$LoadingValueReference<K,V>.loadFuture(K, CacheLoader<? super K,V>) line: 3527
- * ...
- * }</pre>
- *
- * <p>The first interesting caller is {@code GroupCacheImpl$ByNameLoader.load(String) line: 166}. To
- * find this caller from the stacktrace we could specify {@link
- * com.google.gerrit.server.query.group.InternalGroupQuery} as a target since we know that all
- * internal group queries go through this class:
- *
- * <pre>
- * CallerFinder.builder()
- *   .addTarget(InternalGroupQuery.class)
- *   .build();
- * </pre>
- *
- * <p>Since in some places {@link com.google.gerrit.server.query.group.GroupQueryProcessor} may also
- * be used directly we can add it as a secondary target to catch these callers as well:
- *
- * <pre>
- * CallerFinder.builder()
- *   .addTarget(InternalGroupQuery.class)
- *   .addTarget(GroupQueryProcessor.class)
- *   .build();
- * </pre>
- *
- * <p>However since {@link com.google.gerrit.index.query.QueryProcessor} is also responsible to
- * execute other index queries (for changes, accounts, projects) we would need to add the classes
- * for them as targets too. Since there are common base classes we can simply specify the base
- * classes and request matching of subclasses:
- *
- * <pre>
- * CallerFinder.builder()
- *   .addTarget(InternalQuery.class)
- *   .addTarget(QueryProcessor.class)
- *   .matchSubClasses(true)
- *   .build();
- * </pre>
- *
- * <p>Another special case is if the entry point is always an inner class of a known interface. E.g.
- * {@link com.google.gerrit.server.permissions.PermissionBackend} is the entry point for all
- * permission checks but they are done through inner classes, e.g. {@link
- * com.google.gerrit.server.permissions.PermissionBackend.ForProject}. In this case matching of
- * inner classes must be enabled as well:
- *
- * <pre>
- * CallerFinder.builder()
- *   .addTarget(PermissionBackend.class)
- *   .matchSubClasses(true)
- *   .matchInnerClasses(true)
- *   .build();
- * </pre>
- *
- * <p>Finding the interesting caller requires specifying the entry point class as target. This may
- * easily break when code is refactored and hence should be used only with care. It's recommended to
- * use this only when the corresponding code is relatively stable and logging the caller information
- * brings some significant benefit.
- *
- * <p>Based on {@link com.google.common.flogger.util.CallerFinder}.
- */
-@AutoValue
-public abstract class CallerFinder {
-  public static Builder builder() {
-    return new AutoValue_CallerFinder.Builder()
-        .matchSubClasses(false)
-        .matchInnerClasses(false)
-        .skip(0);
-  }
-
-  /**
-   * The target classes for which the caller should be found, in the order in which they should be
-   * checked.
-   *
-   * @return the target classes for which the caller should be found
-   */
-  public abstract ImmutableList<Class<?>> targets();
-
-  /**
-   * Whether inner classes should be matched.
-   *
-   * @return whether inner classes should be matched
-   */
-  public abstract boolean matchSubClasses();
-
-  /**
-   * Whether sub classes of the target classes should be matched.
-   *
-   * @return whether sub classes of the target classes should be matched
-   */
-  public abstract boolean matchInnerClasses();
-
-  /**
-   * The minimum number of calls known to have occurred between the first call to the target class
-   * and the call of {@link #findCallerLazy()}. If in doubt, specify zero here to avoid accidentally
-   * skipping past the caller.
-   *
-   * @return the number of stack elements to skip when computing the caller
-   */
-  public abstract int skip();
-
-  /**
-   * Packages that should be ignored and not be considered as caller once a target has been found.
-   *
-   * @return the ignored packages
-   */
-  public abstract ImmutableList<String> ignoredPackages();
-
-  /**
-   * Classes that should be ignored and not be considered as caller once a target has been found.
-   *
-   * @return the qualified names of the ignored classes
-   */
-  public abstract ImmutableList<String> ignoredClasses();
-
-  @AutoValue.Builder
-  public abstract static class Builder {
-    abstract ImmutableList.Builder<Class<?>> targetsBuilder();
-
-    public Builder addTarget(Class<?> target) {
-      targetsBuilder().add(target);
-      return this;
-    }
-
-    public abstract Builder matchSubClasses(boolean matchSubClasses);
-
-    public abstract Builder matchInnerClasses(boolean matchInnerClasses);
-
-    public abstract Builder skip(int skip);
-
-    abstract ImmutableList.Builder<String> ignoredPackagesBuilder();
-
-    public Builder addIgnoredPackage(String ignoredPackage) {
-      ignoredPackagesBuilder().add(ignoredPackage);
-      return this;
-    }
-
-    abstract ImmutableList.Builder<String> ignoredClassesBuilder();
-
-    public Builder addIgnoredClass(Class<?> ignoredClass) {
-      ignoredClassesBuilder().add(ignoredClass.getName());
-      return this;
-    }
-
-    public abstract CallerFinder build();
-  }
-
-  public LazyArg<String> findCallerLazy() {
-    return lazy(
-        () ->
-            targets().stream()
-                .map(t -> findCallerOf(t, skip() + 1))
-                .filter(Optional::isPresent)
-                .findFirst()
-                .map(Optional::get)
-                .orElse("unknown"));
-  }
-
-  private Optional<String> findCallerOf(Class<?> target, int skip) {
-    // Skip one additional stack frame because we create the Throwable inside this method, not at
-    // the point that this method was invoked.
-    skip++;
-
-    StackTraceElement[] stack = new Throwable().getStackTrace();
-
-    // Note: To avoid having to reflect the getStackTraceDepth() method as well, we assume that we
-    // will find the caller on the stack and simply catch an exception if we fail (which should
-    // hardly ever happen).
-    boolean foundCaller = false;
-    try {
-      for (int index = skip; ; index++) {
-        StackTraceElement element = stack[index];
-        if (isCaller(target, element.getClassName(), matchSubClasses())) {
-          foundCaller = true;
-        } else if (foundCaller
-            && !ignoredPackages().contains(getPackageName(element))
-            && !ignoredClasses().contains(element.getClassName())) {
-          return Optional.of(element.toString());
-        }
-      }
-    } catch (Exception e) {
-      // This should only happen if a) the caller was not found on the stack
-      // (IndexOutOfBoundsException) b) a class that is mentioned in the stack was not found
-      // (ClassNotFoundException), however we don't want anything to be thrown from here.
-      return Optional.empty();
-    }
-  }
-
-  private static String getPackageName(StackTraceElement element) {
-    String className = element.getClassName();
-    return className.substring(0, className.lastIndexOf("."));
-  }
-
-  private boolean isCaller(Class<?> target, String className, boolean matchSubClasses)
-      throws ClassNotFoundException {
-    if (matchSubClasses) {
-      Class<?> clazz = Class.forName(className);
-      while (clazz != null) {
-        if (Object.class.getName().equals(clazz.getName())) {
-          break;
-        }
-
-        if (isCaller(target, clazz.getName(), false)) {
-          return true;
-        }
-        clazz = clazz.getSuperclass();
-      }
-    }
-
-    if (matchInnerClasses()) {
-      int i = className.indexOf('$');
-      if (i > 0) {
-        className = className.substring(0, i);
-      }
-    }
-
-    if (target.getName().equals(className)) {
-      return true;
-    }
-
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index eac96a6..adc9c2b 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.flogger.context.Tags;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.inject.Provider;
 import java.util.List;
 import java.util.concurrent.Callable;
@@ -157,6 +158,7 @@
     return Boolean.TRUE.equals(forceLogging.get());
   }
 
+  @CanIgnoreReturnValue
   boolean forceLogging(boolean force) {
     Boolean oldValue = forceLogging.get();
     if (force) {
@@ -273,6 +275,7 @@
    * @param enable whether ACL logging should be enabled.
    * @return whether ACL logging was be enabled before invoking this method (old value).
    */
+  @CanIgnoreReturnValue
   boolean aclLogging(boolean enable) {
     Boolean oldValue = aclLogging.get();
     if (enable) {
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index b433e9f..b7f3404 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.LazyArg;
 import com.google.common.flogger.LazyArgs;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
@@ -40,6 +41,12 @@
    */
   public abstract Optional<String> actionType();
 
+  /**
+   * Number of attempt. The first execution has {@code attempt=1}, the first retry has {@code
+   * attempt=2}.
+   */
+  public abstract Optional<Integer> attempt();
+
   /** An authentication domain name. */
   public abstract Optional<String> authDomainName();
 
@@ -52,6 +59,9 @@
   /** The name of a cache. */
   public abstract Optional<String> cacheName();
 
+  /** The caller that triggered the operation. */
+  public abstract Optional<String> caller();
+
   /** The name of the implementation class. */
   public abstract Optional<String> className();
 
@@ -185,20 +195,20 @@
    * few are populated this leads to long string representations such as
    *
    * <pre>
-   * Metadata{accountId=Optional.empty, actionType=Optional.empty, authDomainName=Optional.empty,
-   * branchName=Optional.empty, cacheKey=Optional.empty, cacheName=Optional.empty,
-   * className=Optional.empty, cancellationReason=Optional.empty changeId=Optional[9212550],
-   * changeIdType=Optional.empty, cause=Optional.empty, diffAlgorithm=Optional.empty,
-   * eventType=Optional.empty, exportValue=Optional.empty, filePath=Optional.empty,
-   * garbageCollectorName=Optional.empty, gitOperation=Optional.empty, groupId=Optional.empty,
-   * groupName=Optional.empty, groupUuid=Optional.empty, httpStatus=Optional.empty,
-   * indexName=Optional.empty, indexVersion=Optional[0], methodName=Optional.empty,
-   * multiple=Optional.empty, operationName=Optional.empty, partial=Optional.empty,
-   * noteDbFilePath=Optional.empty, noteDbRefName=Optional.empty,
-   * noteDbSequenceType=Optional.empty, patchSetId=Optional.empty, pluginMetadata=[],
-   * pluginName=Optional.empty, projectName=Optional.empty, pushType=Optional.empty,
-   * requestType=Optional.empty, resourceCount=Optional.empty, restViewName=Optional.empty,
-   * revision=Optional.empty, username=Optional.empty}
+   * Metadata{accountId=Optional.empty, actionType=Optional.empty, attempt=Optional.empty,
+   * authDomainName=Optional.empty, branchName=Optional.empty, cacheKey=Optional.empty,
+   * cacheName=Optional.empty, caller=Optional.empty, className=Optional.empty,
+   * cancellationReason=Optional.empty, changeId=Optional[9212550], changeIdType=Optional.empty,
+   * cause=Optional.empty, diffAlgorithm=Optional.empty, eventType=Optional.empty,
+   * exportValue=Optional.empty, filePath=Optional.empty, garbageCollectorName=Optional.empty,
+   * gitOperation=Optional.empty, groupId=Optional.empty, groupName=Optional.empty,
+   * groupUuid=Optional.empty, httpStatus=Optional.empty, indexName=Optional.empty,
+   * indexVersion=Optional[0], methodName=Optional.empty, multiple=Optional.empty,
+   * operationName=Optional.empty, partial=Optional.empty, noteDbFilePath=Optional.empty,
+   * noteDbRefName=Optional.empty, noteDbSequenceType=Optional.empty, patchSetId=Optional.empty,
+   * pluginMetadata=[], pluginName=Optional.empty, projectName=Optional.empty,
+   * pushType=Optional.empty, requestType=Optional.empty, resourceCount=Optional.empty,
+   * restViewName=Optional.empty, revision=Optional.empty, username=Optional.empty}
    * </pre>
    *
    * <p>That's hard to read in logs. This is why this method
@@ -293,6 +303,8 @@
 
     public abstract Builder actionType(@Nullable String actionType);
 
+    public abstract Builder attempt(int attempt);
+
     public abstract Builder authDomainName(@Nullable String authDomainName);
 
     public abstract Builder branchName(@Nullable String branchName);
@@ -301,6 +313,8 @@
 
     public abstract Builder cacheName(@Nullable String cacheName);
 
+    public abstract Builder caller(@Nullable String caller);
+
     public abstract Builder className(@Nullable String className);
 
     public abstract Builder cancellationReason(@Nullable String cancellationReason);
@@ -363,6 +377,7 @@
 
     abstract ImmutableList.Builder<PluginMetadata> pluginMetadataBuilder();
 
+    @CanIgnoreReturnValue
     public Builder addPluginMetadata(PluginMetadata pluginMetadata) {
       pluginMetadataBuilder().add(pluginMetadata);
       return this;
diff --git a/java/com/google/gerrit/server/logging/MutableTags.java b/java/com/google/gerrit/server/logging/MutableTags.java
index 3f48b59..1c7ce63 100644
--- a/java/com/google/gerrit/server/logging/MutableTags.java
+++ b/java/com/google/gerrit/server/logging/MutableTags.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.context.Tags;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 
 public class MutableTags {
   private final SetMultimap<String, String> tagMap =
@@ -39,6 +40,7 @@
    * @return {@code true} if the tag was added, {@code false} if the tag was not added because it
    *     already exists
    */
+  @CanIgnoreReturnValue
   public boolean add(String name, String value) {
     requireNonNull(name, "tag name is required");
     requireNonNull(value, "tag value is required");
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
index 046eeb3..07d9b90 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.logging;
 
 import com.google.auto.value.AutoValue;
+import java.time.Instant;
 import java.util.Optional;
 
 /**
@@ -33,7 +34,8 @@
    * @return the performance log record
    */
   public static PerformanceLogRecord create(String operation, long durationMs) {
-    return new AutoValue_PerformanceLogRecord(operation, durationMs, Optional.empty());
+    return new AutoValue_PerformanceLogRecord(
+        operation, durationMs, Instant.now(), Optional.empty());
   }
 
   /**
@@ -45,20 +47,23 @@
    * @return the performance log record
    */
   public static PerformanceLogRecord create(String operation, long durationMs, Metadata metadata) {
-    return new AutoValue_PerformanceLogRecord(operation, durationMs, Optional.of(metadata));
+    return new AutoValue_PerformanceLogRecord(
+        operation, durationMs, Instant.now(), Optional.of(metadata));
   }
 
   public abstract String operation();
 
   public abstract long durationMs();
 
+  public abstract Instant endTime();
+
   public abstract Optional<Metadata> metadata();
 
   void writeTo(PerformanceLogger performanceLogger) {
     if (metadata().isPresent()) {
-      performanceLogger.log(operation(), durationMs(), metadata().get());
+      performanceLogger.log(operation(), durationMs(), endTime(), metadata().get());
     } else {
-      performanceLogger.log(operation(), durationMs());
+      performanceLogger.log(operation(), durationMs(), endTime());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogger.java b/java/com/google/gerrit/server/logging/PerformanceLogger.java
index 74a1684..bed53ba 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogger.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogger.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.logging;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.time.Instant;
 
 /**
  * Extension point for logging performance records.
@@ -35,8 +36,8 @@
    * @param operation operation that was performed
    * @param durationMs time that the execution of the operation took (in milliseconds)
    */
-  default void log(String operation, long durationMs) {
-    log(operation, durationMs, Metadata.empty());
+  default void log(String operation, long durationMs, Instant endTime) {
+    log(operation, durationMs, endTime, Metadata.empty());
   }
 
   /**
@@ -46,5 +47,5 @@
    * @param durationMs time that the execution of the operation took (in milliseconds)
    * @param metadata metadata
    */
-  void log(String operation, long durationMs, Metadata metadata);
+  void log(String operation, long durationMs, Instant endTime, Metadata metadata);
 }
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 487e0af..fb698f7 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.cancellation.RequestStateContext;
 import java.util.Optional;
@@ -238,10 +239,12 @@
     this.oldAclLogRecords = LoggingContext.getInstance().getAclLogRecords();
   }
 
+  @CanIgnoreReturnValue
   public TraceContext addTag(RequestId.Type requestId, Object tagValue) {
     return addTag(requireNonNull(requestId, "request ID is required").name(), tagValue);
   }
 
+  @CanIgnoreReturnValue
   public TraceContext addTag(String tagName, Object tagValue) {
     String name = requireNonNull(tagName, "tag name is required");
     String value = requireNonNull(tagValue, "tag value is required").toString();
@@ -255,10 +258,12 @@
     return tagMap.build();
   }
 
+  @CanIgnoreReturnValue
   public TraceContext addPluginTag(String pluginName) {
     return addTag(PLUGIN_TAG, pluginName);
   }
 
+  @CanIgnoreReturnValue
   public TraceContext forceLogging() {
     if (stopForceLoggingOnClose) {
       return this;
@@ -285,6 +290,7 @@
     return LoggingContext.getInstance().getTagsAsMap().get(tagName).stream().findFirst();
   }
 
+  @CanIgnoreReturnValue
   public TraceContext enableAclLogging() {
     if (stopAclLoggingOnClose) {
       return this;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/logging/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/logging/package-info.java
index 0709b86..59dbd89 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/logging/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.logging;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/mail/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/mail/package-info.java
index 0709b86..a8ba362 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/mail/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.mail;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 1898a98..6c38210 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -22,6 +22,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
@@ -167,14 +168,16 @@
    * @param message {@link MailMessage} to process
    */
   public void process(MailMessage message) throws RestApiException, UpdateException {
-    retryHelper
-        .changeUpdate(
-            "processCommentsReceivedByEmail",
-            buf -> {
-              processImpl(buf, message);
-              return null;
-            })
-        .call();
+    @SuppressWarnings("unused")
+    var unused =
+        retryHelper
+            .changeUpdate(
+                "processCommentsReceivedByEmail",
+                buf -> {
+                  processImpl(buf, message);
+                  return null;
+                })
+            .call();
   }
 
   private void processImpl(BatchUpdate.Factory buf, MailMessage message)
@@ -198,7 +201,7 @@
       return;
     }
 
-    Set<Account.Id> accountIds = emails.getAccountFor(metadata.author);
+    ImmutableSet<Account.Id> accountIds = emails.getAccountFor(metadata.author);
 
     if (accountIds.size() != 1) {
       logger.atSevere().log(
@@ -249,7 +252,7 @@
           queryProvider
               .get()
               .enforceVisibility(true)
-              .byLegacyChangeId(Change.id(metadata.changeNumber));
+              .byChangeNumber(Change.id(metadata.changeNumber));
       if (changeDataList.isEmpty()) {
         sendRejectionEmail(message, InboundEmailError.CHANGE_NOT_FOUND);
         return;
@@ -449,6 +452,7 @@
               (short) side.ordinal(),
               mailComment.getMessage(),
               false,
+              null,
               null);
 
       comment.tag = tag;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/mail/receive/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/mail/receive/package-info.java
index 0709b86..bee1966f 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/mail/receive/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.mail.receive;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
index 4ecbd52..388b0d0 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
@@ -19,6 +19,7 @@
 
 import com.google.auto.factory.AutoFactory;
 import com.google.auto.factory.Provided;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -58,7 +59,6 @@
 import java.time.Instant;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -88,7 +88,7 @@
 
   // Available after init or after being explicitly set.
   protected OutgoingEmail email;
-  private List<Account.Id> stars;
+  private ImmutableList<Account.Id> stars;
   protected PatchSet patchSet;
   protected PatchSetInfo patchSetInfo;
   private String changeMessage;
@@ -447,7 +447,7 @@
     return watch.getWatchers(type, includeWatchersFromNotifyConfig);
   }
 
-  /** Any user who has published comments on this change. */
+  /** CC all users who are added as reviewer or cc to the change. */
   @Override
   public void ccAllApprovals() {
     if (!NotifyHandling.ALL.equals(email.getNotify().handling())
@@ -459,6 +459,9 @@
       for (Account.Id id : changeData.reviewers().all()) {
         email.addByAccountId(RecipientType.CC, id);
       }
+      for (Address addr : this.changeData.reviewersByEmail().all()) {
+        email.addByEmail(RecipientType.CC, addr);
+      }
     } catch (StorageException err) {
       logger.atWarning().withCause(err).log("Cannot CC users that reviewed updated change");
     }
@@ -476,6 +479,9 @@
       for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
         email.addByAccountId(RecipientType.CC, id);
       }
+      for (Address addr : changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER)) {
+        email.addByEmail(RecipientType.CC, addr);
+      }
     } catch (StorageException err) {
       logger.atWarning().withCause(err).log("Cannot CC users that commented on updated change");
     }
@@ -619,17 +625,6 @@
         currentAttentionSet.stream().map(email::getNameFor).sorted().collect(toImmutableList()));
 
     setChangeSubjectHeader();
-    if (email.getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)
-        || email.getNotify().handling().equals(NotifyHandling.ALL)) {
-      try {
-        this.changeData.reviewersByEmail().byState(ReviewerStateInternal.CC).stream()
-            .forEach(address -> email.addByEmail(RecipientType.CC, address));
-        this.changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER).stream()
-            .forEach(address -> email.addByEmail(RecipientType.CC, address));
-      } catch (StorageException e) {
-        throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
-      }
-    }
 
     if (email.useHtml()) {
       email.appendHtml(email.soyHtmlTemplate("ChangeHeaderHtml"));
diff --git a/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorImpl.java b/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorImpl.java
index c54c488..b5f8014 100644
--- a/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorImpl.java
+++ b/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorImpl.java
@@ -397,7 +397,7 @@
           commentData.put("lines", getLinesOfComment(comment, group.fileData));
         }
         commentData.put("message", comment.message.trim());
-        List<CommentFormatter.Block> blocks = CommentFormatter.parse(comment.message);
+        ImmutableList<CommentFormatter.Block> blocks = CommentFormatter.parse(comment.message);
         commentData.put("messageBlocks", commentBlocksToSoyData(blocks));
 
         // Set the prefix.
diff --git a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
index b32c43a..29b914f 100644
--- a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
@@ -1,58 +1,26 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
 package com.google.gerrit.server.mail.send;
 
-import static com.google.common.base.Preconditions.checkState;
-
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.mail.MailMessage;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.update.RepoView;
-import com.google.inject.Inject;
-import java.io.IOException;
 import java.time.Instant;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
 
-/** A generator class that creates a {@link MessageId} */
-public class MessageIdGenerator {
-  private final GitRepositoryManager repositoryManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  public MessageIdGenerator(GitRepositoryManager repositoryManager, AllUsersName allUsersName) {
-    this.repositoryManager = repositoryManager;
-    this.allUsersName = allUsersName;
-  }
-
+public interface MessageIdGenerator {
   /**
    * A unique id used which is a part of the header of all emails sent through by Gerrit. All of the
    * emails are sent via {@link OutgoingEmail#send()}.
    */
   @AutoValue
-  public abstract static class MessageId {
+  abstract class MessageId {
     public abstract String id();
+
+    public static MessageId create(String id) {
+      return new AutoValue_MessageIdGenerator_MessageId(id);
+    }
   }
 
   /**
@@ -60,43 +28,19 @@
    *
    * @return MessageId that depends on the patchset.
    */
-  public MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId) {
-    return fromChangeUpdateAndReason(repoView, patchsetId, null);
-  }
+  MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId);
 
-  public MessageId fromChangeUpdateAndReason(
-      RepoView repoView, PatchSet.Id patchsetId, @Nullable String reason) {
-    String suffix = (reason != null) ? ("-" + reason) : "";
-    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
-    Optional<ObjectId> metaSha1;
-    try {
-      metaSha1 = repoView.getRef(metaRef);
-    } catch (IOException ex) {
-      throw new StorageException("unable to extract info for Message-Id", ex);
-    }
-    return metaSha1
-        .map(optional -> new AutoValue_MessageIdGenerator_MessageId(optional.getName() + suffix))
-        .orElseThrow(() -> new IllegalStateException(metaRef + " doesn't exist"));
-  }
+  MessageId fromChangeUpdateAndReason(
+      RepoView repoView, PatchSet.Id patchsetId, @Nullable String reason);
 
-  public MessageId fromChangeUpdate(Project.NameKey project, PatchSet.Id patchsetId) {
-    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
-    Ref ref = getRef(metaRef, project);
-    checkState(ref != null, metaRef + " must exist");
-    return new AutoValue_MessageIdGenerator_MessageId(ref.getObjectId().getName());
-  }
+  MessageId fromChangeUpdate(Project.NameKey project, PatchSet.Id patchsetId);
 
   /**
    * Create a {@link MessageId} as a result of an account update
    *
    * @return {@link MessageId} that depends on the account id.
    */
-  public MessageId fromAccountUpdate(Account.Id accountId) {
-    String userRef = RefNames.refsUsers(accountId);
-    Ref ref = getRef(userRef, allUsersName);
-    checkState(ref != null, userRef + " must exist");
-    return new AutoValue_MessageIdGenerator_MessageId(ref.getObjectId().getName());
-  }
+  MessageId fromAccountUpdate(Account.Id accountId);
 
   /**
    * Create a {@link MessageId} from a mail message.
@@ -104,9 +48,7 @@
    * @param mailMessage The message that was sent but was rejected.
    * @return MessageId that depends on the MailMessage that was rejected.
    */
-  public MessageId fromMailMessage(MailMessage mailMessage) {
-    return new AutoValue_MessageIdGenerator_MessageId(mailMessage.id() + "-REJECTION");
-  }
+  MessageId fromMailMessage(MailMessage mailMessage);
 
   /**
    * Create a {@link MessageId} from a reason, Account.Id, and timestamp.
@@ -114,17 +56,5 @@
    * @param reason for performing this account update
    * @return MessageId that depends on the reason, accountId, and timestamp.
    */
-  public MessageId fromReasonAccountIdAndTimestamp(
-      String reason, Account.Id accountId, Instant timestamp) {
-    return new AutoValue_MessageIdGenerator_MessageId(
-        reason + "-" + accountId.toString() + "-" + timestamp.toString());
-  }
-
-  private Ref getRef(String userRef, Project.NameKey project) {
-    try (Repository repository = repositoryManager.openRepository(project)) {
-      return repository.getRefDatabase().findRef(userRef);
-    } catch (IOException ex) {
-      throw new StorageException("unable to extract info for Message-Id", ex);
-    }
-  }
+  MessageId fromReasonAccountIdAndTimestamp(String reason, Account.Id accountId, Instant timestamp);
 }
diff --git a/java/com/google/gerrit/server/mail/send/MessageIdGeneratorImpl.java b/java/com/google/gerrit/server/mail/send/MessageIdGeneratorImpl.java
new file mode 100644
index 0000000..292d042
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGeneratorImpl.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.RepoView;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/** A generator class that creates a {@link MessageId} */
+public class MessageIdGeneratorImpl implements MessageIdGenerator {
+  private final GitRepositoryManager repositoryManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  public MessageIdGeneratorImpl(GitRepositoryManager repositoryManager, AllUsersName allUsersName) {
+    this.repositoryManager = repositoryManager;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  public MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId) {
+    return fromChangeUpdateAndReason(repoView, patchsetId, null);
+  }
+
+  @Override
+  public MessageId fromChangeUpdateAndReason(
+      RepoView repoView, PatchSet.Id patchsetId, @Nullable String reason) {
+    String suffix = (reason != null) ? ("-" + reason) : "";
+    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
+    Optional<ObjectId> metaSha1;
+    try {
+      metaSha1 = repoView.getRef(metaRef);
+    } catch (IOException ex) {
+      throw new StorageException("unable to extract info for Message-Id", ex);
+    }
+    return metaSha1
+        .map(optional -> MessageId.create(optional.getName() + suffix))
+        .orElseThrow(() -> new IllegalStateException(metaRef + " doesn't exist"));
+  }
+
+  @Override
+  public MessageId fromChangeUpdate(Project.NameKey project, PatchSet.Id patchsetId) {
+    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
+    Ref ref = getRef(metaRef, project);
+    checkState(ref != null, metaRef + " must exist");
+    return MessageId.create(ref.getObjectId().getName());
+  }
+
+  @Override
+  public MessageId fromAccountUpdate(Account.Id accountId) {
+    String userRef = RefNames.refsUsers(accountId);
+    Ref ref = getRef(userRef, allUsersName);
+    checkState(ref != null, userRef + " must exist");
+    return MessageId.create(ref.getObjectId().getName());
+  }
+
+  @Override
+  public MessageId fromMailMessage(MailMessage mailMessage) {
+    return MessageId.create(mailMessage.id() + "-REJECTION");
+  }
+
+  @Override
+  public MessageId fromReasonAccountIdAndTimestamp(
+      String reason, Account.Id accountId, Instant timestamp) {
+    return MessageId.create(reason + "-" + accountId.toString() + "-" + timestamp.toString());
+  }
+
+  private Ref getRef(String userRef, Project.NameKey project) {
+    try (Repository repository = repositoryManager.openRepository(project)) {
+      return repository.getRefDatabase().findRef(userRef);
+    } catch (IOException ex) {
+      throw new StorageException("unable to extract info for Message-Id", ex);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 4dda7f0..a6c89dc 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -184,16 +184,18 @@
   /** Format and enqueue the message for delivery. */
   public void send() throws EmailException {
     try {
-      args.retryHelper
-          .action(
-              ActionType.SEND_EMAIL,
-              "sendEmail",
-              () -> {
-                sendImpl();
-                return null;
-              })
-          .retryWithTrace(Exception.class::isInstance)
-          .call();
+      @SuppressWarnings("unused")
+      var unused =
+          args.retryHelper
+              .action(
+                  ActionType.SEND_EMAIL,
+                  "sendEmail",
+                  () -> {
+                    sendImpl();
+                    return null;
+                  })
+              .retryWithTrace(Exception.class::isInstance)
+              .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       Throwables.throwIfInstanceOf(e, EmailException.class);
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 75159b0..94a0e37 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
@@ -215,6 +216,7 @@
     }
   }
 
+  @CanIgnoreReturnValue
   private boolean add(
       Watchers matching,
       Account.Id accountId,
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 17a59a4..59d1b9b 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -142,7 +142,7 @@
   @Override
   public boolean canEmail(String address) {
     if (!isEnabled()) {
-      logger.atWarning().log("Not emailing %s (email is disabled)", address);
+      logger.atFine().log("Not emailing %s (email is disabled)", address);
       return false;
     }
 
@@ -163,7 +163,7 @@
     if (denyrcpt.contains(address)
         || denyrcpt.contains(domain)
         || denyrcpt.contains("@" + domain)) {
-      logger.atInfo().log("Not emailing %s (prohibited by sendemail.denyrcpt)", address);
+      logger.atFine().log("Not emailing %s (prohibited by sendemail.denyrcpt)", address);
       return true;
     }
 
@@ -182,7 +182,7 @@
       return true;
     }
 
-    logger.atWarning().log("Not emailing %s (prohibited by sendemail.allowrcpt)", address);
+    logger.atFine().log("Not emailing %s (prohibited by sendemail.allowrcpt)", address);
     return false;
   }
 
@@ -258,7 +258,8 @@
                 "Server " + smtpHost + " rejected message body: " + client.getReplyString());
           }
 
-          client.logout();
+          @SuppressWarnings("unused")
+          var unused = client.logout();
           if (rejected.length() > 0) {
             throw new EmailException(rejected.toString());
           }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/mail/send/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/mail/send/package-info.java
index 0709b86..8b099cb 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/mail/send/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.mail.send;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/mime/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/mime/package-info.java
index 0709b86..e024c4c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/mime/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.mime;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 5ffb5fb..8bd18d7 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -18,6 +18,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Change;
@@ -142,6 +143,7 @@
     return revision;
   }
 
+  @CanIgnoreReturnValue
   public T load() {
     try (Repository repo = args.repoManager.openRepository(getProjectName())) {
       load(repo);
@@ -151,6 +153,7 @@
     }
   }
 
+  @CanIgnoreReturnValue
   public T load(Repository repo) {
     if (loaded) {
       return self();
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 708d59f..0a82b4c 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.CommentVerifier;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
@@ -55,7 +56,7 @@
   private ObjectId result;
   boolean rootOnly;
 
-  AbstractChangeUpdate(
+  protected AbstractChangeUpdate(
       ChangeNotes notes,
       CurrentUser user,
       PersonIdent serverIdent,
@@ -65,11 +66,11 @@
     this.serverIdent = new PersonIdent(serverIdent, when);
     this.notes = notes;
     this.change = notes.getChange();
+    this.when = when;
     this.accountId = accountId(user);
     Account.Id realAccountId = accountId(user.getRealUser());
     this.realAccountId = realAccountId != null ? realAccountId : accountId;
     this.authorIdent = ident(noteUtil, serverIdent, user, when);
-    this.when = when;
   }
 
   AbstractChangeUpdate(
@@ -236,9 +237,14 @@
     setParentCommit(cb, curr);
     if (cb.getTreeId() == null) {
       if (curr.equals(z)) {
-        cb.setTreeId(emptyTree(ins)); // No parent, assume empty tree.
+        ObjectId emptyTreeId = emptyTree(ins);
+        logger.atFine().log("setting empty tree %s for new change meta commit", emptyTreeId.name());
+        cb.setTreeId(emptyTreeId); // No parent, assume empty tree.
       } else {
         RevCommit p = rw.parseCommit(curr);
+        logger.atFine().log(
+            "setting tree %s of previous commit %s for new change meta commit",
+            p.getTree().name(), p.name());
         cb.setTreeId(p.getTree()); // Copy tree from parent.
       }
     }
@@ -272,23 +278,12 @@
   }
 
   private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
-    return ins.insert(Constants.OBJ_TREE, new byte[] {});
+    ObjectId treeId = ins.insert(Constants.OBJ_TREE, new byte[] {});
+    logger.atFine().log("inserted empty tree %s (inserter: %s)", treeId.name(), ins);
+    return treeId;
   }
 
-  void verifyComment(Comment c) {
-    checkArgument(c.getCommitId() != null, "commit ID required for comment: %s", c);
-    checkArgument(
-        c.author.getId().equals(getAccountId()),
-        "The author for the following comment does not match the author of this %s (%s): %s",
-        getClass().getSimpleName(),
-        getAccountId(),
-        c);
-    checkArgument(
-        c.getRealAuthor().getId().equals(realAccountId),
-        "The real author for the following comment does not match the real"
-            + " author of this %s (%s): %s",
-        getClass().getSimpleName(),
-        realAccountId,
-        c);
+  protected void verifyComment(Comment c) {
+    CommentVerifier.verify(c, accountId, realAccountId, authorIdent);
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
index b15cb50..4bb347a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
@@ -16,9 +16,12 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.logging.TraceContext.newTimer;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -28,9 +31,18 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.ChangeDraftUpdate;
+import com.google.gerrit.server.ChangeDraftUpdateExecutor;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.query.change.ChangeNumberVirtualIdAlgorithm;
+import com.google.gerrit.server.update.BatchUpdateListener;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
@@ -42,12 +54,16 @@
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushCertificate;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
  * A single delta to apply atomically to a change.
@@ -95,7 +111,121 @@
     return new AutoValue_ChangeDraftNotesUpdate_Key(c.getCommitId(), c.key);
   }
 
+  public static class Executor implements ChangeDraftUpdateExecutor, AutoCloseable {
+    public interface Factory extends ChangeDraftUpdateExecutor.Factory<Executor> {
+      @Override
+      Executor create(CurrentUser currentUser);
+    }
+
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
+    private final NoteDbUpdateExecutor noteDbUpdateExecutor;
+    private final CurrentUser currentUser;
+    private final AllUsersAsyncUpdate updateAllUsersAsync;
+    private OpenRepo allUsersRepo;
+    private boolean shouldAllowFastForward = false;
+
+    @Inject
+    Executor(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName,
+        NoteDbUpdateExecutor noteDbUpdateExecutor,
+        AllUsersAsyncUpdate updateAllUsersAsync,
+        @Assisted CurrentUser currentUser) {
+      this.updateAllUsersAsync = updateAllUsersAsync;
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
+      this.noteDbUpdateExecutor = noteDbUpdateExecutor;
+      this.currentUser = currentUser;
+    }
+
+    @Override
+    public void queueAllDraftUpdates(ListMultimap<String, ChangeDraftUpdate> updaters)
+        throws IOException {
+      ListMultimap<String, ChangeDraftNotesUpdate> noteDbUpdaters =
+          filterTypedUpdates(updaters, ChangeDraftNotesUpdate.class);
+      if (canRunAsync(noteDbUpdaters.values())) {
+        updateAllUsersAsync.setDraftUpdates(noteDbUpdaters);
+      } else {
+        initAllUsersRepoIfNull();
+        shouldAllowFastForward = true;
+        allUsersRepo.addUpdatesNoLimits(noteDbUpdaters);
+      }
+    }
+
+    @Override
+    public void queueDeletionForChangeDrafts(Change.Id id) throws IOException {
+      initAllUsersRepoIfNull();
+      // Just scan repo for ref names, but get "old" values from cmds.
+      for (Ref r :
+          allUsersRepo
+              .repo
+              .getRefDatabase()
+              .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(id))) {
+        Optional<ObjectId> old = allUsersRepo.cmds.get(r.getName());
+        old.ifPresent(
+            objectId ->
+                allUsersRepo.cmds.add(
+                    new ReceiveCommand(objectId, ObjectId.zeroId(), r.getName())));
+      }
+    }
+
+    /**
+     * Note this method does not fire {@link BatchUpdateListener#beforeUpdateRefs} events. However,
+     * since the {@link BatchRefUpdate} object is returned, {@link
+     * BatchUpdateListener#afterUpdateRefs} can be fired by the caller.
+     */
+    @Override
+    public Optional<BatchRefUpdate> executeAllSyncUpdates(
+        boolean dryRun, @Nullable PersonIdent refLogIdent, @Nullable String refLogMessage)
+        throws IOException {
+      if (allUsersRepo == null) {
+        return Optional.empty();
+      }
+      try (TraceContext.TraceTimer ignored =
+          newTimer("ChangeDraftNotesUpdate#Executor#updateAllUsersSync", Metadata.empty())) {
+        return noteDbUpdateExecutor.execute(
+            allUsersRepo,
+            dryRun,
+            shouldAllowFastForward,
+            /* batchUpdateListeners= */ ImmutableList.of(),
+            /* pushCert= */ null,
+            refLogIdent,
+            refLogMessage);
+      }
+    }
+
+    @Override
+    public void executeAllAsyncUpdates(
+        @Nullable PersonIdent refLogIdent,
+        @Nullable String refLogMessage,
+        @Nullable PushCertificate pushCert) {
+      updateAllUsersAsync.execute(refLogIdent, refLogMessage, pushCert, currentUser);
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return (allUsersRepo == null || allUsersRepo.cmds.isEmpty()) && updateAllUsersAsync.isEmpty();
+    }
+
+    @Override
+    public void close() throws Exception {
+      if (allUsersRepo != null) {
+        OpenRepo r = allUsersRepo;
+        allUsersRepo = null;
+        r.close();
+      }
+    }
+
+    private void initAllUsersRepoIfNull() throws IOException {
+      if (allUsersRepo == null) {
+        allUsersRepo = OpenRepo.open(repoManager, allUsersName);
+      }
+    }
+  }
+
   private final AllUsersName draftsProject;
+  private final ExperimentFeatures experimentFeatures;
 
   private List<HumanComment> put = new ArrayList<>();
   private Map<Key, DeleteReason> delete = new HashMap<>();
@@ -106,6 +236,7 @@
       @GerritPersonIdent PersonIdent serverIdent,
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
+      ExperimentFeatures experimentFeatures,
       @Nullable ChangeNumberVirtualIdAlgorithm virtualIdFunc,
       @Assisted ChangeNotes notes,
       @Assisted("effective") Account.Id accountId,
@@ -114,6 +245,7 @@
       @Assisted Instant when) {
     super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
+    this.experimentFeatures = experimentFeatures;
     this.virtualIdFunc = virtualIdFunc;
   }
 
@@ -122,6 +254,7 @@
       @GerritPersonIdent PersonIdent serverIdent,
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
+      ExperimentFeatures experimentFeatures,
       @Nullable ChangeNumberVirtualIdAlgorithm virtualIdFunc,
       @Assisted Change change,
       @Assisted("effective") Account.Id accountId,
@@ -130,6 +263,7 @@
       @Assisted Instant when) {
     super(noteUtil, serverIdent, null, change, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
+    this.experimentFeatures = experimentFeatures;
     this.virtualIdFunc = virtualIdFunc;
   }
 
@@ -164,6 +298,14 @@
         });
   }
 
+  /**
+   * Returns whether all the updates in this instance can run asynchronously.
+   *
+   * <p>An update can run asynchronously only if it contains nothing but {@code PUBLISHED} or {@code
+   * FIXED} draft deletions. User-initiated inversions/deletions must run synchronously in order to
+   * return status.
+   */
+  @Override
   public boolean canRunAsync() {
     return put.isEmpty()
         && delete.values().stream()
@@ -184,6 +326,7 @@
             authorIdent,
             draftsProject,
             noteUtil,
+            experimentFeatures,
             virtualIdFunc,
             new Change(getChange()),
             accountId,
@@ -202,6 +345,10 @@
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
 
     for (HumanComment c : put) {
+      if (!experimentFeatures.isFeatureEnabled(
+          ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS)) {
+        checkState(c.fixSuggestions == null, "feature flag prohibits setting fixSuggestions");
+      }
       if (!delete.keySet().contains(key(c))) {
         cache.get(c.getCommitId()).putComment(c);
       }
@@ -295,6 +442,11 @@
   }
 
   @Override
+  public String getStorageKey() {
+    return getRefName();
+  }
+
+  @Override
   protected void setParentCommit(CommitBuilder cb, ObjectId parentCommitId) {
     cb.setParentIds(); // Draft updates should not keep history of parent commits
   }
@@ -304,17 +456,6 @@
     return delete.isEmpty() && put.isEmpty();
   }
 
-  public static Optional<ChangeDraftNotesUpdate> asChangeDraftNotesUpdate(
-      @Nullable ChangeDraftUpdate obj) {
-    if (obj == null) {
-      return Optional.empty();
-    }
-    if (obj instanceof ChangeDraftNotesUpdate) {
-      return Optional.of((ChangeDraftNotesUpdate) obj);
-    }
-    return Optional.empty();
-  }
-
   private Change.Id getVirtualId() {
     Change change = getChange();
     return virtualIdFunc == null
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 881cd96..8df2903 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.auto.value.AutoValue;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.json.OutputFormat;
@@ -52,6 +53,7 @@
    *
    * @return The passed in {@link StringBuilder} instance to which the identifier has been appended.
    */
+  @CanIgnoreReturnValue
   StringBuilder appendAccountIdIdentString(StringBuilder stringBuilder, Account.Id accountId) {
     return stringBuilder
         .append(getAccountIdAsUsername(accountId))
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index cee98d1..a7da7ad 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -219,6 +219,8 @@
      * requires using the Change index and should only be used when {@link
      * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
      */
+    @UsedAt(UsedAt.Project.PLUGINS_ALL)
+    @Deprecated(since = "3.10", forRemoval = true)
     public List<ChangeNotes> createUsingIndexLookup(Collection<Change.Id> changeIds) {
       List<ChangeNotes> notes = new ArrayList<>();
       for (Change.Id changeId : changeIds) {
@@ -553,11 +555,12 @@
     return getDraftComments(author, null, null);
   }
 
-  public ImmutableList<HumanComment> getDraftComments(Account.Id author, Ref ref) {
+  public ImmutableList<HumanComment> getDraftComments(Account.Id author, @Nullable Ref ref) {
     return getDraftComments(author, null, ref);
   }
 
-  public ImmutableList<HumanComment> getDraftComments(Account.Id author, Change.Id virtualId) {
+  public ImmutableList<HumanComment> getDraftComments(
+      Account.Id author, @Nullable Change.Id virtualId) {
     return getDraftComments(author, virtualId, null);
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 0566316..08490a3 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -62,7 +62,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(8)
+            .version(11)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index ea26dbc..011c5e8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -49,6 +49,7 @@
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableTable;
@@ -393,7 +394,8 @@
       return copiedApprovals;
     }
     List<PatchSetApproval> approvalsOnLatestPs = allApprovals.get(latestPs);
-    ListMultimap<Account.Id, PatchSetApproval> approvalsByUser = getApprovalsByUser(allApprovals);
+    ImmutableListMultimap<Account.Id, PatchSetApproval> approvalsByUser =
+        getApprovalsByUser(allApprovals);
     List<SubmitRecord.Label> submitRecordLabels =
         submitRecords.stream()
             .filter(r -> r.labels != null)
@@ -431,7 +433,7 @@
     return allApprovals.values().stream().anyMatch(approval -> approval.copied());
   }
 
-  private ListMultimap<Account.Id, PatchSetApproval> getApprovalsByUser(
+  private ImmutableListMultimap<Account.Id, PatchSetApproval> getApprovalsByUser(
       ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
     return allApprovals.values().stream()
         .collect(
@@ -442,10 +444,21 @@
   private List<ReviewerStatusUpdate> buildReviewerUpdates() {
     List<ReviewerStatusUpdate> result = new ArrayList<>();
     HashMap<Account.Id, ReviewerStateInternal> lastState = new HashMap<>();
+    HashMap<Address, ReviewerStateInternal> lastStateReviewerByEmail = new HashMap<>();
     for (ReviewerStatusUpdate u : Lists.reverse(reviewerUpdates)) {
-      if (!Objects.equals(ownerId, u.reviewer()) && lastState.get(u.reviewer()) != u.state()) {
-        result.add(u);
-        lastState.put(u.reviewer(), u.state());
+      if (u.reviewer().isPresent()) {
+        if (!Objects.equals(ownerId, u.reviewer().get())
+            && lastState.get(u.reviewer().get()) != u.state()) {
+          result.add(u);
+          lastState.put(u.reviewer().get(), u.state());
+        }
+      }
+
+      if (u.reviewerByEmail().isPresent()) {
+        if (lastStateReviewerByEmail.get(u.reviewerByEmail().get()) != u.state()) {
+          result.add(u);
+          lastStateReviewerByEmail.put(u.reviewerByEmail().get(), u.state());
+        }
       }
     }
     return result;
@@ -940,7 +953,7 @@
     revisionNoteMap =
         RevisionNoteMap.parse(
             changeNoteJson, reader, NoteMap.read(reader, tipCommit), HumanComment.Status.PUBLISHED);
-    Map<ObjectId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
+    ImmutableMap<ObjectId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
 
     for (Map.Entry<ObjectId, ChangeRevisionNote> e : rns.entrySet()) {
       for (HumanComment c : e.getValue().getEntities()) {
@@ -1233,7 +1246,8 @@
       throw invalidFooter(state.getFooterKey(), line);
     }
     Account.Id accountId = parseIdent(ident);
-    ReviewerStatusUpdate update = ReviewerStatusUpdate.create(ts, ownerId, accountId, state);
+    ReviewerStatusUpdate update =
+        ReviewerStatusUpdate.createForReviewer(ts, ownerId, accountId, state);
     reviewerUpdates.add(update);
     if (update.state() == ReviewerStateInternal.REMOVED) {
       removedReviewers.add(accountId);
@@ -1254,6 +1268,7 @@
       cie.initCause(e);
       throw cie;
     }
+    reviewerUpdates.add(ReviewerStatusUpdate.createForReviewerByEmail(ts, ownerId, adr, state));
     if (!reviewersByEmail.containsRow(adr)) {
       reviewersByEmail.put(adr, state, ts);
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 6b208f3..eb6c15a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -609,12 +609,24 @@
     }
 
     private static ReviewerStatusUpdateProto toReviewerStatusUpdateProto(ReviewerStatusUpdate u) {
-      return ReviewerStatusUpdateProto.newBuilder()
-          .setTimestampMillis(u.date().toEpochMilli())
-          .setUpdatedBy(u.updatedBy().get())
-          .setReviewer(u.reviewer().get())
-          .setState(REVIEWER_STATE_CONVERTER.reverse().convert(u.state()))
-          .build();
+      ReviewerStatusUpdateProto.Builder protoBuilder =
+          ReviewerStatusUpdateProto.newBuilder()
+              .setTimestampMillis(u.date().toEpochMilli())
+              .setUpdatedBy(u.updatedBy().get())
+              .setState(REVIEWER_STATE_CONVERTER.reverse().convert(u.state()));
+      u.reviewer()
+          .ifPresent(
+              accountId -> {
+                protoBuilder.setHasReviewer(true);
+                protoBuilder.setReviewer(accountId.get());
+              });
+      u.reviewerByEmail()
+          .ifPresent(
+              address -> {
+                protoBuilder.setHasReviewerByEmail(true);
+                protoBuilder.setReviewerByEmail(address.toHeaderString());
+              });
+      return protoBuilder.build();
     }
 
     private static AttentionSetUpdateProto toAttentionSetUpdateProto(
@@ -746,12 +758,26 @@
         List<ReviewerStatusUpdateProto> protos) {
       ImmutableList.Builder<ReviewerStatusUpdate> b = ImmutableList.builder();
       for (ReviewerStatusUpdateProto proto : protos) {
-        b.add(
-            ReviewerStatusUpdate.create(
-                Instant.ofEpochMilli(proto.getTimestampMillis()),
-                Account.id(proto.getUpdatedBy()),
-                Account.id(proto.getReviewer()),
-                REVIEWER_STATE_CONVERTER.convert(proto.getState())));
+        if (proto.getHasReviewerByEmail()) {
+          b.add(
+              ReviewerStatusUpdate.createForReviewerByEmail(
+                  Instant.ofEpochMilli(proto.getTimestampMillis()),
+                  Account.id(proto.getUpdatedBy()),
+                  Address.parse(proto.getReviewerByEmail()),
+                  REVIEWER_STATE_CONVERTER.convert(proto.getState())));
+        } else {
+          // If the "has_reviewer_by_email" field is not set, then either the "has_reviewer" field
+          // is true and the "reviewer" field is populated, or the proto was created before the
+          // "has_reviewer", "has_reviewer_by_email" and "reviewer_by_email" fields have been added
+          // and the "reviewer" field is always populated. This means by not checking that
+          // "proto.getHasReviewer()" is true here we allow the new code to read old protos.
+          b.add(
+              ReviewerStatusUpdate.createForReviewer(
+                  Instant.ofEpochMilli(proto.getTimestampMillis()),
+                  Account.id(proto.getUpdatedBy()),
+                  Account.id(proto.getReviewer()),
+                  REVIEWER_STATE_CONVERTER.convert(proto.getState())));
+        }
       }
       return b.build();
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 17f41d4..c97065b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH;
@@ -52,12 +53,15 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Table;
 import com.google.common.collect.Table.Cell;
 import com.google.common.collect.TreeBasedTable;
+import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
@@ -81,6 +85,8 @@
 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.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.validators.TopicValidator;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.context.RefUpdateContext;
@@ -135,6 +141,8 @@
  * the attached {@link ChangeRevisionNote}.
  */
 public class ChangeUpdate extends AbstractChangeUpdate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     ChangeUpdate create(ChangeNotes notes, CurrentUser user, Instant when);
 
@@ -158,13 +166,14 @@
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
   private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
   private final List<HumanComment> comments = new ArrayList<>();
+  private final ExperimentFeatures experimentFeatures;
 
   private String commitSubject;
   private String subject;
   private String changeId;
   private String branch;
   private Change.Status status;
-  private List<SubmitRecord> submitRecords;
+  private ImmutableList<SubmitRecord> submitRecords;
   private String submissionId;
   private String topic;
   private String commit;
@@ -209,6 +218,7 @@
       ProjectCache projectCache,
       ServiceUserClassifier serviceUserClassifier,
       PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator,
+      ExperimentFeatures experimentFeatures,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user,
       @Assisted Instant when,
@@ -221,6 +231,7 @@
         deleteCommentRewriterFactory,
         serviceUserClassifier,
         patchSetApprovalUuidGenerator,
+        experimentFeatures,
         notes,
         user,
         when,
@@ -246,6 +257,7 @@
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ServiceUserClassifier serviceUserClassifier,
       PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator,
+      ExperimentFeatures experimentFeatures,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user,
       @Assisted Instant when,
@@ -258,10 +270,12 @@
     this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
     this.serviceUserClassifier = serviceUserClassifier;
     this.patchSetApprovalUuidGenerator = patchSetApprovalUuidGenerator;
+    this.experimentFeatures = experimentFeatures;
     this.approvals = approvals(labelNameComparator);
     this.user = user;
   }
 
+  @CanIgnoreReturnValue
   public ObjectId commit() throws IOException {
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       try (NoteDbUpdateManager updateManager =
@@ -423,6 +437,7 @@
   }
 
   @VisibleForTesting
+  @CanIgnoreReturnValue
   ChangeDraftUpdate createDraftUpdateIfNull() {
     if (draftUpdate == null) {
       ChangeNotes notes = getNotes();
@@ -526,13 +541,9 @@
       plannedAttentionSetUpdates = new HashMap<>();
     }
 
-    Set<Account.Id> currentAccountUpdates =
-        plannedAttentionSetUpdates.values().stream()
-            .map(AttentionSetUpdate::account)
-            .collect(Collectors.toSet());
-    updates.stream()
-        .filter(u -> !currentAccountUpdates.contains(u.account()))
-        .forEach(u -> plannedAttentionSetUpdates.putIfAbsent(u.account(), u));
+    // Only add attention set updates for users for which no attention set update has been planned
+    // yet.
+    updates.stream().forEach(u -> plannedAttentionSetUpdates.putIfAbsent(u.account(), u));
   }
 
   public void addToPlannedAttentionSetUpdates(AttentionSetUpdate update) {
@@ -606,6 +617,10 @@
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
     for (HumanComment c : comments) {
       c.tag = tag;
+      if (!experimentFeatures.isFeatureEnabled(
+          ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS)) {
+        checkState(c.fixSuggestions == null, "feature flag prohibits setting fixSuggestions");
+      }
       cache.get(c.getCommitId()).putComment(c);
     }
     if (submitRequirementResults != null) {
@@ -899,7 +914,10 @@
     try {
       ObjectId treeId = storeRevisionNotes(rw, ins, curr);
       if (treeId != null) {
+        logger.atFine().log("change meta tree ID: %s (inserter: %s)", treeId.name(), ins);
         cb.setTreeId(treeId);
+      } else {
+        logger.atFine().log("no revision notes to write, hence no change meta tree was created");
       }
     } catch (ConfigInvalidException e) {
       throw new StorageException(e);
@@ -997,7 +1015,7 @@
     }
 
     Set<AttentionSetUpdate> updates = new HashSet<>();
-    Set<Account.Id> currentReviewers =
+    ImmutableSet<Account.Id> currentReviewers =
         getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER);
     for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) {
       Account.Id reviewerId = reviewer.getKey();
@@ -1049,10 +1067,9 @@
     if (plannedAttentionSetUpdates == null) {
       plannedAttentionSetUpdates = new HashMap<>();
     }
-    Set<Account.Id> currentUsersInAttentionSet =
+    ImmutableMap<Account.Id, String> reasonsForCurrentUsersInAttentionSet =
         AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
-            .map(AttentionSetUpdate::account)
-            .collect(Collectors.toSet());
+            .collect(toImmutableMap(AttentionSetUpdate::account, AttentionSetUpdate::reason));
 
     // Current reviewers/ccs are the reviewers/ccs before the update + the new reviewers/ccs - the
     // deleted reviewers/ccs.
@@ -1075,13 +1092,17 @@
 
     for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
       if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
-          && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
-        // Skip users that are already in the attention set: no need to re-add them.
+          && reasonsForCurrentUsersInAttentionSet.get(attentionSetUpdate.account()) != null
+          && reasonsForCurrentUsersInAttentionSet
+              .get(attentionSetUpdate.account())
+              .equals(attentionSetUpdate.reason())) {
+        // Skip users that are already in the attention set with the same reason: no need to re-add
+        // them.
         continue;
       }
 
       if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.REMOVE
-          && !currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
+          && !reasonsForCurrentUsersInAttentionSet.containsKey(attentionSetUpdate.account())) {
         // Skip users that are not in the attention set: no need to remove them.
         continue;
       }
@@ -1114,7 +1135,7 @@
   }
 
   private void removeInactiveUsersFromAttentionSet(Set<Account.Id> currentReviewers) {
-    Set<Account.Id> inActiveUsersInTheAttentionSet =
+    ImmutableSet<Account.Id> inActiveUsersInTheAttentionSet =
         // get the current attention set.
         getNotes().getAttentionSet().stream()
             .filter(a -> a.operation().equals(Operation.ADD))
@@ -1242,6 +1263,7 @@
     this.workInProgress = workInProgress;
   }
 
+  @CanIgnoreReturnValue
   private static StringBuilder addFooter(StringBuilder sb, FooterKey footer) {
     return sb.append(footer.getName()).append(": ");
   }
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index 270fc32..ff5d85f 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -32,6 +32,7 @@
 import com.google.common.collect.ImmutableSet;
 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.common.UsedAt;
 import com.google.gerrit.entities.Account;
@@ -1074,6 +1075,7 @@
     return Optional.of(fixedCommitBuilder.toString());
   }
 
+  @CanIgnoreReturnValue
   private static StringBuilder addFooter(StringBuilder sb, String footer, String value) {
     if (value == null) {
       return sb;
@@ -1184,7 +1186,7 @@
             .collect(
                 ImmutableMap.toImmutableMap(
                     Map.Entry::getKey, e -> Optional.ofNullable(e.getValue()))));
-    Map<Account.Id, AccountState> possibleReplacements = ImmutableMap.of();
+    ImmutableMap<Account.Id, AccountState> possibleReplacements = ImmutableMap.of();
     if (accountInfo.email().isPresent()) {
       possibleReplacements =
           changeFixProgress.parsedAccounts.entrySet().stream()
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 7d00d2c..4c7e268 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
@@ -256,7 +257,7 @@
 
   private void deleteZombieDraftsBatch(Collection<Ref> refsBatch) throws IOException {
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-      List<ReceiveCommand> deleteCommands =
+      ImmutableList<ReceiveCommand> deleteCommands =
           refsBatch.stream()
               .map(
                   zombieRef ->
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java b/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
index 27c59f9..ea3dd0a 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.query.change.ChangeNumberVirtualIdAlgorithm;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -43,15 +44,18 @@
   private final DraftCommentNotes.Factory draftCommentNotesFactory;
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
+  private final ChangeNumberVirtualIdAlgorithm virtualIdAlgorithm;
 
   @Inject
   DraftCommentsNotesReader(
       DraftCommentNotes.Factory draftCommentNotesFactory,
       GitRepositoryManager repoManager,
-      AllUsersName allUsers) {
+      AllUsersName allUsers,
+      ChangeNumberVirtualIdAlgorithm virtualIdAlgorithm) {
     this.draftCommentNotesFactory = draftCommentNotesFactory;
     this.repoManager = repoManager;
     this.allUsers = allUsers;
+    this.virtualIdAlgorithm = virtualIdAlgorithm;
   }
 
   @Override
@@ -64,7 +68,7 @@
 
   @Override
   public List<HumanComment> getDraftsByChangeAndDraftAuthor(ChangeNotes notes, Account.Id author) {
-    return sort(new ArrayList<>(notes.getDraftComments(author)));
+    return sort(new ArrayList<>(notes.getDraftComments(author, getVirtualId(notes))));
   }
 
   @Override
@@ -77,7 +81,7 @@
   public List<HumanComment> getDraftsByPatchSetAndDraftAuthor(
       ChangeNotes notes, PatchSet.Id psId, Account.Id author) {
     return sort(
-        notes.load().getDraftComments(author).stream()
+        notes.load().getDraftComments(author, getVirtualId(notes)).stream()
             .filter(c -> c.key.patchSetId == psId.get())
             .collect(Collectors.toList()));
   }
@@ -136,7 +140,7 @@
   private List<Ref> getDraftRefs(ChangeNotes notes) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       return repo.getRefDatabase()
-          .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(notes.getChangeId()));
+          .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(getVirtualId(notes)));
     } catch (IOException e) {
       throw new StorageException(e);
     }
@@ -145,4 +149,10 @@
   private List<HumanComment> sort(List<HumanComment> comments) {
     return CommentsUtil.sort(comments);
   }
+
+  private Change.Id getVirtualId(ChangeNotes notes) {
+    return virtualIdAlgorithm == null
+        ? notes.getChangeId()
+        : virtualIdAlgorithm.apply(notes.getServerId(), notes.getChangeId());
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbDraftCommentsModule.java b/java/com/google/gerrit/server/notedb/NoteDbDraftCommentsModule.java
new file mode 100644
index 0000000..783fce0
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/NoteDbDraftCommentsModule.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.ChangeDraftUpdate;
+import com.google.gerrit.server.ChangeDraftUpdateExecutor;
+import com.google.gerrit.server.DraftCommentsReader;
+import com.google.inject.Singleton;
+
+public class NoteDbDraftCommentsModule extends FactoryModule {
+  @Override
+  public void configure() {
+    factory(ChangeDraftNotesUpdate.Factory.class);
+    factory(ChangeDraftNotesUpdate.Executor.Factory.class);
+    factory(DraftCommentNotes.Factory.class);
+
+    bind(DraftCommentsReader.class).to(DraftCommentsNotesReader.class).in(Singleton.class);
+    bind(ChangeDraftUpdate.ChangeDraftUpdateFactory.class).to(ChangeDraftNotesUpdate.Factory.class);
+    bind(ChangeDraftUpdateExecutor.AbstractFactory.class)
+        .to(ChangeDraftNotesUpdate.Executor.Factory.class)
+        .in(Singleton.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbModule.java b/java/com/google/gerrit/server/notedb/NoteDbModule.java
index 31428cd..cf3fad6 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbModule.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbModule.java
@@ -17,11 +17,6 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.DraftCommentsReader;
-import com.google.gerrit.server.StarredChangesReader;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesWriter;
-import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Names;
 
@@ -42,17 +37,11 @@
 
   @Override
   public void configure() {
-    factory(ChangeDraftNotesUpdate.Factory.class);
     factory(ChangeUpdate.Factory.class);
     factory(DeleteCommentRewriter.Factory.class);
-    factory(DraftCommentNotes.Factory.class);
     factory(NoteDbUpdateManager.Factory.class);
     factory(RobotCommentNotes.Factory.class);
     factory(RobotCommentUpdate.Factory.class);
-    bind(StarredChangesReader.class).to(StarredChangesUtilNoteDbImpl.class).in(Singleton.class);
-    bind(StarredChangesWriter.class).to(StarredChangesUtilNoteDbImpl.class).in(Singleton.class);
-    bind(StarredChangesUtil.class).to(StarredChangesUtilNoteDbImpl.class).in(Singleton.class);
-    bind(DraftCommentsReader.class).to(DraftCommentsNotesReader.class).in(Singleton.class);
 
     if (!useTestBindings) {
       install(ChangeNotesCache.module());
diff --git a/java/com/google/gerrit/server/notedb/NoteDbStarredChangesModule.java b/java/com/google/gerrit/server/notedb/NoteDbStarredChangesModule.java
new file mode 100644
index 0000000..733c3cf
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/NoteDbStarredChangesModule.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.StarredChangesReader;
+import com.google.gerrit.server.StarredChangesWriter;
+import com.google.inject.Singleton;
+
+public class NoteDbStarredChangesModule extends FactoryModule {
+  @Override
+  public void configure() {
+    bind(StarredChangesReader.class).to(StarredChangesUtilNoteDbImpl.class).in(Singleton.class);
+    bind(StarredChangesWriter.class).to(StarredChangesUtilNoteDbImpl.class).in(Singleton.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateExecutor.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateExecutor.java
new file mode 100644
index 0000000..c04e184
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateExecutor.java
@@ -0,0 +1,94 @@
+// 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.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.update.BatchUpdateListener;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.transport.PushCertificate;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** Utility class for executing commands on a given repository. */
+class NoteDbUpdateExecutor {
+  private final Provider<PersonIdent> serverIdent;
+
+  @Inject
+  NoteDbUpdateExecutor(@GerritPersonIdent Provider<PersonIdent> serverIdent) {
+    this.serverIdent = serverIdent;
+  }
+
+  Optional<BatchRefUpdate> execute(
+      OpenRepo or,
+      boolean dryrun,
+      boolean maybeAllowNonFastForwards,
+      ImmutableList<BatchUpdateListener> batchUpdateListeners,
+      @Nullable PushCertificate pushCert,
+      @Nullable PersonIdent refLogIdent,
+      @Nullable String refLogMessage)
+      throws IOException {
+    if (or == null || or.cmds.isEmpty()) {
+      return Optional.empty();
+    }
+    if (!dryrun) {
+      or.flush();
+    } else {
+      // OpenRepo buffers objects separately; caller may assume that objects are available in the
+      // inserter it previously passed via setChangeRepo.
+      or.flushToFinalInserter();
+    }
+
+    BatchRefUpdate bru = or.repo.getRefDatabase().newBatchUpdate();
+    bru.setPushCertificate(pushCert);
+    if (refLogMessage != null) {
+      bru.setRefLogMessage(refLogMessage, false);
+    } else {
+      bru.setRefLogMessage(
+          firstNonNull(NoteDbUtil.guessRestApiHandler(), "Update NoteDb refs"), false);
+    }
+    bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
+    bru.setAtomic(true);
+    or.cmds.addTo(bru);
+    bru.setAllowNonFastForwards(maybeAllowNonFastForwards || allowNonFastForwards(or.cmds));
+    for (BatchUpdateListener listener : batchUpdateListeners) {
+      bru = listener.beforeUpdateRefs(bru);
+    }
+
+    if (!dryrun) {
+      RefUpdateUtil.executeChecked(bru, or.rw);
+    }
+    return Optional.of(bru);
+  }
+
+  /**
+   * Allow non-fast-forwards if any of the receive commands is of type {@link
+   * org.eclipse.jgit.transport.ReceiveCommand.Type#UPDATE_NONFASTFORWARD} (for example due to a
+   * force push).
+   */
+  private boolean allowNonFastForwards(ChainedReceiveCommands receiveCommands) {
+    return receiveCommands.getCommands().values().stream()
+        .anyMatch(cmd -> cmd.getType().equals(ReceiveCommand.Type.UPDATE_NONFASTFORWARD));
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 7b2148a..4fce425 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.notedb;
 
-import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
@@ -26,6 +25,7 @@
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
@@ -33,10 +33,10 @@
 import com.google.gerrit.entities.ProjectChangeKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.server.ChangeDraftUpdate;
+import com.google.gerrit.server.ChangeDraftUpdateExecutor;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.cancellation.RequestStateContext;
 import com.google.gerrit.server.cancellation.RequestStateContext.NonCancellableOperationContext;
 import com.google.gerrit.server.config.AllUsersName;
@@ -47,7 +47,6 @@
 import com.google.gerrit.server.update.BatchUpdateListener;
 import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.Collection;
@@ -61,7 +60,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushCertificate;
@@ -85,7 +83,6 @@
     NoteDbUpdateManager create(Project.NameKey projectName, CurrentUser currentUser);
   }
 
-  private final Provider<PersonIdent> serverIdent;
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final NoteDbMetrics metrics;
@@ -94,36 +91,37 @@
   private final int maxPatchSets;
   private final CurrentUser currentUser;
   private final ListMultimap<String, ChangeUpdate> changeUpdates;
-  private final ListMultimap<String, ChangeDraftNotesUpdate> draftUpdates;
+  private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
+  private final NoteDbUpdateExecutor noteDbUpdateExecutor;
+  private final ChangeDraftUpdateExecutor.AbstractFactory draftUpdatesExecutorFactory;
   private final ListMultimap<String, RobotCommentUpdate> robotCommentUpdates;
   private final ListMultimap<String, NoteDbRewriter> rewriters;
   private final Set<Change.Id> changesToDelete;
 
   private OpenRepo changeRepo;
-  private OpenRepo allUsersRepo;
-  private AllUsersAsyncUpdate updateAllUsersAsync;
   private boolean executed;
   private String refLogMessage;
   private PersonIdent refLogIdent;
   private PushCertificate pushCert;
   private ImmutableList<BatchUpdateListener> batchUpdateListeners;
+  private ChangeDraftUpdateExecutor draftUpdatesExecutor;
 
   @Inject
   NoteDbUpdateManager(
       @GerritServerConfig Config cfg,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
       NoteDbMetrics metrics,
-      AllUsersAsyncUpdate updateAllUsersAsync,
       @Assisted Project.NameKey projectName,
+      NoteDbUpdateExecutor noteDbUpdateExecutor,
+      ChangeDraftUpdateExecutor.AbstractFactory draftUpdatesExecutorFactory,
       @Assisted CurrentUser currentUser) {
-    this.serverIdent = serverIdent;
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.metrics = metrics;
-    this.updateAllUsersAsync = updateAllUsersAsync;
     this.projectName = projectName;
+    this.noteDbUpdateExecutor = noteDbUpdateExecutor;
+    this.draftUpdatesExecutorFactory = draftUpdatesExecutorFactory;
     maxUpdates = cfg.getInt("change", null, "maxUpdates", MAX_UPDATES_DEFAULT);
     maxPatchSets = cfg.getInt("change", null, "maxPatchSets", MAX_PATCH_SETS_DEFAULT);
     this.currentUser = currentUser;
@@ -137,21 +135,14 @@
 
   @Override
   public void close() {
-    try {
-      if (allUsersRepo != null) {
-        OpenRepo r = allUsersRepo;
-        allUsersRepo = null;
-        r.close();
-      }
-    } finally {
-      if (changeRepo != null) {
-        OpenRepo r = changeRepo;
-        changeRepo = null;
-        r.close();
-      }
+    if (changeRepo != null) {
+      OpenRepo r = changeRepo;
+      changeRepo = null;
+      r.close();
     }
   }
 
+  @CanIgnoreReturnValue
   public NoteDbUpdateManager setChangeRepo(
       Repository repo, RevWalk rw, @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
     checkState(changeRepo == null, "change repo already initialized");
@@ -159,11 +150,13 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public NoteDbUpdateManager setRefLogMessage(String message) {
     this.refLogMessage = message;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public NoteDbUpdateManager setRefLogIdent(PersonIdent ident) {
     this.refLogIdent = ident;
     return this;
@@ -183,11 +176,13 @@
    * @param pushCert push certificate; may be null.
    * @return this
    */
+  @CanIgnoreReturnValue
   public NoteDbUpdateManager setPushCertificate(PushCertificate pushCert) {
     this.pushCert = pushCert;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public NoteDbUpdateManager setBatchUpdateListeners(
       ImmutableList<BatchUpdateListener> batchUpdateListeners) {
     checkNotNull(batchUpdateListeners);
@@ -205,12 +200,6 @@
     }
   }
 
-  private void initAllUsersRepo() throws IOException {
-    if (allUsersRepo == null) {
-      allUsersRepo = OpenRepo.open(repoManager, allUsersName);
-    }
-  }
-
   private boolean isEmpty() {
     return changeUpdates.isEmpty()
         && draftUpdates.isEmpty()
@@ -218,8 +207,7 @@
         && rewriters.isEmpty()
         && changesToDelete.isEmpty()
         && !hasCommands(changeRepo)
-        && !hasCommands(allUsersRepo)
-        && updateAllUsersAsync.isEmpty();
+        && (draftUpdatesExecutor == null || draftUpdatesExecutor.isEmpty());
   }
 
   private static boolean hasCommands(@Nullable OpenRepo or) {
@@ -246,10 +234,9 @@
         "cannot update & rewrite ref %s in one BatchUpdate",
         update.getRefName());
 
-    Optional<ChangeDraftNotesUpdate> du =
-        ChangeDraftNotesUpdate.asChangeDraftNotesUpdate(update.getDraftUpdate());
-    if (du.isPresent()) {
-      draftUpdates.put(du.get().getRefName(), du.get());
+    ChangeDraftUpdate du = update.getDraftUpdate();
+    if (du != null) {
+      draftUpdates.put(du.getStorageKey(), du);
     }
     RobotCommentUpdate rcu = update.getRobotCommentUpdate();
     if (rcu != null) {
@@ -287,9 +274,9 @@
     changeUpdates.put(update.getRefName(), update);
   }
 
-  public void add(ChangeDraftNotesUpdate draftUpdate) {
+  public void add(ChangeDraftUpdate draftUpdate) {
     checkNotExecuted();
-    draftUpdates.put(draftUpdate.getRefName(), draftUpdate);
+    draftUpdates.put(draftUpdate.getStorageKey(), draftUpdate);
   }
 
   public void deleteChange(Change.Id id) {
@@ -310,16 +297,18 @@
 
       initChangeRepo();
       if (!draftUpdates.isEmpty() || !changesToDelete.isEmpty()) {
-        initAllUsersRepo();
+        draftUpdatesExecutor = draftUpdatesExecutorFactory.create(currentUser);
       }
       addCommands();
     }
   }
 
+  @CanIgnoreReturnValue
   public ImmutableMultimap<Project.NameKey, BatchRefUpdate> execute() throws IOException {
     return execute(false);
   }
 
+  @CanIgnoreReturnValue
   public ImmutableMultimap<Project.NameKey, BatchRefUpdate> execute(boolean dryrun)
       throws IOException {
     checkNotExecuted();
@@ -333,7 +322,7 @@
         NonCancellableOperationContext nonCancellableOperationContext =
             RequestStateContext.startNonCancellableOperation()) {
       stage();
-      // ChangeUpdates must execute before ChangeDraftNotesUpdates.
+      // ChangeUpdates must execute before ChangeDraftUpdates.
       //
       // ChangeUpdate will automatically delete draft comments for any published
       // comments, but the updates to the two repos don't happen atomically.
@@ -345,16 +334,18 @@
           newTimer("NoteDbUpdateManager#updateRepo", Metadata.empty())) {
         execute(changeRepo, dryrun, pushCert).ifPresent(bru -> resultBuilder.put(projectName, bru));
       }
-      try (TraceContext.TraceTimer ignored =
-          newTimer("NoteDbUpdateManager#updateAllUsersSync", Metadata.empty())) {
-        execute(allUsersRepo, dryrun, null).ifPresent(bru -> resultBuilder.put(allUsersName, bru));
-      }
-      if (!dryrun) {
-        // Only execute the asynchronous operation if we are not in dry-run mode: The dry run would
-        // have to run synchronous to be of any value at all. For the removal of draft comments from
-        // All-Users we don't care much of the operation succeeds, so we are skipping the dry run
-        // altogether.
-        updateAllUsersAsync.execute(refLogIdent, refLogMessage, pushCert, currentUser);
+
+      if (draftUpdatesExecutor != null) {
+        draftUpdatesExecutor
+            .executeAllSyncUpdates(dryrun, refLogIdent, refLogMessage)
+            .ifPresent(bru -> resultBuilder.put(allUsersName, bru));
+        if (!dryrun) {
+          // Only execute the asynchronous operation if we are not in dry-run mode: The dry run
+          // would have to run synchronous to be of any value at all. For the removal of draft
+          // comments from All-Users we don't care much of the operation succeeds, so we are
+          // skipping the dry run altogether.
+          draftUpdatesExecutor.executeAllAsyncUpdates(refLogIdent, refLogMessage, pushCert);
+        }
       }
       executed = true;
       return resultBuilder.build();
@@ -373,49 +364,20 @@
 
   private Optional<BatchRefUpdate> execute(
       OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert) throws IOException {
-    if (or == null || or.cmds.isEmpty()) {
-      return Optional.empty();
-    }
-    if (!dryrun) {
-      or.flush();
-    } else {
-      // OpenRepo buffers objects separately; caller may assume that objects are available in the
-      // inserter it previously passed via setChangeRepo.
-      or.flushToFinalInserter();
-    }
-
-    BatchRefUpdate bru = or.repo.getRefDatabase().newBatchUpdate();
-    bru.setPushCertificate(pushCert);
-    if (refLogMessage != null) {
-      bru.setRefLogMessage(refLogMessage, false);
-    } else {
-      bru.setRefLogMessage(
-          firstNonNull(NoteDbUtil.guessRestApiHandler(), "Update NoteDb refs"), false);
-    }
-    bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
-    bru.setAtomic(true);
-    or.cmds.addTo(bru);
-    bru.setAllowNonFastForwards(allowNonFastForwards(or.cmds));
-    for (BatchUpdateListener listener : batchUpdateListeners) {
-      bru = listener.beforeUpdateRefs(bru);
-    }
-
-    if (!dryrun) {
-      RefUpdateUtil.executeChecked(bru, or.rw);
-    }
-    return Optional.of(bru);
+    return noteDbUpdateExecutor.execute(
+        or,
+        dryrun,
+        allowNonFastForwards(),
+        batchUpdateListeners,
+        pushCert,
+        refLogIdent,
+        refLogMessage);
   }
 
   private void addCommands() throws IOException {
     changeRepo.addUpdates(changeUpdates, Optional.of(maxUpdates), Optional.of(maxPatchSets));
     if (!draftUpdates.isEmpty()) {
-      boolean publishOnly =
-          draftUpdates.values().stream().allMatch(ChangeDraftNotesUpdate::canRunAsync);
-      if (publishOnly) {
-        updateAllUsersAsync.setDraftUpdates(draftUpdates);
-      } else {
-        allUsersRepo.addUpdatesNoLimits(draftUpdates);
-      }
+      draftUpdatesExecutor.queueAllDraftUpdates(draftUpdates);
     }
     if (!robotCommentUpdates.isEmpty()) {
       changeRepo.addUpdatesNoLimits(robotCommentUpdates);
@@ -435,14 +397,7 @@
     old.ifPresent(
         objectId -> changeRepo.cmds.add(new ReceiveCommand(objectId, ObjectId.zeroId(), metaRef)));
 
-    // Just scan repo for ref names, but get "old" values from cmds.
-    for (Ref r :
-        allUsersRepo.repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(id))) {
-      old = allUsersRepo.cmds.get(r.getName());
-      old.ifPresent(
-          objectId ->
-              allUsersRepo.cmds.add(new ReceiveCommand(objectId, ObjectId.zeroId(), r.getName())));
-    }
+    draftUpdatesExecutor.queueDeletionForChangeDrafts(id);
   }
 
   private void checkNotExecuted() {
@@ -487,17 +442,10 @@
    *
    * <p>2. NoteDb rewriters.
    *
-   * <p>3. If any of the receive commands is of type {@link
-   * org.eclipse.jgit.transport.ReceiveCommand.Type#UPDATE_NONFASTFORWARD} (for example due to a
-   * force push).
-   *
    * <p>Note that we don't need to explicitly allow non fast-forward updates for DELETE commands
    * since JGit forces the update implicitly in this case.
    */
-  private boolean allowNonFastForwards(ChainedReceiveCommands receiveCommands) {
-    return !draftUpdates.isEmpty()
-        || !rewriters.isEmpty()
-        || receiveCommands.getCommands().values().stream()
-            .anyMatch(cmd -> cmd.getType().equals(ReceiveCommand.Type.UPDATE_NONFASTFORWARD));
+  private boolean allowNonFastForwards() {
+    return !draftUpdates.isEmpty() || !rewriters.isEmpty();
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/OpenRepo.java b/java/com/google/gerrit/server/notedb/OpenRepo.java
index d02ec87..8bef164 100644
--- a/java/com/google/gerrit/server/notedb/OpenRepo.java
+++ b/java/com/google/gerrit/server/notedb/OpenRepo.java
@@ -19,6 +19,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ListMultimap;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -31,6 +32,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -43,6 +45,8 @@
  * objects that are jointly closed when invoking {@link #close}.
  */
 class OpenRepo implements AutoCloseable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   final Repository repo;
   final RevWalk rw;
   final ChainedReceiveCommands cmds;
@@ -106,12 +110,16 @@
 
   void flush() throws IOException {
     flushToFinalInserter();
+    logger.atFine().log("flushing inserter %s", finalIns);
     finalIns.flush();
   }
 
   void flushToFinalInserter() throws IOException {
     checkState(finalIns != null);
     for (InsertedObject obj : inMemIns.getInsertedObjects()) {
+      logger.atFine().log(
+          "copying %s object %s to final inserter %s",
+          Constants.typeString(obj.type()), obj.id().name(), finalIns);
       finalIns.insert(obj.type(), obj.data().toByteArray());
     }
     inMemIns.clear();
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index bf2795d..033a53f 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -404,11 +404,6 @@
   }
 
   @Override
-  public int getBatchSize() {
-    return batchSize;
-  }
-
-  @Override
   public int current() {
     counterLock.lock();
     try (Repository repo = repoManager.openRepository(projectName);
@@ -436,7 +431,8 @@
   @Override
   public int last() {
     if (counter == 0) {
-      next();
+      @SuppressWarnings("unused")
+      var unused = next();
     }
     return counter - 1;
   }
diff --git a/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java b/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
index d5258b1..f13b832 100644
--- a/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
+++ b/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
@@ -19,7 +19,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
@@ -31,7 +31,8 @@
 import com.google.gerrit.git.GitUpdateFailureException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesReader;
+import com.google.gerrit.server.StarredChangesWriter;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -62,7 +63,7 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 @Singleton
-public class StarredChangesUtilNoteDbImpl implements StarredChangesUtil {
+public class StarredChangesUtilNoteDbImpl implements StarredChangesReader, StarredChangesWriter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private static final String DEFAULT_STAR_LABEL = "star";
 
@@ -176,13 +177,13 @@
   }
 
   @Override
-  public ImmutableMap<Account.Id, Ref> byChange(Change.Id virtualId) {
+  public ImmutableList<Account.Id> byChange(Change.Id virtualId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      ImmutableMap.Builder<Account.Id, Ref> builder = ImmutableMap.builder();
+      ImmutableList.Builder<Account.Id> builder = ImmutableList.builder();
       for (Account.Id accountId : getStars(repo, virtualId)) {
         Optional<Ref> starRef = getStarRef(repo, RefNames.refsStarredChanges(virtualId, accountId));
         if (starRef.isPresent()) {
-          builder.put(accountId, starRef.get());
+          builder.add(accountId);
         }
       }
       return builder.build();
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/notedb/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/notedb/package-info.java
index 0709b86..b9ba73d 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/notedb/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.notedb;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/package-info.java
similarity index 69%
rename from java/com/google/gerrit/server/StarredChangesUtil.java
rename to java/com/google/gerrit/server/package-info.java
index 0709b86..bcb24a1 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+@CheckReturnValue
 package com.google.gerrit.server;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index e27faf6..74f5886 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -42,7 +42,6 @@
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
@@ -137,15 +136,10 @@
    * @return auto-merge commit. Headers of the returned RevCommit are parsed.
    */
   public RevCommit lookupFromGitOrMergeInMemory(
-      Repository repo,
-      RevWalk rw,
-      InMemoryInserter ins,
-      RevCommit merge,
-      ThreeWayMergeStrategy mergeStrategy)
-      throws IOException {
+      Repository repo, RevWalk rw, InMemoryInserter ins, RevCommit merge) throws IOException {
     checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
     Optional<RevCommit> existingCommit =
-        lookupCommit(repo, rw, RefNames.refsCacheAutomerge(merge.name()));
+        lookupCommit(new RepoView(repo, rw, ins), RefNames.refsCacheAutomerge(merge.name()));
     if (existingCommit.isPresent()) {
       counter.increment(OperationType.CACHE_LOAD);
       return existingCommit.get();
@@ -153,7 +147,8 @@
     counter.increment(OperationType.IN_MEMORY_WRITE);
     logger.atInfo().log("Computing in-memory AutoMerge for %s", merge.name());
     try (Timer1.Context<OperationType> ignored = latency.start(OperationType.IN_MEMORY_WRITE)) {
-      return rw.parseCommit(createAutoMergeCommit(repo.getConfig(), rw, ins, merge, mergeStrategy));
+      return rw.parseCommit(
+          createAutoMergeCommit(repo.getConfig(), rw, ins, merge, configuredMergeStrategy));
     }
   }
 
@@ -168,8 +163,7 @@
    *     auto merge commit.
    */
   public Optional<ReceiveCommand> createAutoMergeCommitIfNecessary(
-      RepoView repoView, RevWalk rw, ObjectInserter ins, RevCommit maybeMergeCommit)
-      throws IOException {
+      RepoView repoView, ObjectInserter ins, RevCommit maybeMergeCommit) throws IOException {
     if (maybeMergeCommit.getParentCount() != 2) {
       logger.atFine().log("AutoMerge not required");
       return Optional.empty();
@@ -182,14 +176,14 @@
     String automergeRef = RefNames.refsCacheAutomerge(maybeMergeCommit.name());
     logger.atFine().log("AutoMerge ref=%s, mergeCommit=%s", automergeRef, maybeMergeCommit.name());
     if (repoView.getRef(automergeRef).isPresent()) {
-      logger.atFine().log("AutoMerge alredy exists");
+      logger.atFine().log("AutoMerge already exists");
       return Optional.empty();
     }
 
     return Optional.of(
         new ReceiveCommand(
             ObjectId.zeroId(),
-            createAutoMergeCommit(repoView, rw, ins, maybeMergeCommit),
+            createAutoMergeCommit(repoView, ins, maybeMergeCommit),
             automergeRef));
   }
 
@@ -200,23 +194,26 @@
    *
    * @return An auto-merge commit. Headers of the returned RevCommit are parsed.
    */
-  ObjectId createAutoMergeCommit(
-      RepoView repoView, RevWalk rw, ObjectInserter ins, RevCommit mergeCommit) throws IOException {
+  ObjectId createAutoMergeCommit(RepoView repoView, ObjectInserter ins, RevCommit mergeCommit)
+      throws IOException {
     ObjectId autoMerge;
     try (Timer1.Context<OperationType> ignored = latency.start(OperationType.ON_DISK_WRITE)) {
       autoMerge =
           createAutoMergeCommit(
-              repoView.getConfig(), rw, ins, mergeCommit, configuredMergeStrategy);
+              repoView.getConfig(),
+              repoView.getRevWalk(),
+              ins,
+              mergeCommit,
+              configuredMergeStrategy);
     }
     counter.increment(OperationType.ON_DISK_WRITE);
-    logger.atFine().log("Added %s AutoMerge ref update for commit", autoMerge.name());
     return autoMerge;
   }
 
-  Optional<RevCommit> lookupCommit(Repository repo, RevWalk rw, String refName) throws IOException {
-    Ref ref = repo.getRefDatabase().exactRef(refName);
-    if (ref != null && ref.getObjectId() != null) {
-      RevObject obj = rw.parseAny(ref.getObjectId());
+  Optional<RevCommit> lookupCommit(RepoView repoView, String refName) throws IOException {
+    Optional<ObjectId> commit = repoView.getRef(refName);
+    if (commit.isPresent()) {
+      RevObject obj = repoView.getRevWalk().parseAny(commit.get());
       if (obj instanceof RevCommit) {
         return Optional.of((RevCommit) obj);
       }
@@ -236,25 +233,50 @@
       RevCommit merge,
       ThreeWayMergeStrategy mergeStrategy)
       throws IOException {
+    // Use a non-flushing inserter to do the merging and do the flushing explicitly when we are done
+    // with creating the AutoMerge commit.
+    ObjectInserter nonFlushingInserter =
+        ins instanceof InMemoryInserter ? ins : new NonFlushingWrapper(ins);
+
     rw.parseHeaders(merge);
-    ResolveMerger m = (ResolveMerger) mergeStrategy.newMerger(ins, repoConfig);
+    ResolveMerger m = (ResolveMerger) mergeStrategy.newMerger(nonFlushingInserter, repoConfig);
     DirCache dc = DirCache.newInCore();
     m.setDirCache(dc);
-    // If we don't plan on saving results, use a fully in-memory inserter.
-    // Using just a non-flushing wrapper is not sufficient, since in particular DfsInserter might
-    // try to write to storage after exceeding an internal buffer size.
-    m.setObjectInserter(ins instanceof InMemoryInserter ? new NonFlushingWrapper(ins) : ins);
 
     boolean couldMerge = m.merge(merge.getParents());
 
     ObjectId treeId;
     if (couldMerge) {
       treeId = m.getResultTreeId();
+      logger.atFine().log(
+          "AutoMerge treeId=%s (no conflicts, inserter: %s)", treeId.name(), m.getObjectInserter());
     } else {
+      if (m.getResultTreeId() != null) {
+        // Merging with conflicts below uses the same DirCache instance that has been used by the
+        // Merger to attempt the merge without conflicts.
+        //
+        // The Merger uses the DirCache to do the updates, and in particular to write the result
+        // tree. DirCache caches a single DirCacheTree instance that is used to write the result
+        // tree, but it writes the result tree only if there were no conflicts.
+        //
+        // Merging with conflicts uses the same DirCache instance to write the tree with conflicts
+        // that has been used by the Merger. This means if the Merger unexpectedly wrote a result
+        // tree although there had been conflicts, then merging with conflicts uses the same
+        // DirCacheTree instance to write the tree with conflicts. However DirCacheTree#writeTree
+        // writes a tree only once and then that tree is cached. Further invocations of
+        // DirCacheTree#writeTree have no effect and return the previously created tree. This means
+        // merging with conflicts can only successfully create the tree with conflicts if the Merger
+        // didn't write a result tree yet. Hence this is checked here and we log a warning if the
+        // result tree was already written.
+        logger.atWarning().log(
+            "result tree has already been written: %s (merge: %s, conflicts: %s, failed: %s)",
+            m, m.getResultTreeId().name(), m.getUnmergedPaths(), m.getFailingPaths());
+      }
+
       treeId =
           MergeUtil.mergeWithConflicts(
               rw,
-              ins,
+              nonFlushingInserter,
               dc,
               "HEAD",
               merge.getParent(0),
@@ -262,8 +284,9 @@
               merge.getParent(1),
               m.getMergeResults(),
               useDiff3);
+      logger.atFine().log(
+          "AutoMerge treeId=%s (with conflicts, inserter: %s)", treeId.name(), nonFlushingInserter);
     }
-    logger.atFine().log("AutoMerge treeId=%s", treeId.name());
 
     rw.parseHeaders(merge);
     // For maximum stability, choose a single ident using the committer time of
@@ -284,7 +307,6 @@
 
     ObjectId commitId = ins.insert(cb);
     logger.atFine().log("AutoMerge commitId=%s", commitId.name());
-    ins.flush();
 
     if (ins instanceof InMemoryInserter) {
       // When using an InMemoryInserter we need to read back the values from that inserter because
@@ -295,6 +317,8 @@
       }
     }
 
+    logger.atFine().log("flushing inserter %s", ins);
+    ins.flush();
     return rw.parseCommit(commitId);
   }
 
@@ -315,5 +339,10 @@
 
     @Override
     public void close() {}
+
+    @Override
+    public String toString() {
+      return String.format("%s (wrapped inserter: %s)", super.toString(), ins.toString());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
index a264793..f408038 100644
--- a/java/com/google/gerrit/server/patch/BaseCommitUtil.java
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.patch;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -36,6 +37,8 @@
 /** A utility class for computing the base commit / parent for a specific patchset commit. */
 @Singleton
 class BaseCommitUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final AutoMerger autoMerger;
   private final GitRepositoryManager repoManager;
 
@@ -49,17 +52,6 @@
     this.repoManager = repoManager;
   }
 
-  @Nullable
-  RevCommit getBaseCommit(Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
-      throws IOException {
-    try (Repository repo = repoManager.openRepository(project);
-        ObjectInserter ins = newInserter(repo);
-        ObjectReader reader = ins.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-      return getParentCommit(repo, ins, rw, parentNum, newCommit);
-    }
-  }
-
   /**
    * Returns the number of parent commits of the commit represented by the commitId parameter.
    *
@@ -78,29 +70,24 @@
   }
 
   /**
-   * Returns the parent commit Object of the commit represented by the commitId parameter.
+   * Returns the base commit for the provided commit.
    *
-   * @param repo a git repository.
+   * @param repoView repo view
    * @param ins a git object inserter in the database.
-   * @param rw a {@link RevWalk} object of the repository.
+   * @param commitId 20 bytes commitId SHA-1 hash.
    * @param parentNum used to identify the parent number for merge commits. If parentNum is null and
    *     {@code commitId} has two parents, the auto-merge commit will be returned. If {@code
    *     commitId} has a single parent, it will be returned.
-   * @param commitId 20 bytes commitId SHA-1 hash.
    * @return Returns the parent commit of the commit represented by the commitId parameter. Note
    *     that auto-merge is not supported for commits having more than two parents. If the commit
    *     has no parents (initial commit) or more than 2 parents {@code null} is returned as the
    *     parent commit.
    */
   @Nullable
-  RevCommit getParentCommit(
-      Repository repo,
-      ObjectInserter ins,
-      RevWalk rw,
-      @Nullable Integer parentNum,
-      ObjectId commitId)
+  RevCommit getBaseCommit(
+      RepoView repoView, ObjectInserter ins, ObjectId commitId, @Nullable Integer parentNum)
       throws IOException {
-    RevCommit current = rw.parseCommit(commitId);
+    RevCommit current = repoView.getRevWalk().parseCommit(commitId);
     switch (current.getParentCount()) {
       case 0:
         return null;
@@ -109,7 +96,7 @@
       default:
         if (parentNum != null) {
           RevCommit r = current.getParent(parentNum - 1);
-          rw.parseBody(r);
+          repoView.getRevWalk().parseBody(r);
           return r;
         }
         // Only support auto-merge for 2 parents, not octopus merges
@@ -119,7 +106,7 @@
                 "diff against auto-merge commits is only supported if 'change.cacheAutomerge' config is set to true.");
           }
           // TODO(ghareeb): Avoid persisting auto-merge commits.
-          return getAutoMergeFromGitOrCreate(repo, ins, rw, current);
+          return getAutoMergeFromGitOrCreate(repoView, ins, current);
         }
         return null;
     }
@@ -132,19 +119,18 @@
    * @return the auto-merge {@link RevCommit}
    */
   private RevCommit getAutoMergeFromGitOrCreate(
-      Repository repo, ObjectInserter ins, RevWalk rw, RevCommit mergeCommit) throws IOException {
+      RepoView repoView, ObjectInserter ins, RevCommit mergeCommit) throws IOException {
     String refName = RefNames.refsCacheAutomerge(mergeCommit.name());
-    Optional<RevCommit> autoMergeCommit = autoMerger.lookupCommit(repo, rw, refName);
+    Optional<RevCommit> autoMergeCommit = autoMerger.lookupCommit(repoView, refName);
     if (autoMergeCommit.isPresent()) {
       return autoMergeCommit.get();
     }
-    ObjectId autoMergeId =
-        autoMerger.createAutoMergeCommit(new RepoView(repo, rw, ins), rw, ins, mergeCommit);
+    if (!saveAutomerge && !(ins instanceof InMemoryInserter)) {
+      ins = new InMemoryInserter(repoView.getRevWalk().getObjectReader());
+    }
+    ObjectId autoMergeId = autoMerger.createAutoMergeCommit(repoView, ins, mergeCommit);
+    logger.atFine().log("flushing inserter %s", ins);
     ins.flush();
-    return rw.parseCommit(autoMergeId);
-  }
-
-  private ObjectInserter newInserter(Repository repo) {
-    return saveAutomerge ? repo.newObjectInserter() : new InMemoryInserter(repo);
+    return repoView.getRevWalk().parseCommit(autoMergeId);
   }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index a53660a..0629940 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -20,11 +20,14 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.gerrit.server.update.RepoView;
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -36,6 +39,12 @@
  *   <li>The detailed file diff for a single file path.
  *   <li>The Intra-line diffs for a single file path (TODO:ghareeb).
  * </ul>
+ *
+ * <p>Do not use this class from commit validators (classes that implement {@link
+ * com.google.gerrit.server.git.validators.CommitValidationListener}), but use {@link
+ * DiffOperationsForCommitValidation} that is provided in {@link
+ * com.google.gerrit.server.events.CommitReceivedEvent#diffOperations} instead (see javadoc of
+ * {@link DiffOperationsForCommitValidation} for the context).
  */
 public interface DiffOperations {
 
@@ -65,25 +74,27 @@
 
   /**
    * This method is similar to {@link #listModifiedFilesAgainstParent(NameKey, ObjectId, int,
-   * DiffOptions)} but loads the modified files directly instead of retrieving them from the diff
-   * cache.
+   * DiffOptions)} but it loads the modified files directly if the modified files are not cached yet
+   * (instead of loading them via the diff cache).
    *
-   * <p>A RevWalk and repoConfig are also supplied and are used to look up the commit IDs. This is
-   * useful in case one the commits is currently being created, that's why the {@code revWalk}
-   * parameter is needed.
+   * <p>Commits are looked up from the provided {@link RepoView}. This way this method can also read
+   * new commits which are being created by the current request.
    *
-   * <p>Note that rename detection is disabled for this method.
-   *
+   * @param repoView view to the repo from which commits IDs are looked up
+   * @param ins {@link ObjectInserter} to be used to create the auto-merge if the diff is done for a
+   *     merge commit against the auto-merge and the auto-merge ref doesn't exist yet. This may be
+   *     an {@link InMemoryInserter}.
+   * @param enableRenameDetection whether rename detection should be enabled
    * @return a map of file paths to {@link ModifiedFile}. The {@link ModifiedFile} contains the
    *     old/new file paths and the change type (added, deleted, etc...).
    */
-  Map<String, ModifiedFile> loadModifiedFilesAgainstParent(
+  Map<String, ModifiedFile> loadModifiedFilesAgainstParentIfNecessary(
       Project.NameKey project,
       ObjectId newCommit,
       int parentNum,
-      DiffOptions diffOptions,
-      RevWalk revWalk,
-      Config repoConfig)
+      RepoView repoView,
+      ObjectInserter ins,
+      boolean enableRenameDetection)
       throws DiffNotAvailableException;
 
   /**
@@ -105,25 +116,24 @@
 
   /**
    * This method is similar to {@link #listModifiedFilesAgainstParent(NameKey, ObjectId, int,
-   * DiffOptions)} but loads the modified files directly instead of retrieving them from the diff
-   * cache.
+   * DiffOptions)} but it loads the modified files directly if the modified files are not cached yet
+   * (instead of loading them via the diff cache).
    *
    * <p>A RevWalk and repoConfig are also supplied and are used to look up the commit IDs. This is
    * useful in case one the commits is currently being created, that's why the {@code revWalk}
    * parameter is needed.
    *
-   * <p>Note that rename detection is disabled for this method.
-   *
+   * @param enableRenameDetection whether rename detection should be enabled
    * @return a map of file paths to {@link ModifiedFile}. The {@link ModifiedFile} contains the
    *     old/new file paths and the change type (added, deleted, etc...).
    */
-  Map<String, ModifiedFile> loadModifiedFiles(
+  Map<String, ModifiedFile> loadModifiedFilesIfNecessary(
       Project.NameKey project,
       ObjectId oldCommit,
       ObjectId newCommit,
-      DiffOptions diffOptions,
       RevWalk revWalk,
-      Config repoConfig)
+      Config repoConfig,
+      boolean enableRenameDetection)
       throws DiffNotAvailableException;
 
   /**
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsForCommitValidation.java b/java/com/google/gerrit/server/patch/DiffOperationsForCommitValidation.java
new file mode 100644
index 0000000..3bb9ed0
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffOperationsForCommitValidation.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.gerrit.server.update.RepoView;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+
+/**
+ * Class to get modified files in {@link
+ * com.google.gerrit.server.git.validators.CommitValidationListener}s.
+ *
+ * <p>Computing the modified files for a merge commit may require the creation of the auto-merge
+ * commit (usually the auto-merge commit is not created yet when the commit validators are invoked).
+ * However commit validators should not write any commits (as the name {@code
+ * CommitValidationListener} suggests they are only intended to validate and listen). In particular
+ * commit validators must not write the auto-merge commit with a new {@link ObjectInserter} instance
+ * that competes with the main {@link ObjectInserter} instance that is being used to create changes,
+ * patch sets and auto-merge commits. This class wraps the computation of modified files and takes
+ * care of creating any missing auto-merge commit with the main {@link ObjectInserter} instance, so
+ * that the auto-merge commit is only created by this {@link ObjectInserter} instance and there is
+ * no competing {@link ObjectInserter} instance that creates the same auto-merge commit. Creating
+ * the same auto-merge commit with competing {@link ObjectInserter} instances must be avoided as it
+ * can result issues during object quorum.
+ */
+public class DiffOperationsForCommitValidation {
+  public interface Factory {
+    DiffOperationsForCommitValidation create(RepoView repoView, ObjectInserter inserter);
+  }
+
+  private final DiffOperations diffOperations;
+  private final RepoView repoView;
+  private final ObjectInserter inserter;
+
+  @Inject
+  DiffOperationsForCommitValidation(
+      DiffOperations diffOperations,
+      @Assisted RepoView repoView,
+      @Assisted ObjectInserter inserter) {
+    this.diffOperations = diffOperations;
+    this.repoView = repoView;
+    this.inserter = inserter;
+  }
+
+  /**
+   * Retrieves the modified files from the {@link
+   * com.google.gerrit.server.patch.diff.ModifiedFilesCache} if they are already cached. If not, the
+   * modified files are loaded directly (using the main {@link org.eclipse.jgit.revwalk.RevWalk}
+   * instance that can see newly inserted objects) rather than loading them via the {@link
+   * com.google.gerrit.server.patch.diff.ModifiedFilesCache} (that would open a new {@link
+   * org.eclipse.jgit.revwalk.RevWalk} instance).
+   *
+   * <p>If the loading requires the creation of the auto-merge commit it is created with the main
+   * {@link ObjectInserter} instance (also see the class javadoc).
+   *
+   * <p>The results will be stored in the {@link
+   * com.google.gerrit.server.patch.diff.ModifiedFilesCache} so that calling this method multiple
+   * times loads the modified files only once (for the first call, for further calls the cached
+   * modified files are returned).
+   */
+  public Map<String, ModifiedFile> loadModifiedFilesAgainstParentIfNecessary(
+      Project.NameKey project, ObjectId newCommit, int parentNum, boolean enableRenameDetection)
+      throws DiffNotAvailableException {
+    return diffOperations.loadModifiedFilesAgainstParentIfNecessary(
+        project, newCommit, parentNum, repoView, inserter, enableRenameDetection);
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 4d0bcc8..44810e8 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.patch;
 
+import static com.google.common.collect.ImmutableSortedMap.toImmutableSortedMap;
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static java.util.Comparator.naturalOrder;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -29,9 +32,11 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCacheImpl;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.diff.ModifiedFilesLoader;
 import com.google.gerrit.server.patch.filediff.FileDiffCache;
 import com.google.gerrit.server.patch.filediff.FileDiffCacheImpl;
 import com.google.gerrit.server.patch.filediff.FileDiffCacheKey;
@@ -40,6 +45,7 @@
 import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl;
 import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -49,15 +55,13 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.function.Function;
-import java.util.stream.Collectors;
-import org.eclipse.jgit.diff.DiffEntry;
-import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /**
  * Provides different file diff operations. Uses the underlying Git/Gerrit caches to speed up the
@@ -67,25 +71,16 @@
 public class DiffOperationsImpl implements DiffOperations {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final ImmutableMap<DiffEntry.ChangeType, Patch.ChangeType> changeTypeMap =
-      ImmutableMap.of(
-          DiffEntry.ChangeType.ADD,
-          Patch.ChangeType.ADDED,
-          DiffEntry.ChangeType.MODIFY,
-          Patch.ChangeType.MODIFIED,
-          DiffEntry.ChangeType.DELETE,
-          Patch.ChangeType.DELETED,
-          DiffEntry.ChangeType.RENAME,
-          Patch.ChangeType.RENAMED,
-          DiffEntry.ChangeType.COPY,
-          Patch.ChangeType.COPIED);
+  @VisibleForTesting static final int RENAME_SCORE = 60;
 
-  private static final int RENAME_SCORE = 60;
   private static final DiffAlgorithm DEFAULT_DIFF_ALGORITHM =
       DiffAlgorithm.HISTOGRAM_WITH_FALLBACK_MYERS;
   private static final Whitespace DEFAULT_WHITESPACE = Whitespace.IGNORE_NONE;
 
+  private final GitRepositoryManager repoManager;
   private final ModifiedFilesCache modifiedFilesCache;
+  private final ModifiedFilesCacheImpl modifiedFilesCacheImpl;
+  private final ModifiedFilesLoader.Factory modifiedFilesLoaderFactory;
   private final FileDiffCache fileDiffCache;
   private final BaseCommitUtil baseCommitUtil;
 
@@ -104,10 +99,16 @@
 
   @Inject
   public DiffOperationsImpl(
+      GitRepositoryManager repoManager,
       ModifiedFilesCache modifiedFilesCache,
+      ModifiedFilesCacheImpl modifiedFilesCacheImpl,
+      ModifiedFilesLoader.Factory modifiedFilesLoaderFactory,
       FileDiffCache fileDiffCache,
       BaseCommitUtil baseCommit) {
+    this.repoManager = repoManager;
     this.modifiedFilesCache = modifiedFilesCache;
+    this.modifiedFilesCacheImpl = modifiedFilesCacheImpl;
+    this.modifiedFilesLoaderFactory = modifiedFilesLoaderFactory;
     this.fileDiffCache = fileDiffCache;
     this.baseCommitUtil = baseCommit;
   }
@@ -116,8 +117,15 @@
   public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
       Project.NameKey project, ObjectId newCommit, int parent, DiffOptions diffOptions)
       throws DiffNotAvailableException {
-    try {
-      DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
+      logger.atFine().log(
+          "Opened repo %s to list modified files against parent for %s (inserter: %s)",
+          project, newCommit.name(), ins);
+      DiffParameters diffParams =
+          computeDiffParameters(project, newCommit, parent, new RepoView(repo, revWalk, ins), ins);
       return getModifiedFiles(diffParams, diffOptions);
     } catch (IOException e) {
       throw new DiffNotAvailableException(
@@ -126,17 +134,19 @@
   }
 
   @Override
-  public Map<String, ModifiedFile> loadModifiedFilesAgainstParent(
+  public Map<String, ModifiedFile> loadModifiedFilesAgainstParentIfNecessary(
       Project.NameKey project,
       ObjectId newCommit,
       int parentNum,
-      DiffOptions diffOptions,
-      RevWalk revWalk,
-      Config repoConfig)
+      RepoView repoView,
+      ObjectInserter ins,
+      boolean enableRenameDetection)
       throws DiffNotAvailableException {
     try {
-      DiffParameters diffParams = computeDiffParameters(project, newCommit, parentNum);
-      return loadModifiedFilesWithoutCache(project, diffParams, revWalk, repoConfig);
+      DiffParameters diffParams =
+          computeDiffParameters(project, newCommit, parentNum, repoView, ins);
+      return loadModifiedFilesWithoutCacheIfNecessary(
+          project, diffParams, repoView.getRevWalk(), repoView.getConfig(), enableRenameDetection);
     } catch (IOException e) {
       throw new DiffNotAvailableException(
           String.format(
@@ -161,13 +171,13 @@
   }
 
   @Override
-  public Map<String, ModifiedFile> loadModifiedFiles(
+  public Map<String, ModifiedFile> loadModifiedFilesIfNecessary(
       Project.NameKey project,
       ObjectId oldCommit,
       ObjectId newCommit,
-      DiffOptions diffOptions,
       RevWalk revWalk,
-      Config repoConfig)
+      Config repoConfig,
+      boolean enableRenameDetection)
       throws DiffNotAvailableException {
     DiffParameters params =
         DiffParameters.builder()
@@ -176,7 +186,8 @@
             .baseCommit(oldCommit)
             .comparisonType(ComparisonType.againstOtherPatchSet())
             .build();
-    return loadModifiedFilesWithoutCache(project, params, revWalk, repoConfig);
+    return loadModifiedFilesWithoutCacheIfNecessary(
+        project, params, revWalk, repoConfig, enableRenameDetection);
   }
 
   @Override
@@ -187,8 +198,15 @@
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException {
-    try {
-      DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
+      logger.atFine().log(
+          "Opened repo %s to get modified file against parent for %s (inserter: %s)",
+          project, newCommit.name(), ins);
+      DiffParameters diffParams =
+          computeDiffParameters(project, newCommit, parent, new RepoView(repo, revWalk, ins), ins);
       FileDiffCacheKey key =
           createFileDiffCacheKey(
               project,
@@ -283,7 +301,7 @@
 
   private FileDiffOutput getModifiedFileForKey(FileDiffCacheKey key)
       throws DiffNotAvailableException {
-    Map<String, FileDiffOutput> diffList =
+    ImmutableMap<String, FileDiffOutput> diffList =
         getModifiedFilesForKeys(ImmutableList.of(key), DiffOptions.DEFAULTS);
     return diffList.containsKey(key.newFilePath())
         ? diffList.get(key.newFilePath())
@@ -392,51 +410,61 @@
         .build();
   }
 
-  /** Loads the modified file paths between two commits without inspecting the diff cache. */
-  private static Map<String, ModifiedFile> loadModifiedFilesWithoutCache(
-      Project.NameKey project, DiffParameters diffParams, RevWalk revWalk, Config repoConfig)
+  /**
+   * Retrieves the modified files from the {@link ModifiedFilesCache} if they are already cached. If
+   * not, the modified files are loaded directly (using the provided {@link RevWalk}) rather than
+   * loading them via the {@link ModifiedFilesCache} (that would open a new {@link RevWalk}
+   * instance).
+   *
+   * <p>The results will be stored in the {@link ModifiedFilesCache} so that calling this method
+   * multiple times loads the modified files only once (for the first call, for further calls the
+   * cached modified files are returned).
+   */
+  private ImmutableMap<String, ModifiedFile> loadModifiedFilesWithoutCacheIfNecessary(
+      Project.NameKey project,
+      DiffParameters diffParams,
+      RevWalk revWalk,
+      Config repoConfig,
+      boolean enableRenameDetection)
       throws DiffNotAvailableException {
-    ObjectId newCommit = diffParams.newCommit();
-    ObjectId oldCommit = diffParams.baseCommit();
-    try {
-      ObjectReader reader = revWalk.getObjectReader();
-      List<DiffEntry> diffEntries;
-      try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        df.setReader(reader, repoConfig);
-        df.setDetectRenames(false);
-        diffEntries = df.scan(oldCommit.equals(ObjectId.zeroId()) ? null : oldCommit, newCommit);
-      }
-      List<ModifiedFile> modifiedFiles =
-          diffEntries.stream()
-              .map(
-                  entry ->
-                      ModifiedFile.builder()
-                          .changeType(toChangeType(entry.getChangeType()))
-                          .oldPath(getGitPath(entry.getOldPath()))
-                          .newPath(getGitPath(entry.getNewPath()))
-                          .build())
-              .collect(Collectors.toList());
-      return DiffUtil.mergeRewrittenModifiedFiles(modifiedFiles).stream()
-          .collect(ImmutableMap.toImmutableMap(ModifiedFile::getDefaultPath, Function.identity()));
-    } catch (IOException e) {
-      throw new DiffNotAvailableException(
-          String.format(
-              "Failed to compute the modified files for project '%s',"
-                  + " old commit '%s', new commit '%s'.",
-              project, oldCommit.name(), newCommit.name()),
-          e);
+    ModifiedFilesCacheKey.Builder cacheKeyBuilder =
+        ModifiedFilesCacheKey.builder()
+            .project(project)
+            .aCommit(diffParams.baseCommit())
+            .bCommit(diffParams.newCommit());
+    if (enableRenameDetection) {
+      cacheKeyBuilder.renameScore(RENAME_SCORE);
+    } else {
+      cacheKeyBuilder.disableRenameDetection();
     }
+    ModifiedFilesCacheKey cacheKey = cacheKeyBuilder.build();
+
+    Optional<ImmutableList<ModifiedFile>> cachedModifiedFiles =
+        modifiedFilesCacheImpl.getIfPresent(cacheKey);
+    if (cachedModifiedFiles.isPresent()) {
+      return toMap(cachedModifiedFiles.get());
+    }
+
+    ModifiedFilesLoader modifiedFilesLoader = modifiedFilesLoaderFactory.create();
+    if (enableRenameDetection) {
+      modifiedFilesLoader.withRenameDetection(RENAME_SCORE);
+    }
+    ImmutableMap<String, ModifiedFile> modifiedFiles =
+        toMap(
+            modifiedFilesLoader.load(
+                project, repoConfig, revWalk, diffParams.baseCommit(), diffParams.newCommit()));
+
+    // Store the result in the cache.
+    modifiedFilesCacheImpl.put(cacheKey, ImmutableList.copyOf(modifiedFiles.values()));
+    return modifiedFiles;
   }
 
-  private static Optional<String> getGitPath(String path) {
-    return path.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(path);
-  }
-
-  private static Patch.ChangeType toChangeType(DiffEntry.ChangeType changeType) {
-    if (!changeTypeMap.containsKey(changeType)) {
-      throw new IllegalArgumentException("Unsupported type " + changeType);
-    }
-    return changeTypeMap.get(changeType);
+  private static ImmutableMap<String, ModifiedFile> toMap(
+      ImmutableList<ModifiedFile> modifiedFiles) {
+    return modifiedFiles.stream()
+        .collect(
+            toImmutableSortedMap(
+                naturalOrder(), ModifiedFile::getDefaultPath, Function.identity()));
   }
 
   @AutoValue
@@ -485,11 +513,16 @@
 
   /** Compute Diff parameters - the base commit and the comparison type - using the input args. */
   private DiffParameters computeDiffParameters(
-      Project.NameKey project, ObjectId newCommit, Integer parent) throws IOException {
+      Project.NameKey project,
+      ObjectId newCommit,
+      int parent,
+      RepoView repoView,
+      ObjectInserter ins)
+      throws IOException {
     DiffParameters.Builder result =
         DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
     if (parent > 0) {
-      RevCommit baseCommit = baseCommitUtil.getBaseCommit(project, newCommit, parent);
+      RevCommit baseCommit = baseCommitUtil.getBaseCommit(repoView, ins, newCommit, parent);
       if (baseCommit == null) {
         // The specified parent doesn't exist or is not supported, fall back to comparing against
         // the root.
@@ -509,7 +542,7 @@
       return result.build();
     }
     if (numParents == 1) {
-      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
+      result.baseCommit(baseCommitUtil.getBaseCommit(repoView, ins, newCommit, parent));
       result.comparisonType(ComparisonType.againstParent(1));
       return result.build();
     }
@@ -519,11 +552,12 @@
               + "with more than two parents is not supported. Commit %s has %d parents."
               + " Falling back to the diff against the first parent.",
           newCommit, numParents);
-      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, 1).getId());
+      result.baseCommit(baseCommitUtil.getBaseCommit(repoView, ins, newCommit, 1).getId());
       result.comparisonType(ComparisonType.againstParent(1));
       result.skipFiles(true);
     } else {
-      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, null));
+      result.baseCommit(
+          baseCommitUtil.getBaseCommit(repoView, ins, newCommit, /* parentNum= */ null));
       result.comparisonType(ComparisonType.againstAutoMerge());
     }
     return result.build();
diff --git a/java/com/google/gerrit/server/patch/GitPositionTransformer.java b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
index b2c02e7..aca918b 100644
--- a/java/com/google/gerrit/server/patch/GitPositionTransformer.java
+++ b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
@@ -94,7 +94,8 @@
       Collection<PositionedEntity<T>> entities, Set<Mapping> mappings) {
     // Update the file paths first as copied files might exist. For copied files, this operation
     // will duplicate the PositionedEntity instances of the original file.
-    List<PositionedEntity<T>> filePathUpdatedEntities = updateFilePaths(entities, mappings);
+    ImmutableList<PositionedEntity<T>> filePathUpdatedEntities =
+        updateFilePaths(entities, mappings);
 
     return shiftRanges(filePathUpdatedEntities, mappings);
   }
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index 4efbc69..3c5be17 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -59,7 +59,7 @@
    */
   @VisibleForTesting
   static class ChangeTypeCmp implements Comparator<ChangeType> {
-    static final List<ChangeType> order =
+    static final ImmutableList<ChangeType> order =
         ImmutableList.of(
             ChangeType.ADDED,
             ChangeType.RENAMED,
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
index 41f2fed..bdcc424 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -14,19 +14,12 @@
 
 package com.google.gerrit.server.patch.diff;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
-import com.google.common.collect.Streams;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffUtil;
 import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
 import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
 import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
@@ -37,12 +30,9 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
-import java.util.List;
-import java.util.Set;
-import java.util.stream.Stream;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -57,8 +47,6 @@
  */
 @Singleton
 public class ModifiedFilesCacheImpl implements ModifiedFilesCache {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final String MODIFIED_FILES = "modified_files";
 
   private final LoadingCache<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache;
@@ -83,7 +71,7 @@
             .maximumWeight(10 << 20)
             .weigher(ModifiedFilesWeigher.class)
             .version(4)
-            .loader(ModifiedFilesLoader.class);
+            .loader(Loader.class);
       }
     };
   }
@@ -105,14 +93,22 @@
     }
   }
 
-  static class ModifiedFilesLoader
-      extends CacheLoader<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
-    private final GitModifiedFilesCache gitCache;
+  public Optional<ImmutableList<ModifiedFile>> getIfPresent(ModifiedFilesCacheKey key) {
+    return Optional.ofNullable(cache.getIfPresent(key));
+  }
+
+  public void put(ModifiedFilesCacheKey key, ImmutableList<ModifiedFile> modifiedFiles) {
+    cache.put(key, modifiedFiles);
+  }
+
+  static class Loader extends CacheLoader<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
     private final GitRepositoryManager repoManager;
+    private final ModifiedFilesLoader.Factory modifiedFilesLoaderFactory;
 
     @Inject
-    ModifiedFilesLoader(GitModifiedFilesCache gitCache, GitRepositoryManager repoManager) {
-      this.gitCache = gitCache;
+    Loader(
+        GitRepositoryManager repoManager, ModifiedFilesLoader.Factory modifiedFilesLoaderFactory) {
+      this.modifiedFilesLoaderFactory = modifiedFilesLoaderFactory;
       this.repoManager = repoManager;
     }
 
@@ -120,88 +116,15 @@
     public ImmutableList<ModifiedFile> load(ModifiedFilesCacheKey key)
         throws IOException, DiffNotAvailableException {
       try (Repository repo = repoManager.openRepository(key.project());
-          RevWalk rw = new RevWalk(repo.newObjectReader())) {
-        return loadModifiedFiles(key, rw);
+          RevWalk revWalk = new RevWalk(repo.newObjectReader())) {
+        ModifiedFilesLoader loader =
+            modifiedFilesLoaderFactory
+                .createWithRetrievingModifiedFilesForTreesFromGitModifiedFilesCache();
+        if (key.renameDetectionEnabled()) {
+          loader.withRenameDetection(key.renameScore());
+        }
+        return loader.load(key.project(), repo.getConfig(), revWalk, key.aCommit(), key.bCommit());
       }
     }
-
-    private ImmutableList<ModifiedFile> loadModifiedFiles(ModifiedFilesCacheKey key, RevWalk rw)
-        throws IOException, DiffNotAvailableException {
-      ObjectId aTree =
-          key.aCommit().equals(ObjectId.zeroId())
-              ? key.aCommit()
-              : DiffUtil.getTreeId(rw, key.aCommit());
-      ObjectId bTree = DiffUtil.getTreeId(rw, key.bCommit());
-      GitModifiedFilesCacheKey gitKey =
-          GitModifiedFilesCacheKey.builder()
-              .project(key.project())
-              .aTree(aTree)
-              .bTree(bTree)
-              .renameScore(key.renameScore())
-              .build();
-      ImmutableList<ModifiedFile> modifiedFiles =
-          DiffUtil.mergeRewrittenModifiedFiles(gitCache.get(gitKey));
-      if (key.aCommit().equals(ObjectId.zeroId())) {
-        return modifiedFiles;
-      }
-      RevCommit revCommitA = DiffUtil.getRevCommit(rw, key.aCommit());
-      RevCommit revCommitB = DiffUtil.getRevCommit(rw, key.bCommit());
-      if (DiffUtil.areRelated(revCommitA, revCommitB)) {
-        return modifiedFiles;
-      }
-      Set<String> touchedFiles =
-          getTouchedFilesWithParents(
-              key, revCommitA.getParent(0).getId(), revCommitB.getParent(0).getId(), rw);
-      return modifiedFiles.stream()
-          .filter(f -> isTouched(touchedFiles, f))
-          .collect(toImmutableList());
-    }
-
-    /**
-     * Returns the paths of files that were modified between the old and new commits versus their
-     * parents (i.e. old commit vs. its parent, and new commit vs. its parent).
-     *
-     * @param key the {@link ModifiedFilesCacheKey} representing the commits we are diffing
-     * @param rw a {@link RevWalk} for the repository
-     * @return The list of modified files between the old/new commits and their parents
-     */
-    private Set<String> getTouchedFilesWithParents(
-        ModifiedFilesCacheKey key, ObjectId parentOfA, ObjectId parentOfB, RevWalk rw)
-        throws IOException {
-      try {
-        // TODO(ghareeb): as an enhancement: the 3 calls of the underlying git cache can be combined
-        GitModifiedFilesCacheKey oldVsBaseKey =
-            GitModifiedFilesCacheKey.create(
-                key.project(), parentOfA, key.aCommit(), key.renameScore(), rw);
-        List<ModifiedFile> oldVsBase = gitCache.get(oldVsBaseKey);
-
-        GitModifiedFilesCacheKey newVsBaseKey =
-            GitModifiedFilesCacheKey.create(
-                key.project(), parentOfB, key.bCommit(), key.renameScore(), rw);
-        List<ModifiedFile> newVsBase = gitCache.get(newVsBaseKey);
-
-        return Sets.union(getOldAndNewPaths(oldVsBase), getOldAndNewPaths(newVsBase));
-      } catch (DiffNotAvailableException e) {
-        logger.atWarning().log(
-            "Failed to retrieve the touched files' commits (%s, %s) and parents (%s, %s): %s",
-            key.aCommit(), key.bCommit(), parentOfA, parentOfB, e.getMessage());
-        return ImmutableSet.of();
-      }
-    }
-
-    private ImmutableSet<String> getOldAndNewPaths(List<ModifiedFile> files) {
-      return files.stream()
-          .flatMap(
-              file -> Stream.concat(Streams.stream(file.oldPath()), Streams.stream(file.newPath())))
-          .collect(ImmutableSet.toImmutableSet());
-    }
-
-    private static boolean isTouched(Set<String> touchedFilePaths, ModifiedFile modifiedFile) {
-      String oldFilePath = modifiedFile.oldPath().orElse(null);
-      String newFilePath = modifiedFile.newPath().orElse(null);
-      // One of the above file paths could be /dev/null but we need not explicitly check for this
-      // value as the set of file paths shouldn't contain it.
-      return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
-    }
   }
 }
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
index 4a406c8..17829f8 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.proto.Protos;
@@ -68,6 +69,7 @@
 
     public abstract ModifiedFilesCacheKey.Builder bCommit(ObjectId value);
 
+    @CanIgnoreReturnValue
     public ModifiedFilesCacheKey.Builder disableRenameDetection() {
       renameScore(-1);
       return this;
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesLoader.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesLoader.java
new file mode 100644
index 0000000..0d03a58
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesLoader.java
@@ -0,0 +1,268 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.diff;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Comparator.comparing;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesLoader;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Class to load the files that have been modified between two commits.
+ *
+ * <p>Rename detection is off unless {@link #withRenameDetection(int)} is called.
+ *
+ * <p>The commits and their trees are looked up via the {@link RevWalk} instance that is provided to
+ * the {@link #load(com.google.gerrit.entities.Project.NameKey, Config, RevWalk, ObjectId,
+ * ObjectId)} method, unless the modified files for the trees of the commits should be retrieved
+ * from the {@link GitModifiedFilesCache} (see {@link
+ * Factory#createWithRetrievingModifiedFilesForTreesFromGitModifiedFilesCache()} in which case the
+ * trees are looked up via a new {@link RevWalk} instance that is created by {@code
+ * GitModifiedFilesCacheImpl.Loader}. Looking up the trees from a new {@link RevWalk} instance only
+ * succeeds if they were already fully persisted in the repository, i.e., if these are not newly
+ * created trees or tree which have been created in memory. This means using the {@link
+ * GitModifiedFilesCache} is expected to cause {@link MissingObjectException}s for the commit trees
+ * that are newly created or that were created in memory only.
+ */
+public class ModifiedFilesLoader {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @Singleton
+  public static class Factory {
+    private final GitModifiedFilesCache gitModifiedFilesCache;
+
+    @Inject
+    Factory(GitModifiedFilesCache gitModifiedFilesCache) {
+      this.gitModifiedFilesCache = gitModifiedFilesCache;
+    }
+
+    /**
+     * Creates a {@link ModifiedFilesLoader} instance that looks up the commits and their trees via
+     * the {@link RevWalk} instance that is provided to the {@link
+     * #load(com.google.gerrit.entities.Project.NameKey, Config, RevWalk, ObjectId, ObjectId)}
+     * method.
+     */
+    public ModifiedFilesLoader create() {
+      return new ModifiedFilesLoader(/* gitModifiedFilesCache= */ null);
+    }
+
+    /**
+     * Creates a {@link ModifiedFilesLoader} instance that retrieves the modified files for the
+     * trees of the commits from the {@link GitModifiedFilesCache}.
+     *
+     * <p>Retrieving modified files for the trees from the {@link GitModifiedFilesCache} means that
+     * the trees are loaded via a new {@link RevWalk} instance (that is created by {@code
+     * GitModifiedFilesCacheImpl.Loader}), and not by the {@link RevWalk} instance that is given to
+     * the {@link #load(com.google.gerrit.entities.Project.NameKey, Config, RevWalk, ObjectId,
+     * ObjectId)} method. Looking up the trees from a new {@link RevWalk} instance only succeeds if
+     * they were already fully persisted in the repository, i.e., if these are not newly created
+     * trees or tree which have been created in memory. This means using the {@link
+     * GitModifiedFilesCache} is expected to cause {@link MissingObjectException}s for the commit
+     * trees that are newly created or that were created in memory only. Also see the javadoc on
+     * this class.
+     */
+    ModifiedFilesLoader createWithRetrievingModifiedFilesForTreesFromGitModifiedFilesCache() {
+      return new ModifiedFilesLoader(gitModifiedFilesCache);
+    }
+  }
+
+  @Nullable private final GitModifiedFilesCache gitModifiedFilesCache;
+
+  @Nullable private Integer renameScore = null;
+
+  ModifiedFilesLoader(@Nullable GitModifiedFilesCache gitModifiedFilesCache) {
+    this.gitModifiedFilesCache = gitModifiedFilesCache;
+  }
+
+  /**
+   * Enables rename detection
+   *
+   * @param renameScore the score that should be used for the rename detection.
+   */
+  @CanIgnoreReturnValue
+  public ModifiedFilesLoader withRenameDetection(int renameScore) {
+    checkState(renameScore >= 0);
+    this.renameScore = renameScore;
+    return this;
+  }
+
+  /**
+   * Loads the files that have been modified between {@code baseCommit} and {@code newCommit}.
+   *
+   * <p>The commits and the commit trees are looked up via the given {@code revWalk} instance,
+   * unless the modified files for the trees of the commits should be retrieved from the {@link
+   * GitModifiedFilesCache} (see {@link
+   * Factory#createWithRetrievingModifiedFilesForTreesFromGitModifiedFilesCache()} in which case the
+   * trees are looked up via a new {@link RevWalk} instance that is created by {@code
+   * GitModifiedFilesCacheImpl.Loader}. Also see the javadoc on this class.
+   */
+  public ImmutableList<ModifiedFile> load(
+      Project.NameKey project,
+      Config repoConfig,
+      RevWalk revWalk,
+      ObjectId baseCommit,
+      ObjectId newCommit)
+      throws DiffNotAvailableException {
+    try {
+      ObjectId baseTree =
+          baseCommit.equals(ObjectId.zeroId())
+              ? ObjectId.zeroId()
+              : DiffUtil.getTreeId(revWalk, baseCommit);
+      ObjectId newTree = DiffUtil.getTreeId(revWalk, newCommit);
+      ImmutableList<ModifiedFile> modifiedFiles =
+          ImmutableList.sortedCopyOf(
+              comparing(f -> f.getDefaultPath()),
+              DiffUtil.mergeRewrittenModifiedFiles(
+                  getModifiedFiles(
+                      project, repoConfig, revWalk.getObjectReader(), baseTree, newTree)));
+      if (baseCommit.equals(ObjectId.zeroId())) {
+        return modifiedFiles;
+      }
+      RevCommit revCommitBase = DiffUtil.getRevCommit(revWalk, baseCommit);
+      RevCommit revCommitNew = DiffUtil.getRevCommit(revWalk, newCommit);
+      if (DiffUtil.areRelated(revCommitBase, revCommitNew)) {
+        return modifiedFiles;
+      }
+      Set<String> touchedFiles =
+          getTouchedFilesWithParents(
+              project,
+              repoConfig,
+              revWalk,
+              baseCommit,
+              revCommitBase.getParent(0).getId(),
+              newCommit,
+              revCommitNew.getParent(0).getId());
+      return modifiedFiles.stream()
+          .filter(f -> isTouched(touchedFiles, f))
+          .collect(toImmutableList());
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          String.format(
+              "Failed to get files that have been modified between commit %s and commit %s in project %s",
+              baseCommit.name(), newCommit.name(), project),
+          e);
+    }
+  }
+
+  /**
+   * Returns the paths of files that were modified between the base and new commits versus their
+   * parents (i.e. base commit vs. its parent, and new commit vs. its parent).
+   *
+   * @return The list of modified files between the base/new commits and their parents
+   */
+  private Set<String> getTouchedFilesWithParents(
+      Project.NameKey project,
+      Config repoConfig,
+      RevWalk revWalk,
+      ObjectId baseCommit,
+      ObjectId parentOfBase,
+      ObjectId newCommit,
+      ObjectId parentOfNew)
+      throws IOException {
+    try {
+      ImmutableList<ModifiedFile> oldVsBase =
+          getModifiedFiles(
+              project,
+              repoConfig,
+              revWalk.getObjectReader(),
+              DiffUtil.getTreeId(revWalk, parentOfBase),
+              DiffUtil.getTreeId(revWalk, baseCommit));
+      ImmutableList<ModifiedFile> newVsBase =
+          getModifiedFiles(
+              project,
+              repoConfig,
+              revWalk.getObjectReader(),
+              DiffUtil.getTreeId(revWalk, parentOfNew),
+              DiffUtil.getTreeId(revWalk, newCommit));
+      return Sets.union(getOldAndNewPaths(oldVsBase), getOldAndNewPaths(newVsBase));
+    } catch (DiffNotAvailableException e) {
+      logger.atWarning().log(
+          "Failed to retrieve the touched files' commits (%s, %s) and parents (%s, %s): %s",
+          baseCommit, newCommit, parentOfBase, parentOfNew, e.getMessage());
+      return ImmutableSet.of();
+    }
+  }
+
+  /**
+   * Get the files that have been modified between {@code baseTree} and {@code newTree}.
+   *
+   * <p>The modified files are loaded through {@link GitModifiedFilesLoader} unless it was requested
+   * to retrieve them from {@link GitModifiedFilesCache} (see {@link
+   * Factory#createWithRetrievingModifiedFilesForTreesFromGitModifiedFilesCache()})
+   */
+  private ImmutableList<ModifiedFile> getModifiedFiles(
+      Project.NameKey project,
+      Config repoConfig,
+      ObjectReader reader,
+      ObjectId baseTree,
+      ObjectId newTree)
+      throws IOException, DiffNotAvailableException {
+    if (gitModifiedFilesCache != null) {
+      GitModifiedFilesCacheKey.Builder cacheKeyBuilder =
+          GitModifiedFilesCacheKey.builder().project(project).aTree(baseTree).bTree(newTree);
+      if (renameScore != null) {
+        cacheKeyBuilder.renameScore(renameScore);
+      } else {
+        cacheKeyBuilder.disableRenameDetection();
+      }
+      return gitModifiedFilesCache.get(cacheKeyBuilder.build());
+    }
+
+    GitModifiedFilesLoader gitModifiedFilesLoader = new GitModifiedFilesLoader();
+    if (renameScore != null) {
+      gitModifiedFilesLoader.withRenameDetection(renameScore);
+    }
+    return gitModifiedFilesLoader.load(repoConfig, reader, baseTree, newTree);
+  }
+
+  private ImmutableSet<String> getOldAndNewPaths(List<ModifiedFile> files) {
+    return files.stream()
+        .flatMap(file -> Stream.concat(file.oldPath().stream(), file.newPath().stream()))
+        .collect(ImmutableSet.toImmutableSet());
+  }
+
+  private static boolean isTouched(Set<String> touchedFilePaths, ModifiedFile modifiedFile) {
+    String oldFilePath = modifiedFile.oldPath().orElse(null);
+    String newFilePath = modifiedFile.newPath().orElse(null);
+    // One of the above file paths could be /dev/null but we need not explicitly check for this
+    // value as the set of file paths shouldn't contain it.
+    return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
+  }
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/patch/diff/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/patch/diff/package-info.java
index 0709b86..e45b57b 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/patch/diff/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.patch.diff;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java b/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
index 0923252..fb13920 100644
--- a/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
+++ b/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
@@ -75,7 +75,7 @@
     // First batch: "old commit vs. new commit" and "new parent vs. new commit"
     // Second batch: "old parent vs. old commit" and "old parent vs. new parent"
 
-    Map<FileDiffCacheKey, GitDiffEntity> mainDiffs =
+    ImmutableMap<FileDiffCacheKey, GitDiffEntity> mainDiffs =
         computeGitFileDiffs(
             createGitKeys(
                 augmentedKeys,
@@ -83,7 +83,7 @@
                 k -> k.key().newCommit(),
                 k -> k.key().newFilePath()));
 
-    Map<FileDiffCacheKey, GitDiffEntity> oldVsParentDiffs =
+    ImmutableMap<FileDiffCacheKey, GitDiffEntity> oldVsParentDiffs =
         computeGitFileDiffs(
             createGitKeys(
                 keysWithRebaseEdits,
@@ -91,7 +91,7 @@
                 k -> k.key().oldCommit(),
                 k -> mainDiffs.get(k.key()).gitDiff().oldPath().orElse(null)));
 
-    Map<FileDiffCacheKey, GitDiffEntity> newVsParentDiffs =
+    ImmutableMap<FileDiffCacheKey, GitDiffEntity> newVsParentDiffs =
         computeGitFileDiffs(
             createGitKeys(
                 keysWithRebaseEdits,
@@ -99,7 +99,7 @@
                 k -> k.key().newCommit(),
                 k -> k.key().newFilePath()));
 
-    Map<FileDiffCacheKey, GitDiffEntity> parentsDiffs =
+    ImmutableMap<FileDiffCacheKey, GitDiffEntity> parentsDiffs =
         computeGitFileDiffs(
             createGitKeys(
                 keysWithRebaseEdits,
@@ -149,7 +149,7 @@
    * Computes the git diff for the git keys of the input map {@code keys} parameter. The computation
    * uses the underlying {@link GitFileDiffCache}.
    */
-  private Map<FileDiffCacheKey, GitDiffEntity> computeGitFileDiffs(
+  private ImmutableMap<FileDiffCacheKey, GitDiffEntity> computeGitFileDiffs(
       Map<FileDiffCacheKey, GitFileDiffCacheKey> keys) throws DiffNotAvailableException {
     ImmutableMap.Builder<FileDiffCacheKey, GitDiffEntity> result =
         ImmutableMap.builderWithExpectedSize(keys.size());
diff --git a/java/com/google/gerrit/server/patch/filediff/EditTransformer.java b/java/com/google/gerrit/server/patch/filediff/EditTransformer.java
index 55568e4..7083744 100644
--- a/java/com/google/gerrit/server/patch/filediff/EditTransformer.java
+++ b/java/com/google/gerrit/server/patch/filediff/EditTransformer.java
@@ -50,7 +50,7 @@
 
   private final GitPositionTransformer positionTransformer =
       new GitPositionTransformer(OmitPositionOnConflict.INSTANCE);
-  private List<ContextAwareEdit> edits;
+  private ImmutableList<ContextAwareEdit> edits;
 
   /**
    * Creates a new {@code EditTransformer} for the edits contained in the specified {@code
@@ -113,7 +113,7 @@
   }
 
   public static Stream<ContextAwareEdit> toEdits(FileEdits in) {
-    List<Edit> edits = in.edits();
+    ImmutableList<Edit> edits = in.edits();
     if (edits.isEmpty()) {
       return Stream.of(ContextAwareEdit.createForNoContentEdit(in.oldPath(), in.newPath()));
     }
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index d1bda5c..82d7e93 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -408,7 +408,7 @@
         if (!augmentedKey.ignoreRebase()) {
           rebaseFileEdits = computeRebaseEdits(allDiffs);
         }
-        List<Edit> rebaseEdits = rebaseFileEdits.edits();
+        ImmutableList<Edit> rebaseEdits = rebaseFileEdits.edits();
 
         ObjectId oldTreeId = allDiffs.mainDiff().gitKey().oldTree();
 
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/patch/filediff/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/patch/filediff/package-info.java
index 0709b86..1e3ad52 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/patch/filediff/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.patch.filediff;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
index b70f6e1..7cfc066 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
@@ -14,13 +14,9 @@
 
 package com.google.gerrit.server.patch.gitdiff;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.entities.Patch;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.ModifiedFilesProto;
@@ -33,33 +29,14 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
-import java.util.List;
-import java.util.Optional;
 import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.diff.DiffEntry;
-import org.eclipse.jgit.diff.DiffEntry.ChangeType;
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /** Implementation of the {@link GitModifiedFilesCache} */
 @Singleton
 public class GitModifiedFilesCacheImpl implements GitModifiedFilesCache {
   private static final String GIT_MODIFIED_FILES = "git_modified_files";
-  private static final ImmutableMap<ChangeType, Patch.ChangeType> changeTypeMap =
-      ImmutableMap.of(
-          DiffEntry.ChangeType.ADD,
-          Patch.ChangeType.ADDED,
-          DiffEntry.ChangeType.MODIFY,
-          Patch.ChangeType.MODIFIED,
-          DiffEntry.ChangeType.DELETE,
-          Patch.ChangeType.DELETED,
-          DiffEntry.ChangeType.RENAME,
-          Patch.ChangeType.RENAMED,
-          DiffEntry.ChangeType.COPY,
-          Patch.ChangeType.COPIED);
 
   private LoadingCache<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache;
 
@@ -117,44 +94,13 @@
     public ImmutableList<ModifiedFile> load(GitModifiedFilesCacheKey key) throws IOException {
       try (Repository repo = repoManager.openRepository(key.project());
           ObjectReader reader = repo.newObjectReader()) {
-        List<DiffEntry> entries = getGitTreeDiff(repo, reader, key);
-
-        return entries.stream().map(Loader::toModifiedFile).collect(toImmutableList());
-      }
-    }
-
-    private List<DiffEntry> getGitTreeDiff(
-        Repository repo, ObjectReader reader, GitModifiedFilesCacheKey key) throws IOException {
-      try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        df.setReader(reader, repo.getConfig());
+        GitModifiedFilesLoader loader = new GitModifiedFilesLoader();
         if (key.renameDetection()) {
-          df.setDetectRenames(true);
-          df.getRenameDetector().setRenameScore(key.renameScore());
+          loader.withRenameDetection(key.renameScore());
         }
-        // Skip detecting content renames for binary files.
-        df.getRenameDetector().setSkipContentRenamesForBinaryFiles(true);
-        // The scan method only returns the file paths that are different. Callers may choose to
-        // format these paths themselves.
-        return df.scan(key.aTree().equals(ObjectId.zeroId()) ? null : key.aTree(), key.bTree());
+        return loader.load(repo.getConfig(), reader, key.aTree(), key.bTree());
       }
     }
-
-    private static ModifiedFile toModifiedFile(DiffEntry entry) {
-      String oldPath = entry.getOldPath();
-      String newPath = entry.getNewPath();
-      return ModifiedFile.builder()
-          .changeType(toChangeType(entry.getChangeType()))
-          .oldPath(oldPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(oldPath))
-          .newPath(newPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(newPath))
-          .build();
-    }
-
-    private static Patch.ChangeType toChangeType(DiffEntry.ChangeType changeType) {
-      if (!changeTypeMap.containsKey(changeType)) {
-        throw new IllegalArgumentException("Unsupported type " + changeType);
-      }
-      return changeTypeMap.get(changeType);
-    }
   }
 
   public enum ValueSerializer implements CacheSerializer<ImmutableList<ModifiedFile>> {
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
index 16b0e65..52b274a 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.proto.Protos;
@@ -92,6 +93,7 @@
 
     public abstract Builder renameScore(int value);
 
+    @CanIgnoreReturnValue
     public Builder disableRenameDetection() {
       renameScore(-1);
       return this;
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesLoader.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesLoader.java
new file mode 100644
index 0000000..69a1e9d
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesLoader.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch.gitdiff;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+
+/**
+ * /** Class to load the files that have been modified between two Git trees.
+ *
+ * <p>Rename detection is off unless {@link #withRenameDetection(int)} is called.
+ *
+ * <p>The commits and the commit trees are looked up via the {@link RevWalk} instance that is
+ * provided to the {@link #load(Config, ObjectReader, ObjectId, ObjectId)} method.
+ */
+public class GitModifiedFilesLoader {
+  private static final ImmutableMap<ChangeType, Patch.ChangeType> changeTypeMap =
+      ImmutableMap.of(
+          DiffEntry.ChangeType.ADD,
+          Patch.ChangeType.ADDED,
+          DiffEntry.ChangeType.MODIFY,
+          Patch.ChangeType.MODIFIED,
+          DiffEntry.ChangeType.DELETE,
+          Patch.ChangeType.DELETED,
+          DiffEntry.ChangeType.RENAME,
+          Patch.ChangeType.RENAMED,
+          DiffEntry.ChangeType.COPY,
+          Patch.ChangeType.COPIED);
+
+  @Nullable private Integer renameScore = null;
+
+  /**
+   * Enables rename detection
+   *
+   * @param renameScore the score that should be used for the rename detection.
+   */
+  @CanIgnoreReturnValue
+  public GitModifiedFilesLoader withRenameDetection(int renameScore) {
+    checkState(renameScore >= 0);
+    this.renameScore = renameScore;
+    return this;
+  }
+
+  /**
+   * Loads the files that have been modified between {@code aTree} and {@code bTree}.
+   *
+   * <p>The trees are looked up via the given {@code revWalk} instance,
+   */
+  public ImmutableList<ModifiedFile> load(
+      Config repoConfig, ObjectReader reader, ObjectId aTree, ObjectId bTree) throws IOException {
+    List<DiffEntry> entries = getGitTreeDiff(repoConfig, reader, aTree, bTree);
+    return entries.stream().map(GitModifiedFilesLoader::toModifiedFile).collect(toImmutableList());
+  }
+
+  private List<DiffEntry> getGitTreeDiff(
+      Config repoConfig, ObjectReader reader, ObjectId aTree, ObjectId bTree) throws IOException {
+    try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+      df.setReader(reader, repoConfig);
+      if (renameScore != null) {
+        df.setDetectRenames(true);
+        df.getRenameDetector().setRenameScore(renameScore);
+
+        // Skip detecting content renames for binary files.
+        df.getRenameDetector().setSkipContentRenamesForBinaryFiles(true);
+      }
+      // The scan method only returns the file paths that are different. Callers may choose to
+      // format these paths themselves.
+      return df.scan(aTree.equals(ObjectId.zeroId()) ? null : aTree, bTree);
+    }
+  }
+
+  private static ModifiedFile toModifiedFile(DiffEntry entry) {
+    String oldPath = entry.getOldPath();
+    String newPath = entry.getNewPath();
+    return ModifiedFile.builder()
+        .changeType(toChangeType(entry.getChangeType()))
+        .oldPath(oldPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(oldPath))
+        .newPath(newPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(newPath))
+        .build();
+  }
+
+  private static Patch.ChangeType toChangeType(DiffEntry.ChangeType changeType) {
+    if (!changeTypeMap.containsKey(changeType)) {
+      throw new IllegalArgumentException("Unsupported type " + changeType);
+    }
+    return changeTypeMap.get(changeType);
+  }
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/patch/gitdiff/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/patch/gitdiff/package-info.java
index 0709b86..e78c6e3 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.patch.gitdiff;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index d0d024c..580aef5 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.patch.filediff.Edit;
 import com.google.protobuf.Descriptors.FieldDescriptor;
-import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.diff.DiffEntry;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
@@ -45,7 +44,7 @@
  */
 @AutoValue
 public abstract class GitFileDiff {
-  private static final Map<FileMode, Patch.FileMode> fileModeMap =
+  private static final ImmutableMap<FileMode, Patch.FileMode> fileModeMap =
       ImmutableMap.<FileMode, Patch.FileMode>builder()
           .put(FileMode.TREE, Patch.FileMode.TREE)
           .put(FileMode.SYMLINK, Patch.FileMode.SYMLINK)
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index 72dc434..84eda51 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -55,7 +55,6 @@
 import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
@@ -233,7 +232,7 @@
      *
      * @return The git file diffs for all input keys.
      */
-    private Map<GitFileDiffCacheKey, GitFileDiff> loadAllImpl(
+    private ImmutableMap<GitFileDiffCacheKey, GitFileDiff> loadAllImpl(
         Repository repo, DiffOptions options, List<GitFileDiffCacheKey> keys)
         throws IOException, DiffNotAvailableException {
       ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
@@ -279,7 +278,7 @@
     private static ListMultimap<String, DiffEntry> loadDiffEntries(
         DiffFormatter diffFormatter, DiffOptions diffOptions, Collection<String> filePaths)
         throws IOException {
-      Set<String> filePathsSet = ImmutableSet.copyOf(filePaths);
+      ImmutableSet<String> filePathsSet = ImmutableSet.copyOf(filePaths);
       List<DiffEntry> diffEntries =
           diffFormatter.scan(
               diffOptions.oldTree().equals(ObjectId.zeroId()) ? null : diffOptions.oldTree(),
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/patch/gitfilediff/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/patch/gitfilediff/package-info.java
index 0709b86..786011f 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.patch.gitfilediff;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/patch/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/patch/package-info.java
index 0709b86..fc85a1a 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/patch/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.patch;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index a4ee052..677ee18 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -19,6 +19,7 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
@@ -41,7 +42,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.Collection;
-import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 
@@ -238,7 +238,7 @@
     }
 
     private boolean canEmailReviewers() {
-      List<PermissionRule> email = capabilities().emailReviewers;
+      ImmutableList<PermissionRule> email = capabilities().emailReviewers;
       if (allow(email)) {
         logger.atFinest().log(
             "user %s can email reviewers (allowed by %s)", user.getLoggableName(), email);
diff --git a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
index 640ea9a..d8109af 100644
--- a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -107,9 +107,13 @@
             id -> {
               try {
                 ChangeData cd = changeDataFactory.create(projectName, id);
-                cd.notes(); // Make sure notes are available. This will trigger loading notes and
-                // throw an exception in case the change is corrupt and can't be loaded. It will
-                // then be omitted from the result.
+
+                // Make sure notes are available. This will trigger loading notes and throw an
+                // exception in case the change is corrupt and can't be loaded. It will then be
+                // omitted from the result.
+                @SuppressWarnings("unused")
+                var unused = cd.notes();
+
                 return cd;
               } catch (Exception e) {
                 // We drop changes that we can't load. The repositories contain 'dead' change refs
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index aba9522..0aa68e2 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.logging.CallerFinder;
 import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
@@ -61,8 +60,6 @@
   /** All permissions that apply to this reference. */
   private final PermissionCollection relevant;
 
-  private final CallerFinder callerFinder;
-
   // The next 4 members are cached canPerform() permissions.
 
   private Boolean owner;
@@ -83,13 +80,6 @@
     this.repositoryManager = repositoryManager;
     this.refName = ref;
     this.relevant = relevant;
-    this.callerFinder =
-        CallerFinder.builder()
-            .addTarget(PermissionBackend.class)
-            .matchSubClasses(true)
-            .matchInnerClasses(true)
-            .skip(1)
-            .build();
   }
 
   ProjectControl getProjectControl() {
@@ -435,7 +425,6 @@
                 projectControl.getProject().getName(),
                 refName);
         LoggingContext.getInstance().addAclLogRecord(logMessage);
-        logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
       }
       return false;
     }
@@ -455,7 +444,7 @@
                   pr.getGroup().getUUID().get(),
                   pr);
           LoggingContext.getInstance().addAclLogRecord(logMessage);
-          logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+          logger.atFine().log(logMessage);
         }
         return true;
       }
@@ -471,7 +460,7 @@
               projectControl.getProject().getName(),
               refName);
       LoggingContext.getInstance().addAclLogRecord(logMessage);
-      logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+      logger.atFine().log(logMessage);
     }
     return false;
   }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/permissions/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/permissions/package-info.java
index 0709b86..df7a36a 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/permissions/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.permissions;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/plugincontext/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/plugincontext/package-info.java
index 0709b86..487b39c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/plugincontext/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.plugincontext;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/plugins/AutoRegisterModules.java b/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
index 9d93ed2..1942193 100644
--- a/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
+++ b/java/com/google/gerrit/server/plugins/AutoRegisterModules.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.annotations.Listen;
@@ -71,6 +72,7 @@
     this.httpGen = env.hasHttpModule() ? env.newHttpModuleGenerator() : new ModuleGenerator.NOP();
   }
 
+  @CanIgnoreReturnValue
   AutoRegisterModules discover() throws InvalidPluginException {
     sysSingletons = new HashSet<>();
     sysListen = LinkedListMultimap.create();
diff --git a/java/com/google/gerrit/server/plugins/CopyConfigModule.java b/java/com/google/gerrit/server/plugins/CopyConfigModule.java
index c495721..2764675 100644
--- a/java/com/google/gerrit/server/plugins/CopyConfigModule.java
+++ b/java/com/google/gerrit/server/plugins/CopyConfigModule.java
@@ -44,7 +44,37 @@
 @SuppressWarnings("ProvidesMethodOutsideOfModule")
 @Singleton
 class CopyConfigModule extends AbstractModule {
-  @Inject @SitePath private Path sitePath;
+  private final Path sitePath;
+  private final SitePaths sitePaths;
+  private final TrackingFooters trackingFooters;
+  private final Config gerritServerConfig;
+  private final GitRepositoryManager gitRepositoryManager;
+  private final String anonymousCowardName;
+  private final GerritPersonIdentProvider serverIdentProvider;
+  private final SecureStore secureStore;
+  private final GerritIsReplicaProvider isReplicaProvider;
+
+  @Inject
+  CopyConfigModule(
+      @SitePath Path sitePath,
+      SitePaths sitePaths,
+      TrackingFooters trackingFooters,
+      @GerritServerConfig Config gerritServerConfig,
+      GitRepositoryManager gitRepositoryManager,
+      @AnonymousCowardName String anonymousCowardName,
+      GerritPersonIdentProvider serverIdentProvider,
+      SecureStore secureStore,
+      GerritIsReplicaProvider isReplicaProvider) {
+    this.sitePath = sitePath;
+    this.sitePaths = sitePaths;
+    this.trackingFooters = trackingFooters;
+    this.gerritServerConfig = gerritServerConfig;
+    this.gitRepositoryManager = gitRepositoryManager;
+    this.anonymousCowardName = anonymousCowardName;
+    this.serverIdentProvider = serverIdentProvider;
+    this.secureStore = secureStore;
+    this.isReplicaProvider = isReplicaProvider;
+  }
 
   @Provides
   @SitePath
@@ -52,69 +82,50 @@
     return sitePath;
   }
 
-  @Inject private SitePaths sitePaths;
-
   @Provides
   SitePaths getSitePaths() {
     return sitePaths;
   }
 
-  @Inject private TrackingFooters trackingFooters;
-
   @Provides
   TrackingFooters getTrackingFooters() {
     return trackingFooters;
   }
 
-  @Inject @GerritServerConfig private Config gerritServerConfig;
-
   @Provides
   @GerritServerConfig
   Config getGerritServerConfig() {
     return gerritServerConfig;
   }
 
-  @Inject private GitRepositoryManager gitRepositoryManager;
-
   @Provides
   GitRepositoryManager getGitRepositoryManager() {
     return gitRepositoryManager;
   }
 
-  @Inject @AnonymousCowardName private String anonymousCowardName;
-
   @Provides
   @AnonymousCowardName
   String getAnonymousCowardName() {
     return anonymousCowardName;
   }
 
-  @Inject private GerritPersonIdentProvider serverIdentProvider;
-
   @Provides
   @GerritPersonIdent
   PersonIdent getServerIdent() {
     return serverIdentProvider.get();
   }
 
-  @Inject private SecureStore secureStore;
-
   @Provides
   SecureStore getSecureStore() {
     return secureStore;
   }
 
-  @Inject private GerritIsReplicaProvider isReplicaProvider;
-
   @Provides
   @GerritIsReplica
   boolean getIsReplica() {
     return isReplicaProvider.get();
   }
 
-  @Inject
-  CopyConfigModule() {}
-
   @Override
   protected void configure() {}
 }
diff --git a/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index 760631d..e64651d 100644
--- a/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -30,7 +30,6 @@
 import java.net.URLClassLoader;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.time.Instant;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
@@ -131,7 +130,7 @@
       List<URL> urls = new ArrayList<>(2);
       String overlay = System.getProperty("gerrit.plugin-classes");
       if (overlay != null) {
-        Path classes = Paths.get(overlay).resolve(name).resolve("main");
+        Path classes = Path.of(overlay).resolve(name).resolve("main");
         if (Files.isDirectory(classes)) {
           logger.atInfo().log("plugin %s: including %s", name, classes);
           urls.add(classes.toUri().toURL());
diff --git a/java/com/google/gerrit/server/plugins/JarScanner.java b/java/com/google/gerrit/server/plugins/JarScanner.java
index 122e3f4..b92a9b2 100644
--- a/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -19,6 +19,7 @@
 import static com.google.common.collect.Iterables.transform;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
@@ -112,7 +113,7 @@
 
     for (Class<? extends Annotation> annotoation : annotations) {
       String descr = classObjToClassDescr.get(annotoation);
-      Collection<ClassData> discoverdData = rawMap.get(descr);
+      List<ClassData> discoverdData = rawMap.get(descr);
       Collection<ClassData> values = firstNonNull(discoverdData, Collections.emptySet());
 
       result.put(
@@ -331,7 +332,7 @@
     return Maps.transformEntries(attributes, (key, value) -> (String) value);
   }
 
-  private static Iterable<JarEntry> entriesOf(JarFile jarFile) {
+  private static ImmutableList<JarEntry> entriesOf(JarFile jarFile) {
     return jarFile.stream().collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 89d6f8f..7ecb6c6 100644
--- a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -251,7 +251,8 @@
   }
 
   public void exit(RequestContext old) {
-    local.setContext(old);
+    @SuppressWarnings("unused")
+    var unused = local.setContext(old);
   }
 
   public void onStartPlugin(Plugin plugin) {
@@ -276,7 +277,7 @@
         apiSets.putAll(dynamicSetsOf(apiInjector));
         apiMaps.putAll(dynamicMapsOf(apiInjector));
 
-        List<Injector> allPluginInjectors =
+        ImmutableList<Injector> allPluginInjectors =
             listOfInjectors(
                 plugin.getSysInjector(), plugin.getSshInjector(), plugin.getHttpInjector());
         allPluginInjectors.forEach(i -> attachItem(apiItems, i, plugin));
@@ -292,7 +293,7 @@
     }
   }
 
-  private List<Injector> listOfInjectors(Injector... injectors) {
+  private ImmutableList<Injector> listOfInjectors(Injector... injectors) {
     ImmutableList.Builder<Injector> injectorsListBuilder = ImmutableList.builder();
 
     for (Injector injector : injectors) {
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 8260104..e122d8d 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -47,7 +48,6 @@
 import java.nio.file.DirectoryStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.AbstractMap;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
@@ -164,6 +164,7 @@
     return plugins;
   }
 
+  @CanIgnoreReturnValue
   public String installPluginFromStream(String originalName, InputStream in)
       throws IOException, PluginInstallException {
     checkRemoteInstall();
@@ -519,6 +520,7 @@
     dropRemovedDisabledPlugins(jars);
   }
 
+  @CanIgnoreReturnValue
   private Plugin runPlugin(String name, Path plugin, Plugin oldPlugin)
       throws PluginInstallException {
     FileSnapshot snapshot = FileSnapshot.save(plugin.toFile());
@@ -611,6 +613,7 @@
     }
   }
 
+  @CanIgnoreReturnValue
   synchronized int processPendingCleanups() {
     Iterator<Plugin> iterator = toCleanup.iterator();
     while (iterator.hasNext()) {
@@ -726,15 +729,15 @@
       assert winner != null;
       // Disable all loser plugins by renaming their file names to
       // "file.disabled" and replace the disabled files in the multimap.
-      Collection<Path> elementsToRemove = new ArrayList<>();
-      Collection<Path> elementsToAdd = new ArrayList<>();
+      List<Path> elementsToRemove = new ArrayList<>();
+      List<Path> elementsToAdd = new ArrayList<>();
       for (Path loser : Iterables.skip(enabled, 1)) {
         logger.atWarning().log(
             "Plugin <%s> was disabled, because"
                 + " another plugin <%s>"
                 + " with the same name <%s> already exists",
             loser, winner, plugin);
-        Path disabledPlugin = Paths.get(loser + ".disabled");
+        Path disabledPlugin = Path.of(loser + ".disabled");
         elementsToAdd.add(disabledPlugin);
         elementsToRemove.add(loser);
         try {
diff --git a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
index 0f7e87e..8386b4c 100644
--- a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
+++ b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
@@ -33,10 +33,11 @@
 class ServerPluginInfoModule extends AbstractModule {
   private final ServerPlugin plugin;
   private final Path dataDir;
-
-  private volatile boolean ready;
   private final MetricMaker serverMetrics;
 
+  @SuppressWarnings("MutableGuiceModule")
+  private volatile boolean ready;
+
   ServerPluginInfoModule(ServerPlugin plugin, MetricMaker serverMetrics) {
     this.plugin = plugin;
     this.dataDir = plugin.getDataDir();
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/plugins/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/plugins/package-info.java
index 0709b86..fac5fdf 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/plugins/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.plugins;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/project/ChildProjects.java b/java/com/google/gerrit/server/project/ChildProjects.java
index ce5ce2f..5e72325 100644
--- a/java/com/google/gerrit/server/project/ChildProjects.java
+++ b/java/com/google/gerrit/server/project/ChildProjects.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Multimap;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -54,7 +55,7 @@
   /** Gets all child projects recursively. */
   public List<ProjectInfo> list(Project.NameKey parent) throws PermissionBackendException {
     Map<Project.NameKey, Project> projects = readAllReadableProjects();
-    Multimap<Project.NameKey, Project.NameKey> children = parentToChildren(projects);
+    ListMultimap<Project.NameKey, Project.NameKey> children = parentToChildren(projects);
     PermissionBackend.WithUser perm = permissionBackend.currentUser();
 
     List<ProjectInfo> results = new ArrayList<>();
@@ -83,9 +84,9 @@
   }
 
   /** Map of parent project to direct child. */
-  private Multimap<Project.NameKey, Project.NameKey> parentToChildren(
+  private ListMultimap<Project.NameKey, Project.NameKey> parentToChildren(
       Map<Project.NameKey, Project> projects) {
-    Multimap<Project.NameKey, Project.NameKey> m = ArrayListMultimap.create();
+    ListMultimap<Project.NameKey, Project.NameKey> m = ArrayListMultimap.create();
     for (Map.Entry<Project.NameKey, Project> e : projects.entrySet()) {
       if (!allProjects.equals(e.getKey())) {
         m.put(e.getValue().getParent(allProjects), e.getKey());
diff --git a/java/com/google/gerrit/server/project/CommentLinkProvider.java b/java/com/google/gerrit/server/project/CommentLinkProvider.java
index 88f045e..c799b56 100644
--- a/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -35,14 +35,14 @@
 public class CommentLinkProvider implements Provider<List<CommentLinkInfo>>, GerritConfigListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private volatile List<CommentLinkInfo> commentLinks;
+  private volatile ImmutableList<CommentLinkInfo> commentLinks;
 
   @Inject
   CommentLinkProvider(@GerritServerConfig Config cfg) {
     this.commentLinks = parseConfig(cfg);
   }
 
-  private List<CommentLinkInfo> parseConfig(Config cfg) {
+  private ImmutableList<CommentLinkInfo> parseConfig(Config cfg) {
     Set<String> subsections = cfg.getSubsections(ProjectConfig.COMMENTLINK);
     ImmutableList.Builder<CommentLinkInfo> cls =
         ImmutableList.builderWithExpectedSize(subsections.size());
diff --git a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
index f054e84..aa0f87e 100644
--- a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
+++ b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -17,6 +17,8 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableCollection;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.entities.BooleanProjectConfig;
@@ -36,7 +38,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
@@ -89,7 +90,7 @@
     }
 
     IdentifiedUser iUser = user.asIdentifiedUser();
-    Collection<ContributorAgreement> contributorAgreements =
+    ImmutableCollection<ContributorAgreement> contributorAgreements =
         projectCache.getAllProjects().getConfig().getContributorAgreements().values();
     List<UUID> okGroupIds = new ArrayList<>();
     for (ContributorAgreement ca : contributorAgreements) {
@@ -97,14 +98,14 @@
       groupIds = okGroupIds;
 
       // matchProjects defaults to match all projects when missing.
-      List<String> matchProjectsRegexes = ca.getMatchProjectsRegexes();
+      ImmutableList<String> matchProjectsRegexes = ca.getMatchProjectsRegexes();
       if (!matchProjectsRegexes.isEmpty()
           && !projectMatchesAnyPattern(project.get(), matchProjectsRegexes)) {
         // Doesn't match, isn't checked.
         continue;
       }
       // excludeProjects defaults to exclude no projects when missing.
-      List<String> excludeProjectsRegexes = ca.getExcludeProjectsRegexes();
+      ImmutableList<String> excludeProjectsRegexes = ca.getExcludeProjectsRegexes();
       if (!excludeProjectsRegexes.isEmpty()
           && projectMatchesAnyPattern(project.get(), excludeProjectsRegexes)) {
         // Matches, isn't checked.
diff --git a/java/com/google/gerrit/server/project/LabelConfigValidator.java b/java/com/google/gerrit/server/project/LabelConfigValidator.java
index 3e5ff6b..c4bc9fb 100644
--- a/java/com/google/gerrit/server/project/LabelConfigValidator.java
+++ b/java/com/google/gerrit/server/project/LabelConfigValidator.java
@@ -24,16 +24,16 @@
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.DiffOptions;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.inject.Inject;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
@@ -103,11 +103,10 @@
           .put(KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE, "has:unchanged-files")
           .build();
 
-  private final DiffOperations diffOperations;
+  private final ApprovalQueryBuilder approvalQueryBuilder;
 
-  @Inject
-  public LabelConfigValidator(DiffOperations diffOperations) {
-    this.diffOperations = diffOperations;
+  public LabelConfigValidator(ApprovalQueryBuilder approvalQueryBuilder) {
+    this.approvalQueryBuilder = approvalQueryBuilder;
   }
 
   @Override
@@ -143,93 +142,25 @@
       }
 
       // Load the old config
-      Optional<Config> oldConfig = loadOldConfig(receiveEvent);
+      @Nullable Config oldConfig = loadOldConfig(receiveEvent).orElse(null);
 
       for (String labelName : newConfig.getSubsections(ProjectConfig.LABEL)) {
-        for (String deprecatedFlag : DEPRECATED_FLAGS.keySet()) {
-          if (flagChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName, deprecatedFlag)) {
-            validationMessageBuilder.add(
-                new CommitValidationMessage(
-                    String.format(
-                        "Parameter '%s.%s.%s' is deprecated and cannot be set,"
-                            + " use '%s' in '%s.%s.%s' instead.",
-                        ProjectConfig.LABEL,
-                        labelName,
-                        deprecatedFlag,
-                        DEPRECATED_FLAGS.get(deprecatedFlag),
-                        ProjectConfig.LABEL,
-                        labelName,
-                        ProjectConfig.KEY_COPY_CONDITION),
-                    ValidationMessage.Type.ERROR));
-          }
-        }
-
-        if (copyValuesChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName)) {
-          validationMessageBuilder.add(
-              new CommitValidationMessage(
-                  String.format(
-                      "Parameter '%s.%s.%s' is deprecated and cannot be set,"
-                          + " use 'is:<copy-value>' in '%s.%s.%s' instead.",
-                      ProjectConfig.LABEL,
-                      labelName,
-                      KEY_COPY_VALUE,
-                      ProjectConfig.LABEL,
-                      labelName,
-                      ProjectConfig.KEY_COPY_CONDITION),
-                  ValidationMessage.Type.ERROR));
-        }
-
-        // Ban modifying label functions to any blocking function value
-        if (flagChangedOrNewlySet(
-            newConfig, oldConfig.orElse(null), labelName, ProjectConfig.KEY_FUNCTION)) {
-          String fnName =
-              newConfig.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
-          Optional<LabelFunction> labelFn = LabelFunction.parse(fnName);
-          if (labelFn.isPresent() && !isLabelFunctionAllowed(labelFn.get())) {
-            validationMessageBuilder.add(
-                new CommitValidationMessage(
-                    String.format(
-                        "Value '%s' of '%s.%s.%s' is not allowed and cannot be set."
-                            + " Label functions can only be set to {%s, %s, %s}."
-                            + " Use submit requirements instead of label functions.",
-                        fnName,
-                        ProjectConfig.LABEL,
-                        labelName,
-                        ProjectConfig.KEY_FUNCTION,
-                        LabelFunction.NO_BLOCK,
-                        LabelFunction.NO_OP,
-                        LabelFunction.PATCH_SET_LOCK),
-                    ValidationMessage.Type.ERROR));
-          }
-        }
-
-        // Ban deletions of label functions as well since the default is MaxWithBlock
-        if (flagDeleted(newConfig, oldConfig.orElse(null), labelName, ProjectConfig.KEY_FUNCTION)) {
-          validationMessageBuilder.add(
-              new CommitValidationMessage(
-                  String.format(
-                      "Cannot delete '%s.%s.%s'."
-                          + " Label functions can only be set to {%s, %s, %s}."
-                          + " Use submit requirements instead of label functions.",
-                      ProjectConfig.LABEL,
-                      labelName,
-                      ProjectConfig.KEY_FUNCTION,
-                      LabelFunction.NO_BLOCK,
-                      LabelFunction.NO_OP,
-                      LabelFunction.PATCH_SET_LOCK),
-                  ValidationMessage.Type.ERROR));
-        }
+        rejectSettingDeprecatedFlags(newConfig, oldConfig, labelName, validationMessageBuilder);
+        rejectSettingCopyValues(newConfig, oldConfig, labelName, validationMessageBuilder);
+        rejectSettingBlockingFunction(newConfig, oldConfig, labelName, validationMessageBuilder);
+        rejectDeletingFunction(newConfig, oldConfig, labelName, validationMessageBuilder);
+        rejectNonParseableCopyCondition(newConfig, oldConfig, labelName, validationMessageBuilder);
       }
 
       ImmutableList<CommitValidationMessage> validationMessages = validationMessageBuilder.build();
-      if (!validationMessages.isEmpty()) {
+      if (validationMessages.stream().anyMatch(CommitValidationMessage::isError)) {
         throw new CommitValidationException(
             String.format(
                 "invalid %s file in revision %s",
                 ProjectConfig.PROJECT_CONFIG, receiveEvent.commit.getName()),
             validationMessages);
       }
-      return ImmutableList.of();
+      return validationMessages;
     } catch (IOException | DiffNotAvailableException e) {
       String errorMessage =
           String.format(
@@ -243,6 +174,149 @@
     }
   }
 
+  private void rejectSettingDeprecatedFlags(
+      Config newConfig,
+      @Nullable Config oldConfig,
+      String labelName,
+      ImmutableList.Builder<CommitValidationMessage> validationMessageBuilder) {
+    for (String deprecatedFlag : DEPRECATED_FLAGS.keySet()) {
+      if (flagChangedOrNewlySet(newConfig, oldConfig, labelName, deprecatedFlag)) {
+        validationMessageBuilder.add(
+            new CommitValidationMessage(
+                String.format(
+                    "Parameter '%s.%s.%s' is deprecated and cannot be set,"
+                        + " use '%s' in '%s.%s.%s' instead.",
+                    ProjectConfig.LABEL,
+                    labelName,
+                    deprecatedFlag,
+                    DEPRECATED_FLAGS.get(deprecatedFlag),
+                    ProjectConfig.LABEL,
+                    labelName,
+                    ProjectConfig.KEY_COPY_CONDITION),
+                ValidationMessage.Type.ERROR));
+      }
+    }
+  }
+
+  private void rejectSettingCopyValues(
+      Config newConfig,
+      @Nullable Config oldConfig,
+      String labelName,
+      ImmutableList.Builder<CommitValidationMessage> validationMessageBuilder) {
+    if (copyValuesChangedOrNewlySet(newConfig, oldConfig, labelName)) {
+      validationMessageBuilder.add(
+          new CommitValidationMessage(
+              String.format(
+                  "Parameter '%s.%s.%s' is deprecated and cannot be set,"
+                      + " use 'is:<copy-value>' in '%s.%s.%s' instead.",
+                  ProjectConfig.LABEL,
+                  labelName,
+                  KEY_COPY_VALUE,
+                  ProjectConfig.LABEL,
+                  labelName,
+                  ProjectConfig.KEY_COPY_CONDITION),
+              ValidationMessage.Type.ERROR));
+    }
+  }
+
+  private void rejectSettingBlockingFunction(
+      Config newConfig,
+      @Nullable Config oldConfig,
+      String labelName,
+      ImmutableList.Builder<CommitValidationMessage> validationMessageBuilder) {
+    if (flagChangedOrNewlySet(newConfig, oldConfig, labelName, ProjectConfig.KEY_FUNCTION)) {
+      String fnName =
+          newConfig.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
+      Optional<LabelFunction> labelFn = LabelFunction.parse(fnName);
+      if (labelFn.isPresent() && !isLabelFunctionAllowed(labelFn.get())) {
+        validationMessageBuilder.add(
+            new CommitValidationMessage(
+                String.format(
+                    "Value '%s' of '%s.%s.%s' is not allowed and cannot be set."
+                        + " Label functions can only be set to {%s, %s, %s}."
+                        + " Use submit requirements instead of label functions.",
+                    fnName,
+                    ProjectConfig.LABEL,
+                    labelName,
+                    ProjectConfig.KEY_FUNCTION,
+                    LabelFunction.NO_BLOCK,
+                    LabelFunction.NO_OP,
+                    LabelFunction.PATCH_SET_LOCK),
+                ValidationMessage.Type.ERROR));
+      }
+    }
+  }
+
+  private void rejectDeletingFunction(
+      Config newConfig,
+      @Nullable Config oldConfig,
+      String labelName,
+      ImmutableList.Builder<CommitValidationMessage> validationMessageBuilder) {
+    // Ban deletion of label function since the default is MaxWithBlock which is deprecated
+    if (flagDeleted(newConfig, oldConfig, labelName, ProjectConfig.KEY_FUNCTION)) {
+      validationMessageBuilder.add(
+          new CommitValidationMessage(
+              String.format(
+                  "Cannot delete '%s.%s.%s'."
+                      + " Label functions can only be set to {%s, %s, %s}."
+                      + " Use submit requirements instead of label functions.",
+                  ProjectConfig.LABEL,
+                  labelName,
+                  ProjectConfig.KEY_FUNCTION,
+                  LabelFunction.NO_BLOCK,
+                  LabelFunction.NO_OP,
+                  LabelFunction.PATCH_SET_LOCK),
+              ValidationMessage.Type.ERROR));
+    }
+  }
+
+  private void rejectNonParseableCopyCondition(
+      Config newConfig,
+      @Nullable Config oldConfig,
+      String labelName,
+      ImmutableList.Builder<CommitValidationMessage> validationMessageBuilder) {
+    if (flagChangedOrNewlySet(newConfig, oldConfig, labelName, ProjectConfig.KEY_COPY_CONDITION)) {
+      String copyCondition =
+          newConfig.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
+      try {
+        @SuppressWarnings("unused")
+        var unused = approvalQueryBuilder.parse(copyCondition);
+      } catch (QueryParseException e) {
+        validationMessageBuilder.add(
+            new CommitValidationMessage(
+                String.format(
+                    "Cannot parse copy condition '%s' of label %s (parameter '%s.%s.%s'): %s",
+                    copyCondition,
+                    labelName,
+                    ProjectConfig.LABEL,
+                    labelName,
+                    ProjectConfig.KEY_COPY_CONDITION,
+                    e.getMessage()),
+                // if the old copy condition is not parseable allow updating it even if the new copy
+                // condition is also not parseable, only emit a warning in this case
+                hasUnparseableOldCopyCondition(oldConfig, labelName)
+                    ? ValidationMessage.Type.WARNING
+                    : ValidationMessage.Type.ERROR));
+      }
+    }
+  }
+
+  private boolean hasUnparseableOldCopyCondition(@Nullable Config oldConfig, String labelName) {
+    if (oldConfig == null) {
+      return false;
+    }
+
+    String oldCopyCondition =
+        oldConfig.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
+    try {
+      @SuppressWarnings("unused")
+      var unused = approvalQueryBuilder.parse(oldCopyCondition);
+      return false;
+    } catch (QueryParseException e) {
+      return true;
+    }
+  }
+
   /**
    * Whether the given file was changed in the given revision.
    *
@@ -251,7 +325,7 @@
    */
   private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
       throws DiffNotAvailableException {
-    Map<String, FileDiffOutput> fileDiffOutputs;
+    Map<String, ModifiedFile> fileDiffOutputs;
     if (receiveEvent.commit.getParentCount() > 0) {
       // normal commit with one parent: use listModifiedFilesAgainstParent with parentNum = 1 to
       // compare against the only parent (using parentNum = 0 to compare against the default parent
@@ -260,23 +334,26 @@
       // = 1 to compare against the first parent (using parentNum = 0 would compare against the
       // auto-merge)
       fileDiffOutputs =
-          diffOperations.listModifiedFilesAgainstParent(
-              receiveEvent.getProjectNameKey(), receiveEvent.commit, 1, DiffOptions.DEFAULTS);
+          receiveEvent.diffOperations.loadModifiedFilesAgainstParentIfNecessary(
+              receiveEvent.getProjectNameKey(),
+              receiveEvent.commit,
+              1,
+              /* enableRenameDetection= */ true);
     } else {
       // initial commit: must use listModifiedFilesAgainstParent with parentNum = 0
       fileDiffOutputs =
-          diffOperations.listModifiedFilesAgainstParent(
+          receiveEvent.diffOperations.loadModifiedFilesAgainstParentIfNecessary(
               receiveEvent.getProjectNameKey(),
               receiveEvent.commit,
               /* parentNum=*/ 0,
-              DiffOptions.DEFAULTS);
+              /* enableRenameDetection= */ true);
     }
     return fileDiffOutputs.keySet().contains(fileName);
   }
 
   private Config loadNewConfig(CommitReceivedEvent receiveEvent)
       throws IOException, ConfigInvalidException {
-    ProjectLevelConfig.Bare bareConfig = new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    VersionedConfigFile bareConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
     bareConfig.load(receiveEvent.project.getNameKey(), receiveEvent.revWalk, receiveEvent.commit);
     return bareConfig.getConfig();
   }
@@ -288,8 +365,7 @@
     }
 
     try {
-      ProjectLevelConfig.Bare bareConfig =
-          new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+      VersionedConfigFile bareConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
       bareConfig.load(
           receiveEvent.project.getNameKey(),
           receiveEvent.revWalk,
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 6c8087e0..a981c3c 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -744,7 +744,7 @@
           loadPatterns(rc, CONTRIBUTOR_AGREEMENT, name, KEY_EXCLUDE_PROJECTS));
       ca.setMatchProjectsRegexes(loadPatterns(rc, CONTRIBUTOR_AGREEMENT, name, KEY_MATCH_PROJECTS));
 
-      List<PermissionRule> rules =
+      ImmutableList<PermissionRule> rules =
           loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY, false);
       if (rules.isEmpty()) {
         ca.setAutoVerify(null);
@@ -953,7 +953,7 @@
   }
 
   private static LabelValue parseLabelValue(String src) {
-    List<String> parts =
+    ImmutableList<String> parts =
         ImmutableList.copyOf(
             Splitter.on(CharMatcher.whitespace()).omitEmptyStrings().limit(2).split(src));
     if (parts.isEmpty()) {
@@ -1651,7 +1651,7 @@
         rc.unset(LABEL, name, KEY_COPY_CONDITION);
       }
 
-      List<String> refPatterns = label.getRefPatterns();
+      ImmutableList<String> refPatterns = label.getRefPatterns();
       if (refPatterns != null && !refPatterns.isEmpty()) {
         rc.setStringList(LABEL, name, KEY_BRANCH, refPatterns);
       } else {
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index 8bd18ff..71253eb 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -20,6 +20,8 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BooleanProjectConfig;
@@ -36,6 +38,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.config.GerritInstanceId;
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -80,6 +83,7 @@
   private final Provider<PersonIdent> serverIdent;
   private final Provider<IdentifiedUser> identifiedUser;
   private final ProjectConfig.Factory projectConfigFactory;
+  private final String gerritInstanceId;
 
   @Inject
   ProjectCreator(
@@ -91,6 +95,7 @@
       GitReferenceUpdated referenceUpdated,
       RepositoryConfig repositoryCfg,
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      @Nullable @GerritInstanceId String gerritInstanceId,
       Provider<IdentifiedUser> identifiedUser,
       ProjectConfig.Factory projectConfigFactory) {
     this.repoManager = repoManager;
@@ -101,10 +106,12 @@
     this.referenceUpdated = referenceUpdated;
     this.repositoryCfg = repositoryCfg;
     this.serverIdent = serverIdent;
+    this.gerritInstanceId = gerritInstanceId;
     this.identifiedUser = identifiedUser;
     this.projectConfigFactory = projectConfigFactory;
   }
 
+  @CanIgnoreReturnValue
   public ProjectState createProject(CreateProjectArgs args)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     try (RefUpdateContext ctx = RefUpdateContext.open(INIT_REPO)) {
@@ -248,17 +255,19 @@
       return;
     }
 
-    ProjectCreator.Event event = new ProjectCreator.Event(name, head);
+    ProjectCreator.Event event = new ProjectCreator.Event(name, head, gerritInstanceId);
     createdListeners.runEach(l -> l.onNewProjectCreated(event));
   }
 
   static class Event extends AbstractNoNotifyEvent implements NewProjectCreatedListener.Event {
     private final Project.NameKey name;
     private final String head;
+    private final String gerritInstanceId;
 
-    Event(Project.NameKey name, String head) {
+    Event(Project.NameKey name, String head, @Nullable String gerritInstanceId) {
       this.name = name;
       this.head = head;
+      this.gerritInstanceId = gerritInstanceId;
     }
 
     @Override
@@ -270,5 +279,10 @@
     public String getHeadName() {
       return head;
     }
+
+    @Override
+    public String getInstanceId() {
+      return gerritInstanceId;
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectLevelConfig.java b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
index 8256198..7ab7629 100644
--- a/java/com/google/gerrit/server/project/ProjectLevelConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
@@ -18,60 +18,15 @@
 
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.ImmutableConfig;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.server.git.meta.VersionedMetaData;
-import java.io.IOException;
 import java.util.Arrays;
 import java.util.Set;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 import org.eclipse.jgit.annotations.Nullable;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 
 /** Configuration file in the projects refs/meta/config branch. */
 public class ProjectLevelConfig {
-  /**
-   * This class is a low-level API that allows callers to read the config directly from a repository
-   * and make updates to it.
-   */
-  public static class Bare extends VersionedMetaData {
-    private final String fileName;
-    @Nullable private Config cfg;
-
-    public Bare(String fileName) {
-      this.fileName = fileName;
-      this.cfg = null;
-    }
-
-    public Config getConfig() {
-      if (cfg == null) {
-        cfg = new Config();
-      }
-      return cfg;
-    }
-
-    @Override
-    protected String getRefName() {
-      return RefNames.REFS_CONFIG;
-    }
-
-    @Override
-    protected void onLoad() throws IOException, ConfigInvalidException {
-      cfg = readConfig(fileName);
-    }
-
-    @Override
-    protected boolean onSave(CommitBuilder commit) throws IOException {
-      if (commit.getMessage() == null || "".equals(commit.getMessage())) {
-        commit.setMessage("Updated configuration\n");
-      }
-      saveConfig(fileName, cfg);
-      return true;
-    }
-  }
-
   private final String fileName;
   private final ProjectState project;
   private final ImmutableConfig immutableConfig;
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index 4946bea..a317361 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -257,7 +257,7 @@
     }
 
     try {
-      List<ChangeData> queryResult =
+      ImmutableList<ChangeData> queryResult =
           retryHelper
               .changeIndexQuery(
                   "projectsConsistencyCheckerQueryChanges",
@@ -273,34 +273,38 @@
         // Skip changes that we have already processed, either by this query or by
         // earlier queries.
         if (seenChanges.add(autoCloseableChange.getId())) {
-          retryHelper
-              .changeUpdate(
-                  "projectsConsistencyCheckerAutoCloseChanges",
-                  () -> {
-                    // Auto-close by change
-                    if (changeIdToMergedSha1.containsKey(autoCloseableChange.change().getKey())) {
-                      autoCloseableChangesByBranch.add(
-                          changeJson(
-                                  fix,
-                                  changeIdToMergedSha1.get(autoCloseableChange.change().getKey()))
-                              .format(autoCloseableChange));
-                      return null;
-                    }
+          @SuppressWarnings("unused")
+          var unused =
+              retryHelper
+                  .changeUpdate(
+                      "projectsConsistencyCheckerAutoCloseChanges",
+                      () -> {
+                        // Auto-close by change
+                        if (changeIdToMergedSha1.containsKey(
+                            autoCloseableChange.change().getKey())) {
+                          autoCloseableChangesByBranch.add(
+                              changeJson(
+                                      fix,
+                                      changeIdToMergedSha1.get(
+                                          autoCloseableChange.change().getKey()))
+                                  .format(autoCloseableChange));
+                          return null;
+                        }
 
-                    // Auto-close by commit
-                    for (ObjectId patchSetSha1 :
-                        autoCloseableChange.patchSets().stream()
-                            .map(PatchSet::commitId)
-                            .collect(toSet())) {
-                      if (mergedSha1s.contains(patchSetSha1)) {
-                        autoCloseableChangesByBranch.add(
-                            changeJson(fix, patchSetSha1).format(autoCloseableChange));
-                        break;
-                      }
-                    }
-                    return null;
-                  })
-              .call();
+                        // Auto-close by commit
+                        for (ObjectId patchSetSha1 :
+                            autoCloseableChange.patchSets().stream()
+                                .map(PatchSet::commitId)
+                                .collect(toSet())) {
+                          if (mergedSha1s.contains(patchSetSha1)) {
+                            autoCloseableChangesByBranch.add(
+                                changeJson(fix, patchSetSha1).format(autoCloseableChange));
+                            break;
+                          }
+                        }
+                        return null;
+                      })
+                  .call();
         }
       }
 
diff --git a/java/com/google/gerrit/server/project/PrologRulesWarningValidator.java b/java/com/google/gerrit/server/project/PrologRulesWarningValidator.java
index 5683fe7..e3de763 100644
--- a/java/com/google/gerrit/server/project/PrologRulesWarningValidator.java
+++ b/java/com/google/gerrit/server/project/PrologRulesWarningValidator.java
@@ -26,10 +26,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.DiffOptions;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.inject.Inject;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.inject.Singleton;
 import java.util.List;
 import java.util.Map;
@@ -43,13 +40,6 @@
 public class PrologRulesWarningValidator implements CommitValidationListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final DiffOperations diffOperations;
-
-  @Inject
-  public PrologRulesWarningValidator(DiffOperations diffOperations) {
-    this.diffOperations = diffOperations;
-  }
-
   @Override
   public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
       throws CommitValidationException {
@@ -70,13 +60,13 @@
 
   private boolean isFileAdded(CommitReceivedEvent receiveEvent, String fileName)
       throws DiffNotAvailableException {
-    List<Map.Entry<String, FileDiffOutput>> matchingEntries =
-        diffOperations
-            .listModifiedFilesAgainstParent(
+    List<Map.Entry<String, ModifiedFile>> matchingEntries =
+        receiveEvent.diffOperations
+            .loadModifiedFilesAgainstParentIfNecessary(
                 receiveEvent.project.getNameKey(),
                 receiveEvent.commit,
                 /* parentNum=*/ 0,
-                DiffOptions.DEFAULTS)
+                /* enableRenameDetection= */ true)
             .entrySet().stream()
             .filter(e -> fileName.equals(e.getKey()))
             .collect(Collectors.toList());
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
index c935adf..c31cd35 100644
--- a/java/com/google/gerrit/server/project/Reachable.java
+++ b/java/com/google/gerrit/server/project/Reachable.java
@@ -77,7 +77,7 @@
               .orElseGet(() -> permissionBackend.currentUser())
               .project(project)
               .filter(refs, repo, RefFilterOptions.defaults());
-      Collection<RevCommit> visible = new ArrayList<>();
+      List<RevCommit> visible = new ArrayList<>();
       for (Ref r : filtered) {
         try {
           visible.add(rw.parseCommit(r.getObjectId()));
diff --git a/java/com/google/gerrit/server/project/RefPatternMatcher.java b/java/com/google/gerrit/server/project/RefPatternMatcher.java
index 798838e..9249e36 100644
--- a/java/com/google/gerrit/server/project/RefPatternMatcher.java
+++ b/java/com/google/gerrit/server/project/RefPatternMatcher.java
@@ -111,7 +111,7 @@
         // allows the pattern prefix to be clipped, saving time on
         // evaluation.
         String replacement = ":PLACEHOLDER:";
-        Map<String, String> params =
+        ImmutableMap<String, String> params =
             ImmutableMap.of(
                 RefPattern.USERID_SHARDED, replacement,
                 RefPattern.USERNAME, replacement);
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
index 6366a14..e715aca 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
@@ -23,8 +23,6 @@
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.DiffOptions;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.List;
@@ -42,16 +40,13 @@
  * {@link ProjectConfig} is cached in the project cache).
  */
 public class SubmitRequirementConfigValidator implements CommitValidationListener {
-  private final DiffOperations diffOperations;
   private final ProjectConfig.Factory projectConfigFactory;
   private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
 
   @Inject
   SubmitRequirementConfigValidator(
-      DiffOperations diffOperations,
       ProjectConfig.Factory projectConfigFactory,
       SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
-    this.diffOperations = diffOperations;
     this.projectConfigFactory = projectConfigFactory;
     this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
   }
@@ -116,12 +111,12 @@
    */
   private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
       throws DiffNotAvailableException {
-    return diffOperations
-        .listModifiedFilesAgainstParent(
+    return receiveEvent.diffOperations
+        .loadModifiedFilesAgainstParentIfNecessary(
             receiveEvent.project.getNameKey(),
             receiveEvent.commit,
             /* parentNum=*/ 0,
-            DiffOptions.DEFAULTS)
+            /* enableRenameDetection= */ true)
         .keySet().stream()
         .anyMatch(fileName::equals);
   }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index 39ba8b4..7f73cd3 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -50,7 +50,7 @@
    * Retrieve legacy submit records (created by label functions and other {@link
    * com.google.gerrit.server.rules.SubmitRule}s) and convert them to submit requirement results.
    */
-  public static Map<SubmitRequirement, SubmitRequirementResult> getLegacyRequirements(
+  public static ImmutableMap<SubmitRequirement, SubmitRequirementResult> getLegacyRequirements(
       ChangeData cd) {
     // We use SubmitRuleOptions.defaults() which does not recompute submit rules for closed changes.
     // This doesn't have an effect since we never call this class (i.e. to evaluate submit
@@ -105,9 +105,9 @@
   }
 
   @VisibleForTesting
-  static List<SubmitRequirementResult> createResult(
+  static ImmutableList<SubmitRequirementResult> createResult(
       SubmitRecord record, List<LabelType> labelTypes, ObjectId psCommitId, boolean isForced) {
-    List<SubmitRequirementResult> results;
+    ImmutableList<SubmitRequirementResult> results;
     if (record.ruleName != null && record.ruleName.equals(DefaultSubmitRule.RULE_NAME)) {
       results = createFromDefaultSubmitRecord(record.labels, labelTypes, psCommitId, isForced);
     } else {
@@ -117,7 +117,7 @@
     return results;
   }
 
-  private static List<SubmitRequirementResult> createFromDefaultSubmitRecord(
+  private static ImmutableList<SubmitRequirementResult> createFromDefaultSubmitRecord(
       @Nullable List<Label> labels,
       List<LabelType> labelTypes,
       ObjectId psCommitId,
@@ -159,7 +159,7 @@
     return result.build();
   }
 
-  private static List<SubmitRequirementResult> createFromCustomSubmitRecord(
+  private static ImmutableList<SubmitRequirementResult> createFromCustomSubmitRecord(
       SubmitRecord record, ObjectId psCommitId, boolean isForced) {
     String ruleName = record.ruleName != null ? record.ruleName : "Custom-Rule";
     if (record.labels == null || record.labels.isEmpty()) {
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index 7777678..d4db78a 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -27,6 +27,9 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
@@ -83,7 +86,8 @@
   public void validateExpression(SubmitRequirementExpression expression)
       throws QueryParseException {
     try (ManualRequestContext ignored = requestContext.open()) {
-      queryBuilder.get().parse(expression.expressionString());
+      @SuppressWarnings("unused")
+      var unused = queryBuilder.get().parse(expression.expressionString());
     }
   }
 
@@ -180,31 +184,36 @@
    * SubmitRequirement#allowOverrideInChildProjects} of global {@link SubmitRequirement}.
    */
   private ImmutableMap<SubmitRequirement, SubmitRequirementResult> getRequirements(ChangeData cd) {
-    Map<String, SubmitRequirement> globalRequirements = getGlobalRequirements();
+    try (TraceTimer timer =
+        TraceContext.newTimer(
+            "Get submit requirements",
+            Metadata.builder().changeId(cd.change().getId().get()).build())) {
+      ImmutableMap<String, SubmitRequirement> globalRequirements = getGlobalRequirements();
 
-    ProjectState state = projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
-    Map<String, SubmitRequirement> projectConfigRequirements = state.getSubmitRequirements();
+      ProjectState state = projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
+      Map<String, SubmitRequirement> projectConfigRequirements = state.getSubmitRequirements();
 
-    ImmutableMap<String, SubmitRequirement> requirements =
-        Stream.concat(
-                globalRequirements.entrySet().stream(),
-                projectConfigRequirements.entrySet().stream())
-            .collect(
-                toImmutableMap(
-                    Map.Entry::getKey,
-                    Map.Entry::getValue,
-                    (globalSubmitRequirement, projectConfigRequirement) ->
-                        // Override with projectConfigRequirement if allowed by
-                        // globalSubmitRequirement configuration
-                        globalSubmitRequirement.allowOverrideInChildProjects()
-                            ? projectConfigRequirement
-                            : globalSubmitRequirement));
-    ImmutableMap.Builder<SubmitRequirement, SubmitRequirementResult> results =
-        ImmutableMap.builder();
-    for (SubmitRequirement requirement : requirements.values()) {
-      results.put(requirement, evaluateRequirementInternal(requirement, cd));
+      ImmutableMap<String, SubmitRequirement> requirements =
+          Stream.concat(
+                  globalRequirements.entrySet().stream(),
+                  projectConfigRequirements.entrySet().stream())
+              .collect(
+                  toImmutableMap(
+                      Map.Entry::getKey,
+                      Map.Entry::getValue,
+                      (globalSubmitRequirement, projectConfigRequirement) ->
+                          // Override with projectConfigRequirement if allowed by
+                          // globalSubmitRequirement configuration
+                          globalSubmitRequirement.allowOverrideInChildProjects()
+                              ? projectConfigRequirement
+                              : globalSubmitRequirement));
+      ImmutableMap.Builder<SubmitRequirement, SubmitRequirementResult> results =
+          ImmutableMap.builder();
+      for (SubmitRequirement requirement : requirements.values()) {
+        results.put(requirement, evaluateRequirementInternal(requirement, cd));
+      }
+      return results.build();
     }
-    return results.build();
   }
 
   /**
@@ -212,7 +221,7 @@
    *
    * <p>The global {@link SubmitRequirement}s apply to all projects and can be bound by plugins.
    */
-  private Map<String, SubmitRequirement> getGlobalRequirements() {
+  private ImmutableMap<String, SubmitRequirement> getGlobalRequirements() {
     return globalSubmitRequirements.stream()
         .collect(
             toImmutableMap(
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 866ce14..aab1cc5 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -15,21 +15,20 @@
 package com.google.gerrit.server.project;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.Streams;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.index.OnlineReindexMode;
-import com.google.gerrit.server.logging.CallerFinder;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
@@ -46,8 +45,6 @@
  * the results through rules found in the parent projects, all the way up to All-Projects.
  */
 public class SubmitRuleEvaluator {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     /** Returns a new {@link SubmitRuleEvaluator} with the specified options */
     SubmitRuleEvaluator create(SubmitRuleOptions options);
@@ -80,7 +77,6 @@
   private final PluginSetContext<SubmitRule> submitRules;
   private final Metrics metrics;
   private final SubmitRuleOptions opts;
-  private final CallerFinder callerFinder;
 
   @Inject
   private SubmitRuleEvaluator(
@@ -95,14 +91,6 @@
     this.metrics = metrics;
 
     this.opts = options;
-
-    this.callerFinder =
-        CallerFinder.builder()
-            .addTarget(ChangeApi.class)
-            .addTarget(ChangeJson.class)
-            .addTarget(ChangeData.class)
-            .addTarget(SubmitRequirementsEvaluatorImpl.class)
-            .build();
   }
 
   /**
@@ -113,10 +101,11 @@
    * @param cd ChangeData to evaluate
    */
   public List<SubmitRecord> evaluate(ChangeData cd) {
-    logger.atFine().log(
-        "Evaluate submit rules for change %d (caller: %s)",
-        cd.change().getId().get(), callerFinder.findCallerLazy());
-    try (Timer0.Context ignored = metrics.submitRuleEvaluationLatency.start()) {
+    try (TraceTimer timer =
+            TraceContext.newTimer(
+                "Evaluate submit rules",
+                Metadata.builder().changeId(cd.change().getId().get()).build());
+        Timer0.Context ignored = metrics.submitRuleEvaluationLatency.start()) {
       if (cd.change() == null) {
         throw new StorageException("Change not found");
       }
@@ -151,7 +140,7 @@
           // Skip evaluating the default submit rule if the project has prolog rules.
           // Note that in this case, the prolog submit rule will handle labels for us
           .filter(
-              projectState.hasPrologRules()
+              projectState.hasPrologRules() && prologSubmitRuleUtil.isProjectRulesEnabled()
                   ? rule -> !(rule.get() instanceof DefaultSubmitRule)
                   : rule -> true)
           .map(
@@ -180,14 +169,10 @@
    */
   public SubmitTypeRecord getSubmitType(ChangeData cd) {
     try (Timer0.Context ignored = metrics.submitTypeEvaluationLatency.start()) {
-      try {
-        Project.NameKey name = cd.project();
-        Optional<ProjectState> project = projectCache.get(name);
-        if (!project.isPresent()) {
-          throw new NoSuchProjectException(name);
-        }
-      } catch (NoSuchProjectException e) {
-        throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
+      ProjectState projectState =
+          projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
+      if (!prologSubmitRuleUtil.isProjectRulesEnabled()) {
+        return SubmitTypeRecord.OK(projectState.getSubmitType());
       }
 
       return prologSubmitRuleUtil.getSubmitType(cd);
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/project/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/project/package-info.java
index 0709b86..52df3e2 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/project/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.project;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/project/testing/BUILD b/java/com/google/gerrit/server/project/testing/BUILD
index ab75ec7..bf448ab 100644
--- a/java/com/google/gerrit/server/project/testing/BUILD
+++ b/java/com/google/gerrit/server/project/testing/BUILD
@@ -5,5 +5,8 @@
     testonly = True,
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
-    deps = ["//java/com/google/gerrit/entities"],
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//lib/errorprone:annotations",
+    ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/project/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/project/testing/package-info.java
index 0709b86..bae7ec4 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/project/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.project.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
index 9df01f4..a5a73b2 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.index.query.QueryProcessor;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.account.AccountState;
@@ -44,7 +43,6 @@
  */
 public class AccountQueryProcessor extends QueryProcessor<AccountState> {
   private final AccountControl.Factory accountControlFactory;
-  private final Sequences sequences;
   private final IndexConfig indexConfig;
 
   @Singleton
@@ -70,8 +68,7 @@
       IndexConfig indexConfig,
       AccountIndexCollection indexes,
       AccountIndexRewriter rewriter,
-      AccountControl.Factory accountControlFactory,
-      Sequences sequences) {
+      AccountControl.Factory accountControlFactory) {
     super(
         accountQueryMetrics,
         AccountSchemaDefinitions.INSTANCE,
@@ -81,7 +78,6 @@
         FIELD_LIMIT,
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.accountControlFactory = accountControlFactory;
-    this.sequences = sequences;
     this.indexConfig = indexConfig;
   }
 
@@ -97,14 +93,4 @@
   protected String formatForLogging(AccountState accountState) {
     return accountState.account().id().toString();
   }
-
-  @Override
-  protected int getIndexSize() {
-    return sequences.lastAccountId();
-  }
-
-  @Override
-  protected int getBatchSize() {
-    return sequences.accountBatchSize();
-  }
 }
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index fa1758a..e586477 100644
--- a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -124,7 +125,7 @@
     if (hasPreferredEmailExact()) {
       List<List<AccountState>> r =
           query(emails.stream().map(AccountPredicates::preferredEmailExact).collect(toList()));
-      Multimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
+      ListMultimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
       for (int i = 0; i < emails.size(); i++) {
         accountsByEmail.putAll(emails.get(i), r.get(i));
       }
@@ -137,7 +138,7 @@
 
     List<List<AccountState>> r =
         query(emails.stream().map(AccountPredicates::preferredEmail).collect(toList()));
-    Multimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
+    ListMultimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
     for (int i = 0; i < emails.size(); i++) {
       String email = emails.get(i);
       Set<AccountState> matchingAccounts =
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/query/account/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/query/account/package-info.java
index 0709b86..a4d76ee 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/query/account/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.query.account;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
index 901c51f..971996d 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalContext.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -22,8 +22,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.revwalk.RevWalk;
+import com.google.gerrit.server.update.RepoView;
 
 /** Entity representing all required information to match predicates for copying approvals. */
 @AutoValue
@@ -53,11 +52,7 @@
   /** Whether the new patch set is a merge commit. */
   public abstract boolean isMerge();
 
-  /** {@link RevWalk} of the repository for the current commit. */
-  public abstract RevWalk revWalk();
-
-  /** {@link RevWalk} of the repository for the current commit. */
-  public abstract Config repoConfig();
+  public abstract RepoView repoView();
 
   public static ApprovalContext create(
       ChangeNotes changeNotes,
@@ -68,8 +63,7 @@
       PatchSet targetPatchSet,
       ChangeKind changeKind,
       boolean isMerge,
-      RevWalk revWalk,
-      Config repoConfig) {
+      RepoView repoView) {
     checkState(
         sourcePatchSetId.changeId().equals(targetPatchSet.id().changeId()),
         "approval and target must be the same change. got: %s, %s",
@@ -90,7 +84,6 @@
         changeNotes,
         changeKind,
         isMerge,
-        revWalk,
-        repoConfig);
+        repoView);
   }
 }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
index ed876c1..6ae47ad 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -27,10 +27,12 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.Arrays;
 import java.util.Locale;
 import java.util.Optional;
 
+@Singleton
 public class ApprovalQueryBuilder extends QueryBuilder<ApprovalContext, ApprovalQueryBuilder> {
   private static final QueryBuilder.Definition<ApprovalContext, ApprovalQueryBuilder> mydef =
       new QueryBuilder.Definition<>(ApprovalQueryBuilder.class);
diff --git a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
index 958011c..3b3a2dd 100644
--- a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
@@ -21,9 +21,9 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -34,6 +34,7 @@
 import java.util.Objects;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -57,31 +58,31 @@
 
     Integer parentNum =
         isInitialCommit(ctx.changeNotes().getProjectName(), targetPatchSet.commitId()) ? 0 : 1;
-    try {
+    try (ObjectInserter ins = new InMemoryInserter(ctx.repoView().getRevWalk().getObjectReader())) {
       Map<String, ModifiedFile> baseVsCurrent =
-          diffOperations.loadModifiedFilesAgainstParent(
+          diffOperations.loadModifiedFilesAgainstParentIfNecessary(
               ctx.changeNotes().getProjectName(),
               targetPatchSet.commitId(),
               parentNum,
-              DiffOptions.DEFAULTS,
-              ctx.revWalk(),
-              ctx.repoConfig());
+              ctx.repoView(),
+              ins,
+              /* enableRenameDetection= */ false);
       Map<String, ModifiedFile> baseVsPrior =
-          diffOperations.loadModifiedFilesAgainstParent(
+          diffOperations.loadModifiedFilesAgainstParentIfNecessary(
               ctx.changeNotes().getProjectName(),
               sourcePatchSet.commitId(),
               parentNum,
-              DiffOptions.DEFAULTS,
-              ctx.revWalk(),
-              ctx.repoConfig());
+              ctx.repoView(),
+              ins,
+              /* enableRenameDetection= */ false);
       Map<String, ModifiedFile> priorVsCurrent =
-          diffOperations.loadModifiedFiles(
+          diffOperations.loadModifiedFilesIfNecessary(
               ctx.changeNotes().getProjectName(),
               sourcePatchSet.commitId(),
               targetPatchSet.commitId(),
-              DiffOptions.DEFAULTS,
-              ctx.revWalk(),
-              ctx.repoConfig());
+              ctx.repoView().getRevWalk(),
+              ctx.repoView().getConfig(),
+              /* enableRenameDetection= */ false);
       return match(baseVsCurrent, baseVsPrior, priorVsCurrent);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/query/approval/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/query/approval/package-info.java
index 0709b86..fc0ad07 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/query/approval/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.query.approval;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 4718a69..d153bc9 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -36,6 +36,7 @@
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
@@ -49,7 +50,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRecord;
@@ -167,7 +167,8 @@
     }
 
     for (ChangeData cd : changes) {
-      cd.change();
+      @SuppressWarnings("unused")
+      var unused = cd.change();
     }
   }
 
@@ -178,7 +179,8 @@
     }
 
     for (ChangeData cd : changes) {
-      cd.patchSets();
+      @SuppressWarnings("unused")
+      var unused = cd.patchSets();
     }
   }
 
@@ -189,7 +191,8 @@
     }
 
     for (ChangeData cd : changes) {
-      cd.currentPatchSet();
+      @SuppressWarnings("unused")
+      var unused = cd.currentPatchSet();
     }
   }
 
@@ -200,7 +203,8 @@
     }
 
     for (ChangeData cd : changes) {
-      cd.currentApprovals();
+      @SuppressWarnings("unused")
+      var unused = cd.currentApprovals();
     }
   }
 
@@ -211,7 +215,8 @@
     }
 
     for (ChangeData cd : changes) {
-      cd.messages();
+      @SuppressWarnings("unused")
+      var unused = cd.messages();
     }
   }
 
@@ -227,7 +232,8 @@
       ensureAllPatchSetsLoaded(pending);
       ensureMessagesLoaded(pending);
       for (ChangeData cd : pending) {
-        cd.reviewedBy();
+        @SuppressWarnings("unused")
+        var unused = cd.reviewedBy();
       }
     }
   }
@@ -437,7 +443,7 @@
 
   private ImmutableList<Account.Id> stars;
   private Account.Id starredBy;
-  private ImmutableMap<Account.Id, Ref> starRefs;
+  private ImmutableList<Account.Id> starAccounts;
   private ReviewerSet reviewers;
   private ReviewerByEmailSet reviewersByEmail;
   private ReviewerSet pendingReviewers;
@@ -451,7 +457,7 @@
   private Integer totalCommentCount;
   private LabelTypes labelTypes;
   private Optional<Instant> mergedOn;
-  private ImmutableSetMultimap<NameKey, RefState> refStates;
+  private ImmutableSetMultimap<Project.NameKey, RefState> refStates;
   private ImmutableList<byte[]> refStatePatterns;
   private String changeServerId;
   private ChangeNumberVirtualIdAlgorithm virtualIdFunc;
@@ -528,6 +534,7 @@
    * lazyLoad} is on, the {@code ChangeData} object will load from the database ("lazily") when a
    * field accessor is called.
    */
+  @CanIgnoreReturnValue
   public ChangeData setStorageConstraint(StorageConstraint storageConstraint) {
     this.storageConstraint = storageConstraint;
     return this;
@@ -655,7 +662,7 @@
     }
 
     for (ChangeData cd : changes) {
-      cd.changeServerId();
+      var unused = cd.changeServerId();
     }
   }
 
@@ -687,7 +694,9 @@
         return BranchNameKey.create(project, branch);
       }
       throwIfNotLazyLoad("branch");
-      change();
+
+      @SuppressWarnings("unused")
+      var unused = change();
     }
     return change.getDest();
   }
@@ -698,36 +707,49 @@
         return isPrivate;
       }
       throwIfNotLazyLoad("isPrivate");
-      change();
+
+      @SuppressWarnings("unused")
+      var unused = change();
     }
     return change.isPrivate();
   }
 
+  @CanIgnoreReturnValue
   public ChangeData setMetaRevision(ObjectId metaRevision) {
     this.metaRevision = metaRevision;
     return this;
   }
 
-  public ObjectId metaRevisionOrThrow() {
+  public Optional<ObjectId> metaRevision() {
     if (notes == null) {
       if (metaRevision != null) {
-        return metaRevision;
+        return Optional.of(metaRevision);
       }
       if (refStates != null) {
-        Set<RefState> refs = refStates.get(project);
+        ImmutableSet<RefState> refs = refStates.get(project);
         if (refs != null) {
           String metaRef = RefNames.changeMetaRef(getId());
           for (RefState r : refs) {
             if (r.ref().equals(metaRef)) {
-              return r.id();
+              return Optional.of(r.id());
             }
           }
         }
       }
-      throwIfNotLazyLoad("metaRevision");
-      notes();
+      if (!lazyload()) {
+        return Optional.empty();
+      }
+
+      @SuppressWarnings("unused")
+      var unused = notes();
     }
-    return notes.getRevision();
+    metaRevision = notes.getRevision();
+    return Optional.of(metaRevision);
+  }
+
+  public ObjectId metaRevisionOrThrow() {
+    return metaRevision()
+        .orElseThrow(() -> new IllegalStateException("'metaRevision' field not populated"));
   }
 
   boolean fastIsVisibleTo(CurrentUser user) {
@@ -738,6 +760,7 @@
     visibleTo = user;
   }
 
+  @Nullable
   public Change change() {
     if (change == null && lazyload()) {
       loadChange();
@@ -749,11 +772,13 @@
     change = c;
   }
 
+  @CanIgnoreReturnValue
   public Change reloadChange() {
     metaRevision = null;
     return loadChange();
   }
 
+  @CanIgnoreReturnValue
   private Change loadChange() {
     try {
       notes = notesFactory.createChecked(project, legacyId, metaRevision);
@@ -782,6 +807,7 @@
       }
       notes = notesFactory.create(project(), legacyId, metaRevision);
       change = notes.getChange();
+      setPatchSets(null);
     }
     return notes;
   }
@@ -1166,7 +1192,7 @@
    */
   public Map<SubmitRequirement, SubmitRequirementResult> submitRequirementsIncludingLegacy() {
     Map<SubmitRequirement, SubmitRequirementResult> projectConfigReqs = submitRequirements();
-    Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
+    ImmutableMap<SubmitRequirement, SubmitRequirementResult> legacyReqs =
         SubmitRequirementsAdapter.getLegacyRequirements(this);
     return submitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
         projectConfigReqs, legacyReqs, this);
@@ -1432,7 +1458,7 @@
       if (!lazyload()) {
         return ImmutableList.of();
       }
-      return starRefs().keySet().asList();
+      return starAccounts();
     }
     return stars;
   }
@@ -1441,14 +1467,14 @@
     this.stars = ImmutableList.copyOf(accountIds);
   }
 
-  private ImmutableMap<Account.Id, Ref> starRefs() {
-    if (starRefs == null) {
+  private ImmutableList<Account.Id> starAccounts() {
+    if (starAccounts == null) {
       if (!lazyload()) {
-        return ImmutableMap.of();
+        return ImmutableList.of();
       }
-      starRefs = requireNonNull(starredChangesReader).byChange(virtualId());
+      starAccounts = requireNonNull(starredChangesReader).byChange(virtualId());
     }
-    return starRefs;
+    return starAccounts;
   }
 
   public boolean isStarred(Account.Id accountId) {
@@ -1509,13 +1535,14 @@
     }
   }
 
-  public SetMultimap<NameKey, RefState> getRefStates() {
+  public SetMultimap<Project.NameKey, RefState> getRefStates() {
     if (refStates == null) {
       if (!lazyload()) {
         return ImmutableSetMultimap.of();
       }
 
-      ImmutableSetMultimap.Builder<NameKey, RefState> result = ImmutableSetMultimap.builder();
+      ImmutableSetMultimap.Builder<Project.NameKey, RefState> result =
+          ImmutableSetMultimap.builder();
       for (Table.Cell<Account.Id, PatchSet.Id, Ref> edit : editRefs().cellSet()) {
         result.put(
             project,
@@ -1528,7 +1555,10 @@
       // TODO: instantiating the notes is too much. We don't want to parse NoteDb, we just want the
       // refs.
       result.put(project, RefState.create(notes().getRefName(), notes().getMetaId()));
-      notes().getRobotComments(); // Force loading robot comments.
+
+      @SuppressWarnings("unused")
+      var unused = notes().getRobotComments(); // Force loading robot comments.
+
       RobotCommentNotes robotNotes = notes().getRobotCommentNotes();
       result.put(project, RefState.create(robotNotes.getRefName(), robotNotes.getMetaId()));
 
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index d5cc9e6..d5f9c5b 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -17,6 +17,8 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -33,7 +35,6 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Locale;
-import java.util.Set;
 
 /** Predicates that match against {@link ChangeData}. */
 public class ChangePredicates {
@@ -87,10 +88,13 @@
   /**
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has a pending draft comment.
+   *
+   * <p>The predicates filter by "legacy_id_str" field.
    */
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static Predicate<ChangeData> draftBy(
       DraftCommentsReader draftCommentsReader, Account.Id id) {
-    Set<Predicate<ChangeData>> changeIdPredicates =
+    ImmutableSet<Predicate<ChangeData>> changeIdPredicates =
         draftCommentsReader.getChangesWithDrafts(id).stream()
             .map(ChangePredicates::idStr)
             .collect(toImmutableSet());
@@ -102,10 +106,13 @@
   /**
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has starred changes with {@code label}.
+   *
+   * <p>The predicates filter by "legacy_id_str" field.
    */
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static Predicate<ChangeData> starBy(
       StarredChangesReader starredChangesReader, Account.Id id) {
-    Set<Predicate<ChangeData>> starredChanges =
+    ImmutableSet<Predicate<ChangeData>> starredChanges =
         starredChangesReader.byAccountId(id).stream()
             .map(ChangePredicates::idStr)
             .collect(toImmutableSet());
@@ -144,6 +151,19 @@
   }
 
   /**
+   * Returns a predicate that matches the change number with the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
+   */
+  public static Predicate<ChangeData> changeNumber(
+      Change.Id id, ChangeQueryBuilder.Arguments args) {
+    if (args.getSchema().hasField(ChangeField.CHANGENUM_SPEC)) {
+      return new ChangeIndexCardinalPredicate(
+          ChangeField.CHANGENUM_SPEC, ChangeQueryBuilder.FIELD_CHANGE, id.toString(), 1);
+    }
+    return idStr(id);
+  }
+
+  /**
    * Returns a predicate that matches changes owned by the provided {@link
    * com.google.gerrit.entities.Account.Id}.
    */
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index da14d45..d598739 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -111,6 +111,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -143,7 +144,8 @@
 
   public interface ChangeIsOperandFactory extends ChangeOperandFactory {}
 
-  private static final Pattern PAT_LEGACY_ID = Pattern.compile("^[1-9][0-9]*$");
+  private static final Pattern PAT_CHANGE_NUMBER = Pattern.compile("^[1-9][0-9]*$");
+  private static final Pattern PAT_PROJECT_CHANGE_NUM = Pattern.compile("^([^~]+)~([1-9][0-9]*)$");
   private static final Pattern PAT_CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
   private static final Pattern DEF_CHANGE =
       Pattern.compile("^(?:[1-9][0-9]*|(?:[^~]+~[^~]+~)?[iI][0-9a-f]{4,}.*)$");
@@ -164,6 +166,7 @@
 
   public static final String FIELD_CHANGE = "change";
   public static final String FIELD_CHANGE_ID = "change_id";
+  public static final String FIELD_CHANGE_NUMBER = "changenumber";
   public static final String FIELD_COMMENT = "comment";
   public static final String FIELD_COMMENTBY = "commentby";
   public static final String FIELD_COMMIT = "commit";
@@ -586,6 +589,18 @@
 
   @Operator
   public Predicate<ChangeData> change(String query) throws QueryParseException {
+    return getPredicateChangeData(query, changeId -> ChangePredicates.changeNumber(changeId, args));
+  }
+
+  // Keep using the index legacy document-id (legacy_id_str) for URLs queries like: "/q/123456",
+  // "/q/Iasdw2312321", "/q/project~123456"  that are expecting to always find a single element.
+  private Predicate<ChangeData> defaultSearch(String query) throws QueryParseException {
+    return getPredicateChangeData(query, ChangePredicates::idStr);
+  }
+
+  private Predicate<ChangeData> getPredicateChangeData(
+      String query, Function<Change.Id, Predicate<ChangeData>> changePredicateGetter)
+      throws QueryParseException {
     Optional<ChangeTriplet> triplet = ChangeTriplet.parse(query);
     if (triplet.isPresent()) {
       return Predicate.and(
@@ -593,10 +608,16 @@
           branch(triplet.get().branch().branch()),
           ChangePredicates.idPrefix(parseChangeId(triplet.get().id().get())));
     }
-    if (PAT_LEGACY_ID.matcher(query).matches()) {
+
+    Matcher projectChangeNumber = PAT_PROJECT_CHANGE_NUM.matcher(query);
+    if (projectChangeNumber.matches()) {
+      return Predicate.and(
+          project(projectChangeNumber.group(1)),
+          ChangePredicates.idStr(projectChangeNumber.group(2)));
+    } else if (PAT_CHANGE_NUMBER.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
       if (id != null) {
-        return ChangePredicates.idStr(Change.id(id));
+        return changePredicateGetter.apply(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
       return ChangePredicates.idPrefix(parseChangeId(query));
@@ -1221,7 +1242,7 @@
     if (isSelf(who)) {
       return isVisible();
     }
-    Set<Account.Id> accounts = null;
+    ImmutableSet<Account.Id> accounts = null;
     try {
       accounts = parseAccount(who);
     } catch (QueryParseException e) {
@@ -1280,7 +1301,7 @@
 
   private Predicate<ChangeData> ownerDefaultField(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    Set<Account.Id> accounts = parseAccountIgnoreVisibility(who);
+    ImmutableSet<Account.Id> accounts = parseAccountIgnoreVisibility(who);
     if (accounts.size() > MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
       return Predicate.any();
     }
@@ -1442,7 +1463,7 @@
   @Operator
   public Predicate<ChangeData> from(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    Set<Account.Id> ownerIds = parseAccountIgnoreVisibility(who);
+    ImmutableSet<Account.Id> ownerIds = parseAccountIgnoreVisibility(who);
     return Predicate.or(owner(ownerIds), commentby(ownerIds));
   }
 
@@ -1470,7 +1491,8 @@
     try {
       // [,user=<user>]
       if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
-        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
+        ImmutableSet<Account.Id> accounts =
+            parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
         if (accounts != null && accounts.size() > 1) {
           throw error(
               String.format(
@@ -1559,7 +1581,8 @@
     try {
       // [,user=<user>]
       if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
-        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
+        ImmutableSet<Account.Id> accounts =
+            parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
         if (accounts != null && accounts.size() > 1) {
           throw error(
               String.format(
@@ -1679,13 +1702,13 @@
     } else if (DEF_CHANGE.matcher(query).matches()) {
       List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(2);
       try {
-        predicates.add(change(query));
+        predicates.add(defaultSearch(query));
       } catch (QueryParseException e) {
         // Skip.
       }
 
-      // For PAT_LEGACY_ID, it may also be the prefix of some commits.
-      if (query.length() >= 6 && PAT_LEGACY_ID.matcher(query).matches()) {
+      // For PAT_CHANGE_NUMBER, it may also be the prefix of some commits.
+      if (query.length() >= 6 && PAT_CHANGE_NUMBER.matcher(query).matches()) {
         predicates.add(commit(query));
       }
 
@@ -1785,7 +1808,7 @@
     return accounts;
   }
 
-  private Set<Account.Id> parseAccount(String who)
+  private ImmutableSet<Account.Id> parseAccount(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     try {
       return args.accountResolver.resolveAsUser(args.getUser(), who).asNonEmptyIdSet();
@@ -1797,7 +1820,7 @@
     }
   }
 
-  private Set<Account.Id> parseAccountIgnoreVisibility(String who)
+  private ImmutableSet<Account.Id> parseAccountIgnoreVisibility(String who)
       throws QueryRequiresAuthException, IOException, ConfigInvalidException {
     try {
       return args.accountResolver
@@ -1811,7 +1834,7 @@
     }
   }
 
-  private Set<Account.Id> parseAccountIgnoreVisibility(
+  private ImmutableSet<Account.Id> parseAccountIgnoreVisibility(
       String who, java.util.function.Predicate<AccountState> activityFilter)
       throws QueryRequiresAuthException, IOException, ConfigInvalidException {
     try {
@@ -1849,12 +1872,12 @@
   }
 
   private List<ChangeData> parseChangeData(String value) throws QueryParseException {
-    if (PAT_LEGACY_ID.matcher(value).matches()) {
+    if (PAT_CHANGE_NUMBER.matcher(value).matches()) {
       Optional<Change.Id> id = Change.Id.tryParse(value);
       if (!id.isPresent()) {
         throw error("Invalid change id " + value);
       }
-      return args.queryProvider.get().byLegacyChangeId(id.get());
+      return args.queryProvider.get().byChangeNumber(id.get());
     } else if (PAT_CHANGE_ID.matcher(value).matches()) {
       List<ChangeData> changes = args.queryProvider.get().byKeyPrefix(parseChangeId(value));
       if (changes.isEmpty()) {
@@ -1889,7 +1912,7 @@
 
     Predicate<ChangeData> reviewerPredicate = null;
     try {
-      Set<Account.Id> accounts = parseAccountIgnoreVisibility(who);
+      ImmutableSet<Account.Id> accounts = parseAccountIgnoreVisibility(who);
       if (!forDefaultField || accounts.size() <= MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
         reviewerPredicate =
             Predicate.or(
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 305316d..3ada9d7 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
@@ -64,7 +64,6 @@
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
   private final List<Extension<ChangePluginDefinedInfoFactory>>
       changePluginDefinedInfoFactoriesByPlugin = new ArrayList<>();
-  private final Sequences sequences;
   private final IndexConfig indexConfig;
 
   @Singleton
@@ -90,7 +89,6 @@
       IndexConfig indexConfig,
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
-      Sequences sequences,
       ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory,
       DynamicSet<ChangePluginDefinedInfoFactory> changePluginDefinedInfoFactories) {
     super(
@@ -103,7 +101,6 @@
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.userProvider = userProvider;
     this.changeIsVisibleToPredicateFactory = changeIsVisibleToPredicateFactory;
-    this.sequences = sequences;
     this.indexConfig = indexConfig;
 
     changePluginDefinedInfoFactories
@@ -112,6 +109,7 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public ChangeQueryProcessor enforceVisibility(boolean enforce) {
     super.enforceVisibility(enforce);
     return this;
@@ -171,16 +169,6 @@
   }
 
   @Override
-  protected int getIndexSize() {
-    return sequences.lastChangeId();
-  }
-
-  @Override
-  protected int getBatchSize() {
-    return sequences.changeBatchSize();
-  }
-
-  @Override
   protected int getInitialPageSize(int limit) {
     return Math.min(getUserQueryLimit().getAsInt(), limit);
   }
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index fc4c1d0..87b8915 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -89,7 +89,7 @@
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
     and.add(ChangePredicates.project(c.getProject()));
     and.add(ChangePredicates.ref(c.getDest().branch()));
-    and.add(Predicate.not(ChangePredicates.idStr(c.getId())));
+    and.add(Predicate.not(ChangePredicates.changeNumber(c.getId(), args)));
     and.add(Predicate.or(filePredicates));
 
     ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
index 0d6dc3c..aba6a98 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -35,7 +36,6 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData.StorageConstraint;
 import java.io.IOException;
-import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -137,6 +137,8 @@
       Change c = cd.change();
       if (c == null) {
         // The change has disappeared.
+        logger.atFine().log(
+            "%s=%s doesn't match because the change has disappeared.", label, expVal);
         return false;
       }
 
@@ -145,17 +147,28 @@
         // in the index. We do that since computing count=0 requires looping on all {label_type,
         // vote_value} for the change and storing a {count=0} format for it in the change index
         // which is computationally expensive.
+        logger.atFine().log(
+            "%s=%s doesn't match change %s because the count was specified as 0 which is not"
+                + " supported.",
+            label, expVal, cd.change().getChangeId());
         return false;
       }
 
       Optional<ProjectState> project = projectCache.get(c.getDest().project());
       if (!project.isPresent()) {
         // The project has disappeared.
+        logger.atFine().log(
+            "%s=%s doesn't match change %s because its project %s has disappeared.",
+            label, expVal, cd.change().getChangeId(), c.getDest().project().get());
         return false;
       }
 
       LabelType labelType = type(project.get().getLabelTypes(), label);
       if (labelType == null) {
+        logger.atFine().log(
+            "%s=%s doesn't match change %s because the label is not defined by its project %s"
+                + " (label type = %s)",
+            label, expVal, cd.change().getChangeId(), project.get(), project.get().getLabelTypes());
         return false; // Label is not defined by this project.
       }
 
@@ -171,16 +184,51 @@
           }
         }
       }
+      logger.atFine().log(
+          "found %s matching votes for %s=%s on change %s (current approvals = %s)",
+          matchingVotes, label, expVal, cd.change().getChangeId(), cd.currentApprovals());
       cd.setStorageConstraint(currentStorageConstraint);
       if (!hasVote && expVal == 0) {
+        logger.atFine().log(
+            "%s=%s matches change %s because there is no vote for label %s",
+            label, expVal, cd.change().getChangeId(), label);
         return true;
       }
 
-      return count == null ? matchingVotes >= 1 : matchingVotes == count;
+      if (count == null) {
+        if (matchingVotes >= 1) {
+          logger.atFine().log(
+              "%s=%s matches change %s because there are %s matching votes (count was not"
+                  + " specified, hence 1 or more votes are needed)",
+              label, expVal, cd.change().getChangeId(), matchingVotes);
+          return true;
+        }
+        logger.atFine().log(
+            "%s=%s doesn't match change %s because there are no matching votes (count was not"
+                + " specified, hence 1 or more votes are needed)",
+            label, expVal, cd.change().getChangeId());
+        return false;
+      }
+
+      if (matchingVotes == count) {
+        logger.atFine().log(
+            "%s=%s matches change %s because there are %s matching votes which matches the"
+                + " expected count %s",
+            label, expVal, cd.change().getChangeId(), matchingVotes, count);
+        return true;
+      }
+      logger.atFine().log(
+          "%s=%s doesn't match change %s because there are %s matching votes which doesn't match"
+              + " the expected count %s",
+          label, expVal, cd.change().getChangeId(), matchingVotes, count);
+      return false;
     }
 
     private boolean match(ChangeData cd, PatchSetApproval psa) {
       if (psa.value() != expVal) {
+        logger.atFine().log(
+            "vote %s on change %s doesn't match expected value %s",
+            psa, cd.change().getChangeId(), expVal);
         return false;
       }
       Account.Id approver = psa.accountId();
@@ -188,18 +236,27 @@
       if (account != null) {
         // case when account in query is numeric
         if (!account.equals(approver) && !isMagicUser()) {
+          logger.atFine().log(
+              "vote %s on change %s doesn't match expected approver %s",
+              psa, cd.change().getChangeId(), account);
           return false;
         }
 
         // case when account in query = owner
         if (account.equals(ChangeQueryBuilder.OWNER_ACCOUNT_ID)
             && !cd.change().getOwner().equals(approver)) {
+          logger.atFine().log(
+              "vote %s on change %s doesn't match since it is not from the change owner %s",
+              psa, cd.change().getChangeId(), cd.change().getOwner());
           return false;
         }
 
         // case when account in query = non_uploader
         if (account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID)
             && cd.currentPatchSet().uploader().equals(approver)) {
+          logger.atFine().log(
+              "vote %s on change %s doesn't match since it is not from the uploader %s",
+              psa, cd.change().getChangeId(), cd.currentPatchSet().uploader());
           return false;
         }
 
@@ -207,6 +264,14 @@
           if ((cd.currentPatchSet().uploader().equals(approver)
               || matchAccount(cd.getCommitter().getEmailAddress(), approver)
               || matchAccount(cd.getAuthor().getEmailAddress(), approver))) {
+            logger.atFine().log(
+                "vote %s on change %s doesn't match since it is not from a contributor"
+                    + " (uploader: %s, committer: %s, author: %s)",
+                psa,
+                cd.change().getChangeId(),
+                cd.currentPatchSet().uploader(),
+                cd.getCommitter().getEmailAddress(),
+                cd.getAuthor().getEmailAddress());
             return false;
           }
         }
@@ -214,6 +279,10 @@
 
       IdentifiedUser reviewer = userFactory.create(approver);
       if (group != null && !reviewer.getEffectiveGroups().contains(group)) {
+        logger.atFine().log(
+            "vote %s on change %s doesn't match since the approver %s is not a member of the"
+                + " expected group %s",
+            psa, cd.change().getChangeId(), approver, group);
         return false;
       }
 
@@ -221,12 +290,19 @@
       try {
         PermissionBackend.ForChange perm = permissionBackend.absentUser(approver).change(cd);
         if (!projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false)) {
+          logger.atFine().log(
+              "vote %s on change %s doesn't match since the project %s doesn't permit read",
+              psa, cd.change().getChangeId(), cd.project().get());
           return false;
         }
 
         perm.check(ChangePermission.READ);
+        logger.atFine().log("vote %s on change %s matches", psa, cd.change().getChangeId());
         return true;
       } catch (PermissionBackendException | AuthException e) {
+        logger.atFine().log(
+            "vote %s on change %s doesn't match because the approver %s has no read access",
+            psa, cd.change().getChangeId(), approver);
         return false;
       }
     }
@@ -237,7 +313,7 @@
      */
     private boolean matchAccount(String email, Account.Id accountId) {
       try {
-        List<AccountState> accountsList = accountResolver.resolve(email).asList();
+        ImmutableList<AccountState> accountsList = accountResolver.resolve(email).asList();
         return accountsList.stream().anyMatch(c -> c.account().id().equals(accountId));
       } catch (ConfigInvalidException | IOException e) {
         logger.atWarning().withCause(e).log("Failed to resolve account %s", email);
diff --git a/java/com/google/gerrit/server/query/change/GroupPredicate.java b/java/com/google/gerrit/server/query/change/GroupPredicate.java
index c4aba0d..bff2575 100644
--- a/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.index.change.ChangeField;
-import java.util.List;
 
 public class GroupPredicate extends ChangeIndexPredicate {
   public GroupPredicate(String group) {
@@ -26,7 +26,7 @@
   @Override
   public boolean match(ChangeData cd) {
     for (PatchSet ps : cd.patchSets()) {
-      List<String> groups = ps.groups();
+      ImmutableList<String> groups = ps.groups();
       if (groups != null && groups.contains(getValue())) {
         return true;
       }
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 3c7944c..a8ee4bc 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -88,6 +88,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory notesFactory;
   private final EditByPredicateProvider editByPredicateProvider;
+  private final Provider<ChangeQueryBuilder.Arguments> queryBuilderArgsProvider;
 
   @Inject
   InternalChangeQuery(
@@ -96,11 +97,13 @@
       IndexConfig indexConfig,
       ChangeData.Factory changeDataFactory,
       ChangeNotes.Factory notesFactory,
-      EditByPredicateProvider editByPredicateProvider) {
+      EditByPredicateProvider editByPredicateProvider,
+      Provider<ChangeQueryBuilder.Arguments> queryBuilderArgsProvider) {
     super(queryProcessor, indexes, indexConfig);
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
     this.editByPredicateProvider = editByPredicateProvider;
+    this.queryBuilderArgsProvider = queryBuilderArgsProvider;
   }
 
   public List<ChangeData> byKey(Change.Key key) {
@@ -115,6 +118,10 @@
     return query(ChangePredicates.idStr(id));
   }
 
+  public List<ChangeData> byChangeNumber(Change.Id id) {
+    return query(ChangePredicates.changeNumber(id, queryBuilderArgsProvider.get()));
+  }
+
   @UsedAt(UsedAt.Project.GOOGLE)
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
@@ -200,7 +207,7 @@
     return Lists.transform(notes, n -> changeDataFactory.create(n));
   }
 
-  private List<ChangeData> byCommitsOnBranchNotMergedFromIndex(
+  private ImmutableList<ChangeData> byCommitsOnBranchNotMergedFromIndex(
       BranchNameKey branch, Collection<String> hashes) {
     return query(
         and(
@@ -319,7 +326,7 @@
     for (List<String> part : Iterables.partition(groups, batchSize)) {
       for (ChangeData cd :
           queryExhaustively(querySupplier, byProjectGroupsPredicate(indexConfig, project, part))) {
-        if (!seen.add(cd.getId())) {
+        if (!seen.add(cd.virtualId())) {
           result.add(cd);
         }
       }
diff --git a/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java b/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
index 56848a5a..7144d3e 100644
--- a/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
@@ -23,9 +23,9 @@
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.mail.send.ProjectWatch;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 
 public class IsWatchedByPredicate extends AndPredicate<ChangeData> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -44,7 +44,7 @@
     this.user = args.getUser();
   }
 
-  protected static List<Predicate<ChangeData>> filters(ChangeQueryBuilder.Arguments args)
+  protected static ImmutableList<Predicate<ChangeData>> filters(ChangeQueryBuilder.Arguments args)
       throws QueryParseException {
     List<Predicate<ChangeData>> r = new ArrayList<>();
     ProjectWatch.WatcherChangeQueryBuilder builder =
@@ -84,7 +84,7 @@
     return ImmutableList.of(or(r));
   }
 
-  protected static Collection<ProjectWatchKey> getWatches(ChangeQueryBuilder.Arguments args)
+  protected static Set<ProjectWatchKey> getWatches(ChangeQueryBuilder.Arguments args)
       throws QueryParseException {
     CurrentUser user = args.getUser();
     if (user.isIdentifiedUser()) {
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 5a38958..dc859a3 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -37,6 +37,12 @@
   protected static final int MAX_LABEL_VALUE = 4;
   protected static final int MAX_COUNT = 5; // inclusive
 
+  // Set a different max for label counts for in-memory label predicates. This is because the
+  // in-memory count is used by submit requirements to evaluate different expressions
+  // (applicability, submittability, override). The other MAX_COUNT is used by the change index and
+  // change queries.
+  private static final int MAX_COUNT_INTERNAL = 50; // inclusive
+
   protected static class Args {
     protected final AccountResolver accountResolver;
     protected final ProjectCache projectCache;
@@ -244,7 +250,7 @@
     switch (countOp) {
       case GREATER:
       case GREATER_EQUAL:
-        IntStream.range(count + 1, MAX_COUNT + 1).forEach(result::add);
+        IntStream.range(count + 1, MAX_COUNT_INTERNAL + 1).forEach(result::add);
         break;
       case LESS:
       case LESS_EQUAL:
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
index 420ab61d..fadffd7 100644
--- a/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
@@ -150,16 +150,30 @@
     public boolean match(ChangeData cd) {
       Change change = cd.change();
       if (change == null) {
+        logger.atFine().log(
+            "%s doesn't match because the change has disappeared.", magicLabelVote.formatLabel());
         return false; // The change has disappeared.
       }
 
       Optional<ProjectState> project = args.projectCache.get(change.getDest().project());
       if (!project.isPresent()) {
+        logger.atFine().log(
+            "%s doesn't match change %s because its project %s has disappeared.",
+            magicLabelVote.formatLabel(),
+            cd.change().getChangeId(),
+            change.getDest().project().get());
         return false; // The project has disappeared.
       }
 
       LabelType labelType = type(project.get().getLabelTypes(), magicLabelVote.label());
       if (labelType == null) {
+        logger.atFine().log(
+            "%s doesn't match change %s because the label is not defined by its project %s (label"
+                + " types = %s)",
+            magicLabelVote.formatLabel(),
+            cd.change().getChangeId(),
+            project.get(),
+            project.get().getLabelTypes());
         return false; // Label is not defined by this project.
       }
 
diff --git a/java/com/google/gerrit/server/query/change/OrSource.java b/java/com/google/gerrit/server/query/change/OrSource.java
index f2de536..8894287 100644
--- a/java/com/google/gerrit/server/query/change/OrSource.java
+++ b/java/com/google/gerrit/server/query/change/OrSource.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.index.query.ResultSet;
 import java.util.Collection;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 
@@ -45,7 +44,7 @@
   public ResultSet<ChangeData> read() {
     // ResultSets are lazy. Calling #read here first and then dealing with ResultSets only when
     // requested allows the index to run asynchronous queries.
-    List<ResultSet<ChangeData>> results =
+    ImmutableList<ResultSet<ChangeData>> results =
         getChildren().stream().map(p -> ((ChangeDataSource) p).read()).collect(toImmutableList());
     return new LazyResultSet<>(
         () -> {
@@ -53,7 +52,7 @@
           Set<Change.Id> have = new HashSet<>();
           for (ResultSet<ChangeData> resultSet : results) {
             for (ChangeData result : resultSet) {
-              if (have.add(result.getId())) {
+              if (have.add(result.virtualId())) {
                 r.add(result);
               }
             }
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index d21f5b6..d3b5605 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
@@ -125,7 +126,7 @@
   }
 
   void setLimit(int n) {
-    queryProcessor.setUserProvidedLimit(n);
+    queryProcessor.setUserProvidedLimit(n, /* applyDefaultLimit */ false);
   }
 
   public void setNoLimit(boolean on) {
@@ -348,7 +349,7 @@
       eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
     }
 
-    List<PluginDefinedInfo> pluginInfos = pluginInfosByChange.get(d.getId());
+    ImmutableList<PluginDefinedInfo> pluginInfos = pluginInfosByChange.get(d.getId());
     if (!pluginInfos.isEmpty()) {
       c.plugins = pluginInfos;
     }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/query/change/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/query/change/package-info.java
index 0709b86..a269041 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/query/change/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.query.change;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
index 89c802d..ee910f1 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
@@ -17,6 +17,8 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.Account;
@@ -37,7 +39,6 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
-import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Parses a query string meant to be applied to group objects. */
@@ -135,8 +136,8 @@
   @Operator
   public Predicate<InternalGroup> member(String query)
       throws QueryParseException, ConfigInvalidException, IOException {
-    Set<Account.Id> accounts = parseAccount(query);
-    List<Predicate<InternalGroup>> predicates =
+    ImmutableSet<Account.Id> accounts = parseAccount(query);
+    ImmutableList<Predicate<InternalGroup>> predicates =
         accounts.stream().map(GroupPredicates::member).collect(toImmutableList());
     return Predicate.or(predicates);
   }
@@ -156,7 +157,7 @@
     return new LimitPredicate<>(FIELD_LIMIT, limit);
   }
 
-  private Set<Account.Id> parseAccount(String nameOrEmail)
+  private ImmutableSet<Account.Id> parseAccount(String nameOrEmail)
       throws QueryParseException, IOException, ConfigInvalidException {
     try {
       return args.accountResolver.resolve(nameOrEmail).asNonEmptyIdSet();
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
index e08ff1c..8c97bb2 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.index.query.QueryProcessor;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
@@ -45,7 +44,6 @@
 public class GroupQueryProcessor extends QueryProcessor<InternalGroup> {
   private final Provider<CurrentUser> userProvider;
   private final GroupControl.GenericFactory groupControlFactory;
-  private final Sequences sequences;
   private final IndexConfig indexConfig;
 
   @Singleton
@@ -71,8 +69,7 @@
       IndexConfig indexConfig,
       GroupIndexCollection indexes,
       GroupIndexRewriter rewriter,
-      GroupControl.GenericFactory groupControlFactory,
-      Sequences sequences) {
+      GroupControl.GenericFactory groupControlFactory) {
     super(
         groupQueryMetrics,
         GroupSchemaDefinitions.INSTANCE,
@@ -83,7 +80,6 @@
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.userProvider = userProvider;
     this.groupControlFactory = groupControlFactory;
-    this.sequences = sequences;
     this.indexConfig = indexConfig;
   }
 
@@ -100,14 +96,4 @@
   protected String formatForLogging(InternalGroup internalGroup) {
     return internalGroup.getGroupUUID().get();
   }
-
-  @Override
-  protected int getIndexSize() {
-    return sequences.lastGroupId();
-  }
-
-  @Override
-  protected int getBatchSize() {
-    return sequences.groupBatchSize();
-  }
 }
diff --git a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
index 078acd4..29163a4 100644
--- a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
+++ b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
@@ -72,7 +72,7 @@
       ImmutableSet<AccountGroup.UUID> subgroupIds) {
     List<Predicate<InternalGroup>> predicates =
         subgroupIds.stream().map(e -> GroupPredicates.subgroup(e)).collect(Collectors.toList());
-    List<InternalGroup> groups = query(Predicate.or(predicates));
+    ImmutableList<InternalGroup> groups = query(Predicate.or(predicates));
 
     Map<AccountGroup.UUID, Set<AccountGroup.UUID>> parentsByChild =
         Maps.newHashMapWithExpectedSize(groups.size());
@@ -90,7 +90,7 @@
 
   private Optional<InternalGroup> getOnlyGroup(
       Predicate<InternalGroup> predicate, String groupDescription) {
-    List<InternalGroup> groups = query(predicate);
+    ImmutableList<InternalGroup> groups = query(predicate);
     if (groups.isEmpty()) {
       return Optional.empty();
     }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/query/group/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/query/group/package-info.java
index 0709b86..22affc7 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/query/group/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.query.group;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
index ddc7ccc..c24c439 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -47,7 +46,6 @@
 public class ProjectQueryProcessor extends QueryProcessor<ProjectData> {
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> userProvider;
-  private final ProjectCache projectCache;
   private final IndexConfig indexConfig;
 
   @Singleton
@@ -73,8 +71,7 @@
       IndexConfig indexConfig,
       ProjectIndexCollection indexes,
       ProjectIndexRewriter rewriter,
-      PermissionBackend permissionBackend,
-      ProjectCache projectCache) {
+      PermissionBackend permissionBackend) {
     super(
         projectQueryMetrics,
         ProjectSchemaDefinitions.INSTANCE,
@@ -85,7 +82,6 @@
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.permissionBackend = permissionBackend;
     this.userProvider = userProvider;
-    this.projectCache = projectCache;
     this.indexConfig = indexConfig;
   }
 
@@ -102,14 +98,4 @@
   protected String formatForLogging(ProjectData projectData) {
     return projectData.getProject().getName();
   }
-
-  @Override
-  protected int getIndexSize() {
-    return projectCache.all().size();
-  }
-
-  @Override
-  protected int getBatchSize() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/query/project/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/query/project/package-info.java
index 0709b86..e8444ba 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/query/project/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.query.project;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java b/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
index c659975..c5aa84c 100644
--- a/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
+++ b/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
@@ -65,7 +65,8 @@
 
     // PluginSets can change their content when plugins (de-)register. Copy the currently registered
     // plugins so that we can iterate twice on a stable list.
-    List<PluginSetEntryContext<QuotaEnforcer>> enforcers = ImmutableList.copyOf(quotaEnforcers);
+    ImmutableList<PluginSetEntryContext<QuotaEnforcer>> enforcers =
+        ImmutableList.copyOf(quotaEnforcers);
     List<QuotaResponse> responses = new ArrayList<>(enforcers.size());
     for (PluginSetEntryContext<QuotaEnforcer> enforcer : enforcers) {
       try {
@@ -107,7 +108,8 @@
       QuotaRequestContext requestContext) {
     // PluginSets can change their content when plugins (de-)register. Copy the currently registered
     // plugins so that we can iterate twice on a stable list.
-    List<PluginSetEntryContext<QuotaEnforcer>> enforcers = ImmutableList.copyOf(quotaEnforcers);
+    ImmutableList<PluginSetEntryContext<QuotaEnforcer>> enforcers =
+        ImmutableList.copyOf(quotaEnforcers);
     List<QuotaResponse> responses = new ArrayList<>(enforcers.size());
     for (PluginSetEntryContext<QuotaEnforcer> enforcer : enforcers) {
       responses.add(enforcer.call(p -> p.availableTokens(quotaGroup, requestContext)));
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/quota/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/quota/package-info.java
index 0709b86..d309f97 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/quota/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.quota;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/restapi/access/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/restapi/access/package-info.java
index 0709b86..7e525eb 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/restapi/access/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.restapi.access;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index 57cd466..fe925ce 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -162,7 +162,9 @@
         throw new ResourceConflictException(e.getMessage());
       }
       if (input.preferred) {
-        putPreferred.apply(new AccountResource.Email(user, email), null);
+        @SuppressWarnings("unused")
+        var unused = putPreferred.apply(new AccountResource.Email(user, email), null);
+
         info.preferred = true;
       }
     } else {
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
index cbbaac5..79038af 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
@@ -18,6 +18,8 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
@@ -44,6 +46,7 @@
 import com.google.gerrit.server.restapi.change.CommentJson;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdates;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
@@ -62,7 +65,8 @@
 @Singleton
 public class DeleteDraftCommentsUtil {
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final ChangeQueryBuilder queryBuilder;
+  private final BatchUpdates batchUpdates;
+  private final Supplier<ChangeQueryBuilder> queryBuilderSupplier;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeJson.Factory changeJsonFactory;
@@ -75,7 +79,8 @@
   @Inject
   public DeleteDraftCommentsUtil(
       BatchUpdate.Factory batchUpdateFactory,
-      ChangeQueryBuilder queryBuilder,
+      BatchUpdates batchUpdates,
+      Provider<ChangeQueryBuilder> queryBuilderProvider,
       Provider<InternalChangeQuery> queryProvider,
       ChangeData.Factory changeDataFactory,
       ChangeJson.Factory changeJsonFactory,
@@ -84,7 +89,8 @@
       DraftCommentsReader draftCommentsReader,
       PatchSetUtil psUtil) {
     this.batchUpdateFactory = batchUpdateFactory;
-    this.queryBuilder = queryBuilder;
+    this.batchUpdates = batchUpdates;
+    this.queryBuilderSupplier = Suppliers.memoize(queryBuilderProvider::get);
     this.queryProvider = queryProvider;
     this.changeDataFactory = changeDataFactory;
     this.changeJsonFactory = changeJsonFactory;
@@ -120,7 +126,7 @@
       // were,
       // all updates from this operation only happen in All-Users and thus are fully atomic, so
       // allowing partial failure would have little value.
-      BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
+      batchUpdates.execute(updates.values(), ImmutableList.of(), false);
     }
     return ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList());
   }
@@ -132,7 +138,7 @@
       return hasDraft;
     }
     try {
-      return Predicate.and(hasDraft, queryBuilder.parse(query));
+      return Predicate.and(hasDraft, queryBuilderSupplier.get().parse(query));
     } catch (QueryParseException e) {
       throw new BadRequestException("Invalid query: " + e.getMessage(), e);
     }
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
index eb2be10..b9c64e6 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAgreements.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.common.collect.ImmutableCollection;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.ContributorAgreement;
@@ -38,7 +39,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 
@@ -93,7 +93,7 @@
     }
 
     List<AgreementInfo> results = new ArrayList<>();
-    Collection<ContributorAgreement> cas =
+    ImmutableCollection<ContributorAgreement> cas =
         projectCache.getAllProjects().getConfig().getContributorAgreements().values();
     for (ContributorAgreement ca : cas) {
       List<AccountGroup.UUID> groupIds = new ArrayList<>();
diff --git a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
index d7a5da11..613a651 100644
--- a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
+++ b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
@@ -35,7 +36,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
@@ -71,7 +71,7 @@
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
-    Collection<ExternalId> ids = externalIds.byAccount(resource.getUser().getAccountId());
+    ImmutableSet<ExternalId> ids = externalIds.byAccount(resource.getUser().getAccountId());
     if (ids.isEmpty()) {
       return Response.ok(ImmutableList.of());
     }
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index 2131070..3f543af 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -100,7 +100,8 @@
 
       if (!Strings.isNullOrEmpty(info.filter)) {
         try {
-          QueryParser.parse(info.filter);
+          @SuppressWarnings("unused")
+          var unused = QueryParser.parse(info.filter);
         } catch (QueryParseException e) {
           throw new BadRequestException(
               String.format(
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index 9a11891..b1af85e 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -42,7 +43,6 @@
 import java.io.IOException;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -124,7 +124,7 @@
                       // user doesn't have an external ID for this email
                       if (user.hasEmailAddress(preferredEmail)) {
                         // but Realm says the user is allowed to use this email
-                        Set<ExternalId> existingExtIdsWithThisEmail =
+                        ImmutableSet<ExternalId> existingExtIdsWithThisEmail =
                             externalIds.byEmail(preferredEmail);
                         if (!existingExtIdsWithThisEmail.isEmpty()) {
                           // but the email is already assigned to another account
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index 9fc0c42..8966ec4 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -197,9 +197,7 @@
       throw new MethodNotAllowedException("query disabled");
     }
 
-    if (limit != null) {
-      queryProcessor.setUserProvidedLimit(limit);
-    }
+    queryProcessor.setUserProvidedLimit(limit != null ? limit : 0, /* applyDefaultLimit */ true);
 
     if (start != null) {
       if (start < 0) {
@@ -213,7 +211,7 @@
       Predicate<AccountState> queryPred;
       if (suggest) {
         queryPred = queryBuilder.defaultQuery(query);
-        queryProcessor.setUserProvidedLimit(suggestLimit);
+        queryProcessor.setUserProvidedLimit(suggestLimit, /* applyDefaultLimit */ true);
       } else {
         queryPred = queryBuilder.parse(query);
       }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/restapi/account/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/restapi/account/package-info.java
index 0709b86..491593b 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/restapi/account/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.restapi.account;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
index 6adde99..7d8c793 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -163,7 +163,7 @@
 
       RevCommit latestPatchset = revWalk.parseCommit(destChange.currentPatchSet().commitId());
       RevCommit baseCommit;
-      List<RevCommit> parents;
+      ImmutableList<RevCommit> parents;
       if (!Strings.isNullOrEmpty(input.base)) {
         baseCommit =
             CommitUtil.getBaseCommit(
@@ -244,6 +244,13 @@
         hasInputCommitMessage ? input.commitMessage : latestPatchset.getFullMessage();
     // Since we might add error information to the message, we need to split the footers from the
     // actual description.
+    // TODO: Fix parsing footers from the commit message. FooterLine#fromMessage expects the raw
+    // commit message that contains header lines, see RawParseUtils#commitMessage which is invoked
+    // from FooterLine#fromMessage. RawParseUtils#commitMessage always increases the pointer by 46
+    // to skip the "tree ..." line and if this line is not present the parsing of the footers is
+    // broken. This can lead to no footers being found although a Change-Id footer is present. This
+    // causes us to add the Change-Id again and as a result we end up with a commit message that
+    // contains the Change-Id line twice.
     List<FooterLine> footerLines = FooterLine.fromMessage(fullMessage);
     String messageWithNoFooters = removeFooters(fullMessage, footerLines);
     if (FooterLine.getValues(footerLines, FOOTER_CHANGE_ID).isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
index c0c5f56..56b3842 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
@@ -17,6 +17,8 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.hash.HashCode;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -152,7 +154,7 @@
                 + "\n[[[Original patch trimmed due to size. Decoded string size: "
                 + patchDescription.length()
                 + ". Decoded string SHA1: "
-                + Hashing.sha1().hashString(patchDescription, UTF_8)
+                + sha1(patchDescription)
                 + ".]]]");
       }
     }
@@ -166,7 +168,7 @@
                 + "\n[[[Result patch trimmed due to size. Decoded string size: "
                 + resultPatch.length()
                 + ". Decoded string SHA1: "
-                + Hashing.sha1().hashString(resultPatch, UTF_8)
+                + sha1(resultPatch)
                 + ".]]]");
       }
     }
@@ -213,11 +215,24 @@
   }
 
   private static String decodeIfNecessary(String patch) {
-    if (Base64.isBase64(patch)) {
-      return new String(org.eclipse.jgit.util.Base64.decode(patch), UTF_8);
+    if (Base64.isBase64(patch.getBytes(UTF_8))) {
+      try {
+        return new String(org.eclipse.jgit.util.Base64.decode(patch), UTF_8);
+      } catch (IllegalArgumentException e) {
+        // It's possible that all the chars in the patch are valid Base64 chars, but the full string
+        // is not a valid Base64 string as expected by jGit. In this case, we assume the patch is
+        // already unencoded.
+        return patch;
+      }
     }
     return patch;
   }
 
+  @SuppressWarnings("deprecation")
+  @VisibleForTesting
+  public static HashCode sha1(String s) {
+    return Hashing.sha1().hashString(s, UTF_8);
+  }
+
   private ApplyPatchUtil() {}
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 8a7beb6..0877e62 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -28,10 +28,12 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.ChangeEditIdentityType;
 import com.google.gerrit.extensions.api.changes.FileContentInput;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -49,21 +51,28 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.change.ChangeEditResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.FileContentUtil;
 import com.google.gerrit.server.change.FileInfoJson;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditJson;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -72,6 +81,7 @@
 import java.util.Optional;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -134,8 +144,7 @@
         ChangeResource resource, IdString id, FileContentInput fileContentInput)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
-      putEdit.apply(resource, id.get(), fileContentInput);
-      return Response.none();
+      return putEdit.apply(resource, id.get(), fileContentInput);
     }
   }
 
@@ -558,4 +567,87 @@
       throw new ResourceNotFoundException();
     }
   }
+
+  @Singleton
+  public static class EditIdentity implements RestModifyView<ChangeResource, EditIdentity.Input> {
+    public static class Input {
+      public String name;
+      public String email;
+      public ChangeEditIdentityType type;
+    }
+
+    private final ChangeEditModifier editModifier;
+    private final GitRepositoryManager repositoryManager;
+    private final PermissionBackend permissionBackend;
+    private final Provider<CurrentUser> self;
+    private final Provider<PersonIdent> serverIdent;
+    private final DynamicItem<UrlFormatter> urlFormatter;
+
+    @Inject
+    EditIdentity(
+        ChangeEditModifier editModifier,
+        GitRepositoryManager repositoryManager,
+        PermissionBackend permissionBackend,
+        Provider<CurrentUser> self,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        DynamicItem<UrlFormatter> urlFormatter) {
+      this.editModifier = editModifier;
+      this.repositoryManager = repositoryManager;
+      this.permissionBackend = permissionBackend;
+      this.self = self;
+      this.serverIdent = serverIdent;
+      this.urlFormatter = urlFormatter;
+    }
+
+    @Override
+    public Response<Object> apply(ChangeResource rsrc, EditIdentity.Input input)
+        throws AuthException, IOException, BadRequestException, ResourceConflictException,
+            PermissionBackendException {
+      if (input == null || input.type == null) {
+        throw new BadRequestException("type must be provided");
+      }
+      if (input.name == null && input.email == null) {
+        throw new BadRequestException("name or email must be provided");
+      }
+      input.name = Strings.nullToEmpty(input.name);
+      input.email = Strings.nullToEmpty(input.email);
+
+      RefPermission perm;
+      switch (input.type) {
+        case AUTHOR:
+          perm = RefPermission.FORGE_AUTHOR;
+          break;
+        case COMMITTER:
+        default:
+          perm = RefPermission.FORGE_COMMITTER;
+          break;
+      }
+
+      PersonIdent newIdent =
+          new PersonIdent(input.name, input.email, TimeUtil.now(), serverIdent.get().getZoneId());
+
+      if (!input.email.isEmpty() && !self.get().asIdentifiedUser().hasEmailAddress(input.email)) {
+        try {
+          permissionBackend.user(self.get()).ref(rsrc.getNotes().getChange().getDest()).check(perm);
+        } catch (AuthException e) {
+          throw new ResourceConflictException(
+              CommitValidators.invalidEmail(
+                      input.type.toString(),
+                      newIdent,
+                      self.get().asIdentifiedUser(),
+                      urlFormatter.get())
+                  .getMessage(),
+              e);
+        }
+      }
+
+      try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
+        editModifier.modifyIdentity(repository, rsrc.getNotes(), newIdent, input.type);
+      } catch (InvalidChangeOperationException e) {
+        throw new ResourceConflictException(e.getMessage());
+      }
+
+      return Response.none();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 2ac24c6..8c95e93 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -88,6 +88,7 @@
     put(CHANGE_EDIT_KIND, "/").to(ChangeEdits.Put.class);
     get(CHANGE_EDIT_KIND, "meta").to(ChangeEdits.GetMeta.class);
 
+    put(CHANGE_KIND, "edit:identity").to(ChangeEdits.EditIdentity.class);
     put(CHANGE_KIND, "edit:message").to(ChangeEdits.EditMessage.class);
     get(CHANGE_KIND, "edit:message").to(ChangeEdits.GetMessage.class);
     post(CHANGE_KIND, "edit:publish").to(PublishChangeEdit.class);
@@ -98,6 +99,7 @@
     post(CHANGE_KIND, "index").to(Index.class);
     get(CHANGE_KIND, "meta_diff").to(GetMetaDiff.class);
     post(CHANGE_KIND, "merge").to(CreateMergePatchSet.class);
+    get(CHANGE_KIND, "message").to(GetMessage.class);
     put(CHANGE_KIND, "message").to(PutMessage.class);
 
     child(CHANGE_KIND, "messages").to(ChangeMessages.class);
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 4da8410..63edb7b 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -21,6 +21,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
@@ -88,6 +89,8 @@
 
 @Singleton
 public class CherryPickChange {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @AutoValue
   abstract static class Result {
     static Result create(Change.Id changeId, ImmutableSet<String> filesWithGitConflicts) {
@@ -374,6 +377,7 @@
                 input.parent - 1,
                 input.allowEmpty,
                 input.allowConflicts);
+        logger.atFine().log("flushing inserter %s", oi);
         oi.flush();
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
         throw new IntegrationConflictException("Cherry pick failed: " + e.getMessage(), e);
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 2d0c739..272afc9 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -227,6 +227,7 @@
         r.author = loader.get(c.author.getId());
       }
       r.commitId = c.getCommitId().getName();
+      r.fixSuggestions = toFixSuggestionInfos(c.fixSuggestions);
     }
 
     protected Range toRange(Comment.Range commentRange) {
@@ -240,32 +241,6 @@
       }
       return range;
     }
-  }
-
-  public class HumanCommentFormatter extends BaseCommentFormatter<HumanComment, CommentInfo> {
-    @Override
-    protected CommentInfo toInfo(HumanComment c, AccountLoader loader) {
-      CommentInfo ci = new CommentInfo();
-      fillCommentInfo(c, ci, loader);
-      ci.unresolved = c.unresolved;
-      return ci;
-    }
-
-    private HumanCommentFormatter() {}
-  }
-
-  class RobotCommentFormatter extends BaseCommentFormatter<RobotComment, RobotCommentInfo> {
-    @Override
-    protected RobotCommentInfo toInfo(RobotComment c, AccountLoader loader) {
-      RobotCommentInfo rci = new RobotCommentInfo();
-      rci.robotId = c.robotId;
-      rci.robotRunId = c.robotRunId;
-      rci.url = c.url;
-      rci.properties = c.properties;
-      rci.fixSuggestions = toFixSuggestionInfos(c.fixSuggestions);
-      fillCommentInfo(c, rci, loader);
-      return rci;
-    }
 
     @Nullable
     private List<FixSuggestionInfo> toFixSuggestionInfos(
@@ -293,6 +268,31 @@
       fixReplacementInfo.replacement = fixReplacement.replacement;
       return fixReplacementInfo;
     }
+  }
+
+  public class HumanCommentFormatter extends BaseCommentFormatter<HumanComment, CommentInfo> {
+    @Override
+    protected CommentInfo toInfo(HumanComment c, AccountLoader loader) {
+      CommentInfo ci = new CommentInfo();
+      fillCommentInfo(c, ci, loader);
+      ci.unresolved = c.unresolved;
+      return ci;
+    }
+
+    private HumanCommentFormatter() {}
+  }
+
+  class RobotCommentFormatter extends BaseCommentFormatter<RobotComment, RobotCommentInfo> {
+    @Override
+    protected RobotCommentInfo toInfo(RobotComment c, AccountLoader loader) {
+      RobotCommentInfo rci = new RobotCommentInfo();
+      rci.robotId = c.robotId;
+      rci.robotRunId = c.robotRunId;
+      rci.url = c.url;
+      rci.properties = c.properties;
+      fillCommentInfo(c, rci, loader);
+      return rci;
+    }
 
     private RobotCommentFormatter() {}
   }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 5146a97..e254bfc 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -26,15 +26,18 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.converter.ChangeInputProtoConverter;
 import com.google.gerrit.exceptions.InvalidMergeStrategyException;
 import com.google.gerrit.exceptions.MergeWithConflictsNotSupportedException;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -52,6 +55,7 @@
 import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.proto.Entities;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -96,6 +100,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.annotations.NonNull;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -118,6 +123,8 @@
 public class CreateChange
     implements RestCollectionModifyView<TopLevelResource, ChangeResource, ChangeInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final ChangeInputProtoConverter CHANGE_INPUT_PROTO_CONVERTER =
+      ChangeInputProtoConverter.INSTANCE;
 
   private final BatchUpdate.Factory updateFactory;
   private final String anonymousCowardName;
@@ -191,11 +198,51 @@
     return execute(updateFactory, input, projectsCollection.parse(input.project));
   }
 
-  /** Creates the changes in the given project. This is public for reuse in the project API. */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  @FunctionalInterface
+  public interface CommitTreeSupplier {
+    @NonNull
+    ObjectId get(Repository repo, ObjectInserter oi, ChangeInput input, RevCommit mergeTip)
+        throws IOException, RestApiException;
+  }
+
+  /**
+   * Creates the changes in the given project, using the proto representation of ChangeInput -
+   * {@link com.google.gerrit.proto.Entities.ChangeInput}.
+   */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public Response<ChangeInfo> execute(
+      BatchUpdate.Factory updateFactory,
+      Entities.ChangeInput input,
+      CommitTreeSupplier commitTreeSupplier)
+      throws IOException, RestApiException, UpdateException, PermissionBackendException,
+          ConfigInvalidException {
+    return execute(
+        updateFactory,
+        CHANGE_INPUT_PROTO_CONVERTER.fromProto(input),
+        projectsCollection.parse(input.getProject()),
+        Optional.of(commitTreeSupplier));
+  }
+
+  /**
+   * Creates the changes in the given project, using the java-class representation of ChangeInput -
+   * {@link com.google.gerrit.extensions.common.ChangeInput}. This is public for reuse in the
+   * project API.
+   */
   public Response<ChangeInfo> execute(
       BatchUpdate.Factory updateFactory, ChangeInput input, ProjectResource projectResource)
       throws IOException, RestApiException, UpdateException, PermissionBackendException,
           ConfigInvalidException {
+    return execute(updateFactory, input, projectResource, Optional.empty());
+  }
+
+  private Response<ChangeInfo> execute(
+      BatchUpdate.Factory updateFactory,
+      ChangeInput input,
+      ProjectResource projectResource,
+      Optional<CommitTreeSupplier> commitTreeSupplier)
+      throws IOException, RestApiException, UpdateException, PermissionBackendException,
+          ConfigInvalidException {
     if (!user.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
@@ -204,14 +251,15 @@
     projectState.checkStatePermitsWrite();
 
     IdentifiedUser me = user.get().asIdentifiedUser();
-    checkAndSanitizeChangeInput(input, me);
+    checkAndSanitizeChangeInput(input, me, commitTreeSupplier);
 
     Project.NameKey project = projectResource.getNameKey();
     contributorAgreements.check(project, user.get());
 
     checkRequiredPermissions(project, input.branch, input.author);
 
-    ChangeInfo newChange = createNewChange(input, me, projectState, updateFactory);
+    ChangeInfo newChange =
+        createNewChange(input, me, projectState, updateFactory, commitTreeSupplier);
     return Response.created(newChange);
   }
 
@@ -225,7 +273,8 @@
    * @param me the user who sent the current request to create a change.
    * @throws BadRequestException if the input is not legal.
    */
-  private void checkAndSanitizeChangeInput(ChangeInput input, IdentifiedUser me)
+  private void checkAndSanitizeChangeInput(
+      ChangeInput input, IdentifiedUser me, Optional<CommitTreeSupplier> commitTreeSupplier)
       throws RestApiException, PermissionBackendException, IOException {
     if (Strings.isNullOrEmpty(input.branch)) {
       throw new BadRequestException("branch must be non-empty");
@@ -303,6 +352,11 @@
       throw new BadRequestException("Only one of `merge` and `patch` arguments can be set.");
     }
 
+    if ((input.merge != null || input.patch != null) && commitTreeSupplier.isPresent()) {
+      throw new BadRequestException(
+          "`CommitTreeSupplier` cannot be provided along with `merge` or `patch` arguments");
+    }
+
     if (input.author != null
         && (Strings.isNullOrEmpty(input.author.email)
             || Strings.isNullOrEmpty(input.author.name))) {
@@ -332,7 +386,8 @@
       ChangeInput input,
       IdentifiedUser me,
       ProjectState projectState,
-      BatchUpdate.Factory updateFactory)
+      BatchUpdate.Factory updateFactory,
+      Optional<CommitTreeSupplier> commitTreeSupplier)
       throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException,
           UpdateException {
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
@@ -404,35 +459,28 @@
           }
         } else if (input.patch != null) {
           // create a commit with the given patch.
-          if (mergeTip == null) {
-            throw new BadRequestException("Cannot apply patch on top of an empty tree.");
-          }
-          PatchApplier.Result applyResult =
-              ApplyPatchUtil.applyPatch(git, oi, input.patch, mergeTip);
-          ObjectId treeId = applyResult.getTreeId();
-          String appliedPatchCommitMessage =
-              getCommitMessage(
-                  ApplyPatchUtil.buildCommitMessage(
-                      input.subject,
-                      ImmutableList.of(),
-                      input.patch.patch,
-                      ApplyPatchUtil.getResultPatch(git, reader, mergeTip, rw.lookupTree(treeId)),
-                      applyResult.getErrors()),
-                  me);
           c =
-              rw.parseCommit(
-                  CommitUtil.createCommitWithTree(
-                      oi,
-                      author,
-                      committer,
-                      ImmutableList.of(mergeTip),
-                      appliedPatchCommitMessage,
-                      treeId));
+              createCommitWithPatch(
+                  git, reader, oi, rw, mergeTip, input.patch, input.subject, author, committer, me);
+        } else if (commitTreeSupplier.isPresent()) {
+          c =
+              createCommitWithSuppliedTree(
+                  git,
+                  oi,
+                  rw,
+                  mergeTip,
+                  input,
+                  commitTreeSupplier.get(),
+                  author,
+                  committer,
+                  commitMessage);
+
         } else {
           // create an empty commit.
           c = createEmptyCommit(oi, rw, author, committer, mergeTip, commitMessage);
         }
         // Flush inserter so that commit becomes visible to validators
+        logger.atFine().log("flushing inserter %s", oi);
         oi.flush();
 
         Change.Id changeId = Change.id(seq.nextChangeId());
@@ -604,12 +652,70 @@
       @Nullable RevCommit mergeTip,
       String commitMessage)
       throws IOException {
-    logger.atFine().log("Creating empty commit");
-    ObjectId treeID = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree().getId();
+    logger.atFine().log("Creating empty commit (mergeTip = %s)", mergeTip);
+    ObjectId treeId = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree().getId();
+    logger.atFine().log("Tree ID of empty commit: %s", treeId.name());
     List<RevCommit> parents = mergeTip == null ? ImmutableList.of() : ImmutableList.of(mergeTip);
     return rw.parseCommit(
         CommitUtil.createCommitWithTree(
-            oi, authorIdent, committerIdent, parents, commitMessage, treeID));
+            oi, authorIdent, committerIdent, parents, commitMessage, treeId));
+  }
+
+  private CodeReviewCommit createCommitWithPatch(
+      Repository repo,
+      ObjectReader reader,
+      ObjectInserter oi,
+      CodeReviewRevWalk rw,
+      RevCommit mergeTip,
+      ApplyPatchInput patch,
+      String subject,
+      PersonIdent authorIdent,
+      PersonIdent committerIdent,
+      IdentifiedUser me)
+      throws IOException, RestApiException {
+    if (mergeTip == null) {
+      throw new BadRequestException("Cannot apply patch on top of an empty tree.");
+    }
+    PatchApplier.Result applyResult = ApplyPatchUtil.applyPatch(repo, oi, patch, mergeTip);
+    ObjectId treeId = applyResult.getTreeId();
+    logger.atFine().log("tree ID after applying patch: %s", treeId.name());
+    String appliedPatchCommitMessage =
+        getCommitMessage(
+            ApplyPatchUtil.buildCommitMessage(
+                subject,
+                ImmutableList.of(),
+                patch.patch,
+                ApplyPatchUtil.getResultPatch(repo, reader, mergeTip, rw.lookupTree(treeId)),
+                applyResult.getErrors()),
+            me);
+    return rw.parseCommit(
+        CommitUtil.createCommitWithTree(
+            oi,
+            authorIdent,
+            committerIdent,
+            ImmutableList.of(mergeTip),
+            appliedPatchCommitMessage,
+            treeId));
+  }
+
+  private static CodeReviewCommit createCommitWithSuppliedTree(
+      Repository repo,
+      ObjectInserter oi,
+      CodeReviewRevWalk rw,
+      RevCommit mergeTip,
+      ChangeInput input,
+      CommitTreeSupplier commitTreeSupplier,
+      PersonIdent authorIdent,
+      PersonIdent committerIdent,
+      String commitMessage)
+      throws IOException, RestApiException {
+    if (mergeTip == null) {
+      throw new BadRequestException("`CommitTreeSupplier` cannot be used on top of an empty tree.");
+    }
+    ObjectId treeId = commitTreeSupplier.get(repo, oi, input, mergeTip);
+    return rw.parseCommit(
+        CommitUtil.createCommitWithTree(
+            oi, authorIdent, committerIdent, ImmutableList.of(mergeTip), commitMessage, treeId));
   }
 
   private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
@@ -653,17 +759,20 @@
     logger.atFine().log("merge strategy = %s", mergeStrategy);
 
     try {
-      return MergeUtil.createMergeCommit(
-          oi,
-          repo.getConfig(),
-          mergeTip,
-          sourceCommit,
-          mergeStrategy,
-          merge.allowConflicts,
-          authorIdent,
-          committerIdent,
-          commitMessage,
-          rw);
+      CodeReviewCommit mergeCommit =
+          MergeUtil.createMergeCommit(
+              oi,
+              repo.getConfig(),
+              mergeTip,
+              sourceCommit,
+              mergeStrategy,
+              merge.allowConflicts,
+              authorIdent,
+              committerIdent,
+              commitMessage,
+              rw);
+      logger.atFine().log("tree ID of merge commit: %s", mergeCommit.getTree().getId().name());
+      return mergeCommit;
     } catch (NoMergeBaseException e) {
       throw new ResourceConflictException(
           String.format("Cannot create merge commit: %s", e.getMessage()), e);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index 8849c82..c386b29 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.PublishCommentUtil;
+import com.google.gerrit.server.change.CommentsValidator;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -134,6 +135,8 @@
             rsrc.getPatchSet(),
             commentsUtil);
 
+    CommentsValidator.ensureFixSuggestionsAreAddable(in.fixSuggestions, in.path);
+
     CommentValidationContext ctx =
         CommentValidationContext.create(
             rsrc.getChange().getChangeId(),
@@ -182,7 +185,8 @@
             draftInput.side(),
             draftInput.message.trim(),
             draftInput.unresolved,
-            parentUuid);
+            parentUuid,
+            CommentsUtil.createFixSuggestionsFromInput(draftInput.fixSuggestions));
     comment.setLineNbrAndRange(draftInput.line, draftInput.range);
     comment.tag = draftInput.tag;
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 989dc7a..d3561fc3 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.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.entities.BranchNameKey;
@@ -172,7 +173,7 @@
       }
 
       RevCommit currentPsCommit;
-      List<String> groups = null;
+      ImmutableList<String> groups = null;
       if (!in.inheritParent && !in.baseChange.isEmpty()) {
         PatchSet basePS = findBasePatchSet(in.baseChange);
         currentPsCommit = rw.parseCommit(basePS.commitId());
diff --git a/java/com/google/gerrit/server/restapi/change/Fixes.java b/java/com/google/gerrit/server/restapi/change/Fixes.java
index 38240e3..080c1e7 100644
--- a/java/com/google/gerrit/server/restapi/change/Fixes.java
+++ b/java/com/google/gerrit/server/restapi/change/Fixes.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixSuggestion;
-import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
 
@@ -53,10 +54,13 @@
     String fixId = id.get();
     ChangeNotes changeNotes = revisionResource.getNotes();
 
-    List<RobotComment> robotComments =
-        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().id());
-    for (RobotComment robotComment : robotComments) {
-      for (FixSuggestion fixSuggestion : robotComment.fixSuggestions) {
+    List<Comment> allComments = new ArrayList<>();
+    allComments.addAll(
+        commentsUtil.publishedByPatchSet(changeNotes, revisionResource.getPatchSet().id()));
+    allComments.addAll(
+        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().id()));
+    for (Comment comment : allComments) {
+      for (FixSuggestion fixSuggestion : comment.fixSuggestions) {
         if (Objects.equals(fixId, fixSuggestion.fixId)) {
           return new FixResource(revisionResource, fixSuggestion.replacements);
         }
diff --git a/java/com/google/gerrit/server/restapi/change/GetBlame.java b/java/com/google/gerrit/server/restapi/change/GetBlame.java
index 04828f2..bc2755e 100644
--- a/java/com/google/gerrit/server/restapi/change/GetBlame.java
+++ b/java/com/google/gerrit/server/restapi/change/GetBlame.java
@@ -25,10 +25,8 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.FileResource;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gitiles.blame.cache.BlameCache;
@@ -38,13 +36,11 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
@@ -53,7 +49,6 @@
 
   private final GitRepositoryManager repoManager;
   private final BlameCache blameCache;
-  private final ThreeWayMergeStrategy mergeStrategy;
   private final AutoMerger autoMerger;
 
   @Option(
@@ -65,14 +60,9 @@
   private boolean base;
 
   @Inject
-  GetBlame(
-      GitRepositoryManager repoManager,
-      BlameCache blameCache,
-      @GerritServerConfig Config cfg,
-      AutoMerger autoMerger) {
+  GetBlame(GitRepositoryManager repoManager, BlameCache blameCache, AutoMerger autoMerger) {
     this.repoManager = repoManager;
     this.blameCache = blameCache;
-    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
     this.autoMerger = autoMerger;
   }
 
@@ -116,8 +106,7 @@
 
       } else if (parents.length == 2) {
         ObjectId automerge =
-            autoMerger.lookupFromGitOrMergeInMemory(
-                repository, revWalk, ins, revCommit, mergeStrategy);
+            autoMerger.lookupFromGitOrMergeInMemory(repository, revWalk, ins, revCommit);
         result = blame(automerge, path, repository, revWalk);
 
       } else {
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 9424198..3de0f27 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -19,6 +19,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.entities.PatchSet;
@@ -256,21 +257,25 @@
     }
   }
 
+  @CanIgnoreReturnValue
   public GetDiff setBase(String base) {
     this.base = base;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public GetDiff setParent(int parentNum) {
     this.parentNum = parentNum;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public GetDiff setIntraline(boolean intraline) {
     this.intraline = intraline;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public GetDiff setWhitespace(Whitespace whitespace) {
     this.whitespace = whitespace;
     return this;
diff --git a/java/com/google/gerrit/server/restapi/change/GetMessage.java b/java/com/google/gerrit/server/restapi/change/GetMessage.java
new file mode 100644
index 0000000..5715caa
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetMessage.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static java.util.stream.Collectors.toMap;
+
+import com.google.gerrit.extensions.common.CommitMessageInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.revwalk.FooterLine;
+
+@Singleton
+public class GetMessage implements RestReadView<ChangeResource> {
+  private final ChangeData.Factory changeDataFactory;
+
+  @Inject
+  GetMessage(ChangeData.Factory changeDataFactory) {
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  @Override
+  public Response<CommitMessageInfo> apply(ChangeResource resource)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    CommitMessageInfo commitMessageInfo = new CommitMessageInfo();
+    commitMessageInfo.subject = resource.getChange().getSubject();
+
+    ChangeData cd = changeDataFactory.create(resource.getNotes());
+    commitMessageInfo.fullMessage = cd.commitMessage();
+    commitMessageInfo.footers =
+        cd.commitFooters().stream().collect(toMap(FooterLine::getKey, FooterLine::getValue));
+
+    return Response.ok(commitMessageInfo);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index c90e4fc..f5356f8 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -87,7 +87,7 @@
     return getAsList(listComments(rsrc), rsrc);
   }
 
-  private Iterable<HumanComment> listComments(ChangeResource rsrc) {
+  private List<HumanComment> listComments(ChangeResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     return commentsUtil.publishedHumanCommentsByChange(cd.notes());
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index 9faa9b5..e5d11f5 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -70,7 +70,7 @@
     this.draftCommentsReader = draftCommentsReader;
   }
 
-  private Iterable<HumanComment> listComments(ChangeResource rsrc) {
+  private List<HumanComment> listComments(ChangeResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     return draftCommentsReader.getDraftsByChangeAndDraftAuthor(
         cd.notes(), rsrc.getUser().getAccountId());
diff --git a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
index 25f4005..c5feb96 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
@@ -78,7 +78,7 @@
     return commentInfosMap;
   }
 
-  private Iterable<RobotComment> listComments(RevisionResource rsrc) {
+  private List<RobotComment> listComments(RevisionResource rsrc) {
     return commentsUtil.robotCommentsByPatchSet(rsrc.getNotes(), rsrc.getPatchSet().id());
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index 9797bda..8d6f03e 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.BranchOrderSection;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -43,7 +44,6 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
@@ -126,7 +126,7 @@
         Optional<BranchOrderSection> branchOrder = projectState.getBranchOrderSection();
         if (branchOrder.isPresent()) {
           int prefixLen = Constants.R_HEADS.length();
-          List<String> names = branchOrder.get().getMoreStable(ref.getName());
+          ImmutableList<String> names = branchOrder.get().getMoreStable(ref.getName());
           Map<String, Ref> refs =
               git.getRefDatabase().exactRef(names.toArray(new String[names.size()]));
           for (String n : names) {
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 32474a4..3c30b84 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -16,12 +16,10 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.toList;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
@@ -42,7 +40,6 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
-import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -52,12 +49,8 @@
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewerResult;
-import com.google.gerrit.extensions.client.Comment.Range;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
-import com.google.gerrit.extensions.common.FixReplacementInfo;
-import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -70,7 +63,6 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.IdentifiedUser;
@@ -81,6 +73,7 @@
 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.CommentsValidator;
 import com.google.gerrit.server.change.ModifyReviewersEmail;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerModifier;
@@ -92,11 +85,6 @@
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.DiffSummary;
-import com.google.gerrit.server.patch.DiffSummaryKey;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.LabelPermission;
@@ -106,6 +94,8 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdates;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -115,17 +105,14 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.Set;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
 public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
@@ -158,16 +145,13 @@
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
 
-  private final BatchUpdate.Factory updateFactory;
+  private final RetryHelper retryHelper;
   private final PostReviewOp.Factory postReviewOpFactory;
   private final ChangeResource.Factory changeResourceFactory;
-  private final ChangeData.Factory changeDataFactory;
   private final AccountCache accountCache;
   private final ApprovalsUtil approvalsUtil;
-  private final CommentsUtil commentsUtil;
   private final DraftCommentsReader draftCommentsReader;
 
-  private final PatchListCache patchListCache;
   private final AccountResolver accountResolver;
   private final ReviewerModifier reviewerModifier;
   private final Metrics metrics;
@@ -181,18 +165,16 @@
   private final ReviewerAdded reviewerAdded;
   private final boolean strictLabels;
   private final ChangeJson.Factory changeJsonFactory;
+  private final CommentsValidator commentsValidator;
 
   @Inject
   PostReview(
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       PostReviewOp.Factory postReviewOpFactory,
       ChangeResource.Factory changeResourceFactory,
-      ChangeData.Factory changeDataFactory,
       AccountCache accountCache,
       ApprovalsUtil approvalsUtil,
-      CommentsUtil commentsUtil,
       DraftCommentsReader draftCommentsReader,
-      PatchListCache patchListCache,
       AccountResolver accountResolver,
       ReviewerModifier reviewerModifier,
       Metrics metrics,
@@ -204,15 +186,13 @@
       PermissionBackend permissionBackend,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
       ReviewerAdded reviewerAdded,
-      ChangeJson.Factory changeJsonFactory) {
-    this.updateFactory = updateFactory;
+      ChangeJson.Factory changeJsonFactory,
+      CommentsValidator commentsValidator) {
+    this.retryHelper = retryHelper;
     this.postReviewOpFactory = postReviewOpFactory;
     this.changeResourceFactory = changeResourceFactory;
-    this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
-    this.commentsUtil = commentsUtil;
     this.draftCommentsReader = draftCommentsReader;
-    this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
     this.accountResolver = accountResolver;
     this.reviewerModifier = reviewerModifier;
@@ -226,6 +206,7 @@
     this.reviewerAdded = reviewerAdded;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
     this.changeJsonFactory = changeJsonFactory;
+    this.commentsValidator = commentsValidator;
   }
 
   @Override
@@ -261,7 +242,7 @@
     }
     if (input.comments != null) {
       input.comments = cleanUpComments(input.comments);
-      checkComments(revision, input.comments);
+      commentsValidator.checkComments(revision, input.comments);
     }
     if (input.draftIdsToPublish != null) {
       checkDraftIds(revision, input.draftIdsToPublish, input.drafts);
@@ -311,99 +292,58 @@
     }
     output.labels = input.labels;
 
-    // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
-    NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
-    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-      try (BatchUpdate bu =
-          updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
-        bu.setNotify(notify);
-
-        Account account = revision.getUser().asIdentifiedUser().getAccount();
-        boolean ccOrReviewer = false;
-        if (input.labels != null && !input.labels.isEmpty()) {
-          ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
-          if (ccOrReviewer) {
-            logger.atFine().log(
-                "calling user is cc/reviewer on the change due to voting on a label");
-          }
-        }
-
-        if (!ccOrReviewer) {
-          // Check if user was already CCed or reviewing prior to this review.
-          ReviewerSet currentReviewers =
-              approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
-          ccOrReviewer = currentReviewers.all().contains(account.id());
-          if (ccOrReviewer) {
-            logger.atFine().log("calling user is already cc/reviewer on the change");
-          }
-        }
-
-        // Apply reviewer changes first. Revision emails should be sent to the
-        // updated set of reviewers. Also keep track of whether the user added
-        // themselves as a reviewer or to the CC list.
-        logger.atFine().log("adding reviewer additions");
-        for (ReviewerModification reviewerResult : reviewerResults) {
-          reviewerResult.op.suppressEmail(); // Send a single batch email below.
-          reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
-          bu.addOp(revision.getChange().getId(), reviewerResult.op);
-          if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
-            logger.atFine().log("calling user is explicitly added as reviewer or CC");
-            ccOrReviewer = true;
-          }
-        }
-
-        if (!ccOrReviewer) {
-          // User posting this review isn't currently in the reviewer or CC list,
-          // isn't being explicitly added, and isn't voting on any label.
-          // Automatically CC them on this change so they receive replies.
-          logger.atFine().log("CCing calling user");
-          ReviewerModification selfAddition =
-              reviewerModifier.ccCurrentUser(revision.getUser(), revision);
-          selfAddition.op.suppressEmail();
-          selfAddition.op.suppressEvent();
-          bu.addOp(revision.getChange().getId(), selfAddition.op);
-        }
-
-        // Add WorkInProgressOp if requested.
-        if ((input.ready || input.workInProgress)
-            && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
-          if (input.ready && input.workInProgress) {
-            output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
-            return Response.withStatusCode(SC_BAD_REQUEST, output);
-          }
-
-          revision
-              .getChangeResource()
-              .permissions()
-              .check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
-
-          if (input.ready) {
-            output.ready = true;
-          }
-
-          logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
-          WorkInProgressOp wipOp =
-              workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input());
-          wipOp.suppressEmail();
-          bu.addOp(revision.getChange().getId(), wipOp);
-        }
-
-        // Add the review ops.
-        logger.atFine().log("posting review");
-        PostReviewOp postReviewOp =
-            postReviewOpFactory.create(
-                projectState, revision.getPatchSet().id(), input, revision.getAccountId());
-        bu.addOp(revision.getChange().getId(), postReviewOp);
-
-        // Adjust the attention set based on the input
-        replyAttentionSetUpdates.updateAttentionSet(
-            bu, revision.getNotes(), input, revision.getUser());
-        bu.execute();
+    Account account = revision.getUser().asIdentifiedUser().getAccount();
+    boolean ccOrReviewer = false;
+    if (input.labels != null && !input.labels.isEmpty()) {
+      ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
+      if (ccOrReviewer) {
+        logger.atFine().log("calling user is cc/reviewer on the change due to voting on a label");
       }
     }
 
-    // Re-read change to take into account results of the update.
-    ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
+    if (!ccOrReviewer) {
+      // Check if user was already CCed or reviewing prior to this review.
+      ReviewerSet currentReviewers =
+          approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
+      ccOrReviewer = currentReviewers.all().contains(account.id());
+      if (ccOrReviewer) {
+        logger.atFine().log("calling user is already cc/reviewer on the change");
+      }
+    }
+
+    for (ReviewerModification reviewerResult : reviewerResults) {
+      reviewerResult.op.suppressEmail(); // Send a single batch email below.
+      reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
+      if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
+        logger.atFine().log("calling user is explicitly added as reviewer or CC");
+        ccOrReviewer = true;
+      }
+    }
+
+    // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
+    NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
+
+    if ((input.ready || input.workInProgress)
+        && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
+      if (input.ready && input.workInProgress) {
+        output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
+        return Response.withStatusCode(SC_BAD_REQUEST, output);
+      }
+
+      revision
+          .getChangeResource()
+          .permissions()
+          .check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
+
+      if (input.ready) {
+        output.ready = true;
+      }
+    }
+
+    BatchUpdates.Result batchUpdateResult =
+        runBatchUpdate(projectState, revision, input, ts, notify, reviewerResults, ccOrReviewer);
+    ChangeData cd =
+        batchUpdateResult.getChangeData(revision.getProject(), revision.getChange().getId());
     for (ReviewerModification reviewerResult : reviewerResults) {
       reviewerResult.gatherResults(cd);
     }
@@ -415,11 +355,83 @@
 
     if (input.responseFormatOptions != null) {
       output.changeInfo = changeJsonFactory.create(input.responseFormatOptions).format(cd);
+    } else {
+      output.changeInfo = changeJsonFactory.noOptions().format(cd);
     }
 
     return Response.ok(output);
   }
 
+  private BatchUpdates.Result runBatchUpdate(
+      ProjectState projectState,
+      RevisionResource revision,
+      ReviewInput input,
+      Instant ts,
+      NotifyResolver.Result notify,
+      List<ReviewerModification> reviewerResults,
+      boolean ccOrReviewer)
+      throws UpdateException, RestApiException {
+    return retryHelper
+        .changeUpdate(
+            "batchUpdate",
+            updateFactory -> {
+              try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+                try (BatchUpdate bu =
+                    updateFactory.create(
+                        revision.getChange().getProject(), revision.getUser(), ts)) {
+                  bu.setNotify(notify);
+
+                  // Apply reviewer changes first. Revision emails should be sent to the
+                  // updated set of reviewers. Also keep track of whether the user added
+                  // themselves as a reviewer or to the CC list.
+                  logger.atFine().log("adding reviewer additions");
+                  reviewerResults.forEach(
+                      reviewerResult -> bu.addOp(revision.getChange().getId(), reviewerResult.op));
+
+                  if (!ccOrReviewer) {
+                    // User posting this review isn't currently in the reviewer or CC list,
+                    // isn't being explicitly added, and isn't voting on any label.
+                    // Automatically CC them on this change so they receive replies.
+                    logger.atFine().log("CCing calling user");
+                    ReviewerModification selfAddition =
+                        reviewerModifier.ccCurrentUser(revision.getUser(), revision);
+                    selfAddition.op.suppressEmail();
+                    selfAddition.op.suppressEvent();
+                    bu.addOp(revision.getChange().getId(), selfAddition.op);
+                  }
+
+                  // Add WorkInProgressOp if requested.
+                  if ((input.ready || input.workInProgress)
+                      && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
+                    logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
+                    WorkInProgressOp wipOp =
+                        workInProgressOpFactory.create(
+                            input.workInProgress, new WorkInProgressOp.Input());
+                    wipOp.suppressEmail();
+                    bu.addOp(revision.getChange().getId(), wipOp);
+                  }
+
+                  // Add the review ops.
+                  logger.atFine().log("posting review");
+                  PostReviewOp postReviewOp =
+                      postReviewOpFactory.create(
+                          projectState,
+                          revision.getPatchSet().id(),
+                          input,
+                          revision.getAccountId());
+                  bu.addOp(revision.getChange().getId(), postReviewOp);
+
+                  // Adjust the attention set based on the input
+                  replyAttentionSetUpdates.updateAttentionSetOnPostReview(
+                      bu, postReviewOp, revision.getNotes(), input, revision.getUser());
+
+                  return bu.execute();
+                }
+              }
+            })
+        .call();
+  }
+
   private boolean didWorkInProgressChange(boolean currentWorkInProgress, ReviewInput input) {
     return input.ready == currentWorkInProgress || input.workInProgress != currentWorkInProgress;
   }
@@ -667,27 +679,6 @@
         .collect(toList());
   }
 
-  private <T extends com.google.gerrit.extensions.client.Comment> void checkComments(
-      RevisionResource revision, Map<String, List<T>> commentsPerPath)
-      throws BadRequestException, PatchListNotAvailableException {
-    logger.atFine().log("checking comments");
-    Set<String> revisionFilePaths = getAffectedFilePaths(revision);
-    for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
-      String path = entry.getKey();
-      PatchSet.Id patchSetId = revision.getPatchSet().id();
-      ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);
-
-      List<T> comments = entry.getValue();
-      for (T comment : comments) {
-        ensureLineIsNonNegative(comment.line, path);
-        ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
-        ensureRangeIsValid(path, comment.range);
-        ensureValidPatchsetLevelComment(path, comment);
-        ensureValidInReplyTo(revision.getNotes(), comment.inReplyTo);
-      }
-    }
-  }
-
   /**
    * Asserts that the draft IDs to publish are valid, i.e. they exist and belong to the current
    * user. If the {@code draftHandling} parameter is equal to {@link DraftHandling#PUBLISH}, then
@@ -724,59 +715,6 @@
     }
   }
 
-  private Set<String> getAffectedFilePaths(RevisionResource revision)
-      throws PatchListNotAvailableException {
-    ObjectId newId = revision.getPatchSet().commitId();
-    DiffSummaryKey key =
-        DiffSummaryKey.fromPatchListKey(
-            PatchListKey.againstDefaultBase(newId, Whitespace.IGNORE_NONE));
-    DiffSummary ds = patchListCache.getDiffSummary(key, revision.getProject());
-    return new HashSet<>(ds.getPaths());
-  }
-
-  private static void ensurePathRefersToAvailableOrMagicFile(
-      String path, Set<String> availableFilePaths, PatchSet.Id patchSetId)
-      throws BadRequestException {
-    if (!availableFilePaths.contains(path) && !Patch.isMagic(path)) {
-      throw new BadRequestException(
-          String.format("file %s not found in revision %s", path, patchSetId));
-    }
-  }
-
-  private static void ensureLineIsNonNegative(Integer line, String path)
-      throws BadRequestException {
-    if (line != null && line < 0) {
-      throw new BadRequestException(
-          String.format("negative line number %d not allowed on %s", line, path));
-    }
-  }
-
-  private static <T extends com.google.gerrit.extensions.client.Comment>
-      void ensureCommentNotOnMagicFilesOfAutoMerge(String path, T comment)
-          throws BadRequestException {
-    if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
-      throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
-    }
-  }
-
-  private static <T extends com.google.gerrit.extensions.client.Comment>
-      void ensureValidPatchsetLevelComment(String path, T comment) throws BadRequestException {
-    if (path.equals(PATCHSET_LEVEL)
-        && (comment.side != null || comment.range != null || comment.line != null)) {
-      throw new BadRequestException("Patchset-level comments can't have side, range, or line");
-    }
-  }
-
-  private void ensureValidInReplyTo(ChangeNotes changeNotes, String inReplyTo)
-      throws BadRequestException {
-    if (inReplyTo != null
-        && !commentsUtil.getPublishedHumanComment(changeNotes, inReplyTo).isPresent()
-        && !commentsUtil.getRobotComment(changeNotes, inReplyTo).isPresent()) {
-      throw new BadRequestException(
-          String.format("Invalid inReplyTo, comment %s not found", inReplyTo));
-    }
-  }
-
   private void checkRobotComments(
       RevisionResource revision, Map<String, List<RobotCommentInput>> in)
       throws BadRequestException, PatchListNotAvailableException {
@@ -786,18 +724,17 @@
       for (RobotCommentInput c : e.getValue()) {
         ensureRobotIdIsSet(c.robotId, commentPath);
         ensureRobotRunIdIsSet(c.robotRunId, commentPath);
-        ensureFixSuggestionsAreAddable(c.fixSuggestions, commentPath);
         // Size is validated later, in CommentLimitsValidator.
       }
     }
-    checkComments(revision, in);
+    commentsValidator.checkComments(revision, in);
   }
 
   private static void ensureRobotIdIsSet(String robotId, String commentPath)
       throws BadRequestException {
     if (robotId == null) {
       throw new BadRequestException(
-          String.format("robotId is missing for robot comment on %s", commentPath));
+          String.format("robotId is missing for comment on %s", commentPath));
     }
   }
 
@@ -805,131 +742,7 @@
       throws BadRequestException {
     if (robotRunId == null) {
       throw new BadRequestException(
-          String.format("robotRunId is missing for robot comment on %s", commentPath));
-    }
-  }
-
-  private static void ensureFixSuggestionsAreAddable(
-      List<FixSuggestionInfo> fixSuggestionInfos, String commentPath) throws BadRequestException {
-    if (fixSuggestionInfos == null) {
-      return;
-    }
-
-    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
-      ensureDescriptionIsSet(commentPath, fixSuggestionInfo.description);
-      ensureFixReplacementsAreAddable(commentPath, fixSuggestionInfo.replacements);
-    }
-  }
-
-  private static void ensureDescriptionIsSet(String commentPath, String description)
-      throws BadRequestException {
-    if (description == null) {
-      throw new BadRequestException(
-          String.format(
-              "A description is required for the suggested fix of the robot comment on %s",
-              commentPath));
-    }
-  }
-
-  private static void ensureFixReplacementsAreAddable(
-      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
-    ensureReplacementsArePresent(commentPath, fixReplacementInfos);
-
-    for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
-      ensureReplacementPathIsSetAndNotPatchsetLevel(commentPath, fixReplacementInfo.path);
-      ensureRangeIsSet(commentPath, fixReplacementInfo.range);
-      ensureRangeIsValid(commentPath, fixReplacementInfo.range);
-      ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
-    }
-
-    Map<String, List<FixReplacementInfo>> replacementsPerFilePath =
-        fixReplacementInfos.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
-    for (List<FixReplacementInfo> sameFileReplacements : replacementsPerFilePath.values()) {
-      ensureRangesDoNotOverlap(commentPath, sameFileReplacements);
-    }
-  }
-
-  private static void ensureReplacementsArePresent(
-      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
-    if (fixReplacementInfos == null || fixReplacementInfos.isEmpty()) {
-      throw new BadRequestException(
-          String.format(
-              "At least one replacement is "
-                  + "required for the suggested fix of the robot comment on %s",
-              commentPath));
-    }
-  }
-
-  private static void ensureReplacementPathIsSetAndNotPatchsetLevel(
-      String commentPath, String replacementPath) throws BadRequestException {
-    if (replacementPath == null) {
-      throw new BadRequestException(
-          String.format(
-              "A file path must be given for the replacement of the robot comment on %s",
-              commentPath));
-    }
-    if (replacementPath.equals(PATCHSET_LEVEL)) {
-      throw new BadRequestException(
-          String.format(
-              "A file path must not be %s for the replacement of the robot comment on %s",
-              PATCHSET_LEVEL, commentPath));
-    }
-  }
-
-  private static void ensureRangeIsSet(String commentPath, Range range) throws BadRequestException {
-    if (range == null) {
-      throw new BadRequestException(
-          String.format(
-              "A range must be given for the replacement of the robot comment on %s", commentPath));
-    }
-  }
-
-  private static void ensureRangeIsValid(String commentPath, Range range)
-      throws BadRequestException {
-    if (range == null) {
-      return;
-    }
-    if (!range.isValid()) {
-      throw new BadRequestException(
-          String.format(
-              "Range (%s:%s - %s:%s) is not valid for the comment on %s",
-              range.startLine,
-              range.startCharacter,
-              range.endLine,
-              range.endCharacter,
-              commentPath));
-    }
-  }
-
-  private static void ensureReplacementStringIsSet(String commentPath, String replacement)
-      throws BadRequestException {
-    if (replacement == null) {
-      throw new BadRequestException(
-          String.format(
-              "A content for replacement "
-                  + "must be indicated for the replacement of the robot comment on %s",
-              commentPath));
-    }
-  }
-
-  private static void ensureRangesDoNotOverlap(
-      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
-    List<Range> sortedRanges =
-        fixReplacementInfos.stream()
-            .map(fixReplacementInfo -> fixReplacementInfo.range)
-            .sorted()
-            .collect(toList());
-
-    int previousEndLine = 0;
-    int previousOffset = -1;
-    for (Range range : sortedRanges) {
-      if (range.startLine < previousEndLine
-          || (range.startLine == previousEndLine && range.startCharacter < previousOffset)) {
-        throw new BadRequestException(
-            String.format("Replacements overlap for the robot comment on %s", commentPath));
-      }
-      previousEndLine = range.endLine;
-      previousOffset = range.endCharacter;
+          String.format("robotRunId is missing for comment on %s", commentPath));
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index a47e179..490ff490 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
@@ -35,8 +35,6 @@
 import com.google.common.collect.Table.Cell;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.FixReplacement;
-import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
@@ -47,8 +45,6 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
-import com.google.gerrit.extensions.common.FixReplacementInfo;
-import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
@@ -57,7 +53,6 @@
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.IdentifiedUser;
@@ -177,6 +172,54 @@
     }
   }
 
+  @AutoValue
+  public abstract static class Result {
+    /**
+     * Whether this {@code PostReviewOp} updated any vote on the current patch set.
+     *
+     * @return returns {@code true} if a) ReviewInput contained votes and b) ReviewInput was applied
+     *     on the current patch set or any votes got copied to the current patch set.
+     */
+    abstract boolean updatedAnyVoteOnCurrentPatchSet();
+
+    /**
+     * Whether this {@code PostReviewOp} applied any negative vote on the current patch set.
+     *
+     * @return returns {@code true} if a) ReviewInput contained negative votes and b) ReviewInput
+     *     was applied on the current patch set or any of the negative votes got copied to the
+     *     current patch set.
+     */
+    abstract boolean updatedAnyNegativeVoteOnCurrentPatchSet();
+
+    /**
+     * Whether this {@code PostReviewOp} applied votes on an outdated patch set that were not copied
+     * to the current patch set.
+     *
+     * @return returns {@code true} if a) ReviewInput contained votes, b) ReviewInput was applied on
+     *     an outdated patch set and c) not all of the votes got copied to the current patch set
+     */
+    abstract boolean appliedVotesOnOutdatedPatchSetThatWereNotCopiedToCurrentPatchSet();
+
+    /**
+     * Whether this {@code PostReviewOp} posted a change message.
+     *
+     * @return returns {@code true} if ReviewInput contained a message.
+     */
+    abstract boolean postedChangeMessage();
+
+    static Result create(
+        boolean updatedAnyVoteOnCurrentPatchSet,
+        boolean updatedAnyNegativeVoteOnCurrentPatchSet,
+        boolean appliedVotesOnOutdatedPatchSetThatWereNotCopiedToCurrentPatchSet,
+        boolean postedChangeMessage) {
+      return new AutoValue_PostReviewOp_Result(
+          updatedAnyVoteOnCurrentPatchSet,
+          updatedAnyNegativeVoteOnCurrentPatchSet,
+          appliedVotesOnOutdatedPatchSetThatWereNotCopiedToCurrentPatchSet,
+          postedChangeMessage);
+    }
+  }
+
   @VisibleForTesting
   public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
 
@@ -209,6 +252,8 @@
   private Map<String, Short> approvals = new HashMap<>();
   private Map<String, Short> oldApprovals = new HashMap<>();
 
+  private Result result;
+
   @Inject
   PostReviewOp(
       @GerritServerConfig Config gerritConfig,
@@ -272,6 +317,14 @@
     try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
       dirty |= insertMessage(ctx);
     }
+
+    result =
+        Result.create(
+            updatedAnyVoteOnCurrentPatchSet(),
+            updatedAnyNegativeVoteOnCurrentPatchSet(),
+            appliedVotesOnOutdatedPatchSetThatWereNotCopiedToCurrentPatchSet(),
+            postedChangeMessage());
+
     return dirty;
   }
 
@@ -404,7 +457,8 @@
                   inputComment.side(),
                   inputComment.message,
                   inputComment.unresolved,
-                  parent);
+                  parent,
+                  CommentsUtil.createFixSuggestionsFromInput(inputComment.fixSuggestions));
         } else {
           // In ChangeUpdate#putDraftComment() the draft with the same ID will be deleted.
           comment.writtenOn = Timestamp.from(ctx.getWhen());
@@ -510,39 +564,11 @@
     robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
     robotComment.tag = in.tag;
     commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
-    robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
+    robotComment.fixSuggestions =
+        CommentsUtil.createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
     return robotComment;
   }
 
-  private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
-      List<FixSuggestionInfo> fixSuggestionInfos) {
-    if (fixSuggestionInfos == null) {
-      return ImmutableList.of();
-    }
-
-    ImmutableList.Builder<FixSuggestion> fixSuggestions =
-        ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
-    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
-      fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
-    }
-    return fixSuggestions.build();
-  }
-
-  private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
-    List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
-    String fixId = ChangeUtil.messageUuid();
-    return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
-  }
-
-  private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
-    return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
-  }
-
-  private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
-    Comment.Range range = new Comment.Range(fixReplacementInfo.range);
-    return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
-  }
-
   private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
     return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
         .map(CommentSetEntry::create)
@@ -687,8 +713,21 @@
         addLabelDelta(normName, c.value());
         oldApprovals.put(normName, previous.get(normName));
         approvals.put(normName, c.value());
-        update.putReviewer(user.getAccountId(), REVIEWER);
         update.putApproval(normName, ent.getValue());
+
+        // Votes may be applied on outdated patch sets, using a ChangeUpdate that was created for
+        // the outdated patch set. Reviewers however cannot be added on outdated patch sets, but
+        // only on the change. This means reviewers should always be added using a ChangeUpdate
+        // that was created for the current patch set.
+        // This is important so that updates on the current patch set that are done by other ops
+        // within the same BatchUpdate after this PostReviewOp was executed can see the reviewer
+        // updates. E.g. the AddToAttentionSetOp, that updates the attention set on the current
+        // patch set, needs to see newly added reviewers, as otherwise attention set updates for
+        // these reviewers are dropped (ChangeUpdate#updateAttentionSet drops attention set updates
+        // for users that are not active on the change, i.e. for users that are neither change
+        // owner, uploader nor reviewer).
+        ctx.getUpdate(notes.getChange().currentPatchSetId())
+            .putReviewer(user.getAccountId(), REVIEWER);
       }
     }
 
@@ -1147,6 +1186,98 @@
     labelDelta.add(LabelVote.create(name, value));
   }
 
+  /**
+   * Gets the result of running this {@code PostReviewOp}.
+   *
+   * <p>Must only be invoked after this {@code PostReviewOp} has been executed with {@link
+   * com.google.gerrit.server.update.BatchUpdate}.
+   *
+   * @throws IllegalStateException thrown if invoked before this {@code PostReviewOp} has been
+   *     executed
+   */
+  public Result getResult() {
+    checkState(result != null, "cannot retrieve result, change update has not been executed yet");
+    return result;
+  }
+
+  /**
+   * Whether this {@code PostReviewOp} updated any vote on the current patch set.
+   *
+   * <p>Must only be invoked after this {@code PostReviewOp} has been executed with {@link
+   * com.google.gerrit.server.update.BatchUpdate}.
+   *
+   * @return returns {@code true} if a) ReviewInput contained votes and b) ReviewInput was applied
+   *     on the current patch set or any votes got copied to the current patch set.
+   */
+  private boolean updatedAnyVoteOnCurrentPatchSet() {
+    return in.labels != null
+        && !in.labels.isEmpty()
+        && (notes.getCurrentPatchSet().id().equals(psId)
+            || labelUpdatesOnFollowUpPatchSets.values().stream()
+                .anyMatch(
+                    copiedLabelUpdate ->
+                        copiedLabelUpdate.patchSetId().equals(notes.getCurrentPatchSet().id())));
+  }
+
+  /**
+   * Whether this {@code PostReviewOp} applied any negative vote on the current patch set.
+   *
+   * <p>Must only be invoked after this {@code PostReviewOp} has been executed with {@link
+   * com.google.gerrit.server.update.BatchUpdate}.
+   *
+   * @return returns {@code true} if a) ReviewInput contained negative votes and b) ReviewInput was
+   *     applied on the current patch set or any of the negative votes got copied to the current
+   *     patch set.
+   */
+  private boolean updatedAnyNegativeVoteOnCurrentPatchSet() {
+    return in.labels != null
+        && in.labels.values().stream().anyMatch(vote -> vote < 0)
+        && (notes.getCurrentPatchSet().id().equals(psId)
+            || labelUpdatesOnFollowUpPatchSets.entries().stream()
+                .filter(e -> e.getKey().value() < 0)
+                .anyMatch(e -> e.getValue().patchSetId().equals(notes.getCurrentPatchSet().id())));
+  }
+
+  /**
+   * Whether this {@code PostReviewOp} applied votes on an outdated patch set that were not copied
+   * to the current patch set.
+   *
+   * <p>Must only be invoked after this {@code PostReviewOp} has been executed with {@link
+   * com.google.gerrit.server.update.BatchUpdate}.
+   *
+   * @return returns {@code true} if a) ReviewInput contained votes, b) ReviewInput was applied on
+   *     an outdated patch set and c) not all of the votes got copied to the current patch set
+   */
+  private boolean appliedVotesOnOutdatedPatchSetThatWereNotCopiedToCurrentPatchSet() {
+    if (in.labels == null || notes.getCurrentPatchSet().id().equals(psId)) {
+      return false;
+    }
+
+    for (Map.Entry<String, Short> labelEntry : in.labels.entrySet()) {
+      if (labelUpdatesOnFollowUpPatchSets
+          .get(LabelVote.create(labelEntry.getKey(), labelEntry.getValue())).stream()
+          .anyMatch(
+              copiedLabelUpdate ->
+                  copiedLabelUpdate.patchSetId().equals(notes.getCurrentPatchSet().id()))) {
+        continue;
+      }
+
+      // vote was not copied to current patch set
+      return true;
+    }
+
+    return false;
+  }
+
+  /**
+   * Whether this {@code PostReviewOp} posted a change message.
+   *
+   * @return returns {@code true} if ReviewInput contained a message.
+   */
+  private boolean postedChangeMessage() {
+    return !Strings.isNullOrEmpty(in.message);
+  }
+
   private TraceContext.TraceTimer newTimer(String method) {
     return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
   }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index b0e58c5..675610d 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -65,10 +65,6 @@
   public Response<ReviewerResult> apply(ChangeResource rsrc, ReviewerInput input)
       throws IOException, RestApiException, UpdateException, PermissionBackendException,
           ConfigInvalidException {
-    if (input.reviewer == null) {
-      throw new BadRequestException("missing reviewer field");
-    }
-
     ReviewerModification modification =
         reviewerModifier.prepare(rsrc.getNotes(), rsrc.getUser(), input, true);
     if (modification.op == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 345d915..912239b 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -181,6 +181,9 @@
     if (in.unresolved != null) {
       e.unresolved = in.unresolved;
     }
+    if (in.fixSuggestions != null) {
+      e.fixSuggestions = CommentsUtil.createFixSuggestionsFromInput(in.fixSuggestions);
+    }
     return e;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 3717e02..91f1575 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -142,7 +142,8 @@
 
           PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.id());
           ObjectId newCommit =
-              createCommit(objectInserter, patchSetCommit, sanitizedCommitMessage, ts);
+              createCommit(
+                  objectInserter, patchSetCommit, sanitizedCommitMessage, ts, input.committerEmail);
           PatchSetInserter inserter =
               psInserterFactory.create(resource.getNotes(), psId, newCommit);
           inserter.setMessage(
@@ -171,20 +172,35 @@
       ObjectInserter objectInserter,
       RevCommit basePatchSetCommit,
       String commitMessage,
-      Instant timestamp)
-      throws IOException {
+      Instant timestamp,
+      String committerEmail)
+      throws IOException, BadRequestException {
     CommitBuilder builder = new CommitBuilder();
     builder.setTreeId(basePatchSetCommit.getTree());
     builder.setParentIds(basePatchSetCommit.getParents());
     builder.setAuthor(basePatchSetCommit.getAuthorIdent());
     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));
+    PersonIdent committer;
+    if (committerEmail == null) {
+      committer =
+          Optional.ofNullable(basePatchSetCommit.getCommitterIdent())
+              .map(
+                  ident ->
+                      user.newCommitterIdent(ident.getEmailAddress(), timestamp, zoneId)
+                          .orElseGet(() -> user.newCommitterIdent(timestamp, zoneId)))
+              .orElseGet(() -> user.newCommitterIdent(timestamp, zoneId));
+    } else {
+      committer =
+          user.newCommitterIdent(committerEmail, timestamp, zoneId)
+              .orElseThrow(
+                  () ->
+                      new BadRequestException(
+                          String.format(
+                              "Cannot set commit message using committer email %s, "
+                                  + "as it is not among the registered emails of account %s",
+                              committerEmail, user.getAccountId().get())));
+    }
+
     builder.setCommitter(committer);
     builder.setMessage(commitMessage);
     ObjectId newCommitId = objectInserter.insert(builder);
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 4d279b0..812711a 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -172,9 +172,7 @@
       throw new QueryParseException("query disabled");
     }
 
-    if (limit != null) {
-      queryProcessor.setUserProvidedLimit(limit);
-    }
+    queryProcessor.setUserProvidedLimit(limit != null ? limit : 0, /* applyDefaultLimit */ true);
     if (start != null) {
       if (start < 0) {
         throw new BadRequestException("'start' parameter cannot be less than zero");
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 98a3f83..9d574a4 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -172,7 +172,7 @@
     boolean enabled = false;
     try (Repository repo = repoManager.openRepository(change.getDest().project());
         RevWalk rw = new RevWalk(repo)) {
-      if (RebaseUtil.hasOneParent(rw, rsrc.getPatchSet())) {
+      if (RebaseUtil.hasAtLeastOneParent(rw, rsrc.getPatchSet())) {
         enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChain.java b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
index 76c5253..68d3c63 100644
--- a/java/com/google/gerrit/server/restapi/change/RebaseChain.java
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
@@ -311,7 +311,7 @@
       } else {
         for (RevisionResource psRsrc : chainAsRevisionResources) {
           if (patchSetUtil.isPatchSetLocked(psRsrc.getNotes())
-              || !RebaseUtil.hasOneParent(rw, psRsrc.getPatchSet())) {
+              || !RebaseUtil.hasAtLeastOneParent(rw, psRsrc.getPatchSet())) {
             enabled = false;
             break;
           }
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 7f4b10f..bc47adc 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -15,11 +15,13 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Sets.SetView;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.HumanComment;
@@ -37,6 +39,7 @@
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.AttentionSetUnchangedOp;
+import com.google.gerrit.server.change.AttentionSetUpdateCondition;
 import com.google.gerrit.server.change.CommentThread;
 import com.google.gerrit.server.change.CommentThreads;
 import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
@@ -106,6 +109,7 @@
     }
     processRules(
         bu,
+        /* postReviewOp= */ null,
         changeNotes,
         readyForReview,
         currentUser,
@@ -113,14 +117,24 @@
   }
 
   /**
-   * Adjusts the attention set by adding and removing users. If the same user should be added and
-   * removed or added/removed twice, the user will only be added/removed once, based on first
-   * addition/removal.
+   * Adjusts the attention set when a review is posted.
+   *
+   * <p>If the same user should be added and removed or added/removed twice, the user will only be
+   * added/removed once, based on first addition/removal.
+   *
+   * @param postReviewOp the {@link PostReviewOp} that is being executed before the attention set
+   *     updates
    */
-  public void updateAttentionSet(
-      BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input, CurrentUser currentUser)
+  public void updateAttentionSetOnPostReview(
+      BatchUpdate bu,
+      PostReviewOp postReviewOp,
+      ChangeNotes changeNotes,
+      ReviewInput input,
+      CurrentUser currentUser)
       throws BadRequestException, IOException, PermissionBackendException,
           UnprocessableEntityException, ConfigInvalidException {
+    requireNonNull(postReviewOp, "postReviewOp must not be null");
+
     processManualUpdates(bu, changeNotes, input);
     if (input.ignoreAutomaticAttentionSetRules) {
 
@@ -133,12 +147,13 @@
     boolean isReadyForReview = isReadyForReview(changeNotes, input);
 
     if (isReadyForReview && serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
-      botsWithNegativeLabelsAddOwnerAndUploader(bu, changeNotes, input);
+      botsWithNegativeLabelsAddOwnerAndUploader(bu, postReviewOp, changeNotes);
       return;
     }
 
     processRules(
         bu,
+        postReviewOp,
         changeNotes,
         isReadyForReview,
         currentUser,
@@ -163,7 +178,8 @@
                 commentInput.side(),
                 commentInput.message,
                 commentInput.unresolved,
-                commentInput.inReplyTo));
+                commentInput.inReplyTo,
+                CommentsUtil.createFixSuggestionsFromInput(commentInput.fixSuggestions)));
       }
     }
     List<HumanComment> drafts = new ArrayList<>();
@@ -181,27 +197,33 @@
   }
 
   /**
-   * Process the automatic rules of the attention set. All of the automatic rules except
-   * adding/removing reviewers and entering/exiting WIP state are done here, and the rest are done
-   * in {@link ChangeUpdate}
+   * Process the automatic rules of the attention set.
+   *
+   * <p>All of the automatic rules except adding/removing reviewers and entering/exiting WIP state
+   * are done here, and the rest are done in {@link ChangeUpdate}.
+   *
+   * @param postReviewOp {@link PostReviewOp} that is being executed before the attention set
+   *     updates, may be {@code null}
    */
   private void processRules(
       BatchUpdate bu,
+      @Nullable PostReviewOp postReviewOp,
       ChangeNotes changeNotes,
       boolean readyForReview,
       CurrentUser currentUser,
       ImmutableSet<HumanComment> allNewComments) {
-    // Replying removes the publishing user from the attention set.
-    removeFromAttentionSet(bu, changeNotes, currentUser.getAccountId(), "removed on reply", false);
-
-    Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
-    Account.Id owner = changeNotes.getChange().getOwner();
+    updateAttentionSetForCurrentUser(bu, postReviewOp, changeNotes, currentUser);
 
     // The rest of the conditions only apply if the change is open.
     if (changeNotes.getChange().getStatus().isClosed()) {
       // We still add the owner if a new comment thread was created, on closed changes.
       if (allNewComments.stream().anyMatch(c -> c.parentUuid == null)) {
-        addToAttentionSet(bu, changeNotes, owner, "A new comment thread was created", false);
+        addToAttentionSet(
+            bu,
+            changeNotes,
+            changeNotes.getChange().getOwner(),
+            "A new comment thread was created",
+            false);
       }
       return;
     }
@@ -211,14 +233,98 @@
       return;
     }
 
-    if (!currentUser.getAccountId().equals(owner)) {
-      addToAttentionSet(bu, changeNotes, owner, "Someone else replied on the change", false);
+    addOwnerAndUploaderToAttentionSetIfSomeoneElseReplied(
+        bu, postReviewOp, changeNotes, currentUser, readyForReview, allNewComments);
+    addAllAuthorsOfCommentThreads(bu, changeNotes, allNewComments);
+  }
+
+  /**
+   * Updates the attention set for the current user.
+   *
+   * <p>Removes the current user from the attention set (since they replied) unless they voted on an
+   * outdated patch set and some of the votes were not copied to the current patch set (in this case
+   * they should be in the attention set to re-apply their votes).
+   *
+   * <p>If the current user voted on an outdated patch set and some of the votes were not copied to
+   * the current patch set:
+   *
+   * <ul>
+   *   <li>the current user is added to the attention set (if they are not in the attention set yet)
+   *       or
+   *   <li>the reason for the current user to be in the attention set is updated (if they are
+   *       already in the attention set).
+   * </ul>
+   */
+  private void updateAttentionSetForCurrentUser(
+      BatchUpdate bu,
+      @Nullable PostReviewOp postReviewOp,
+      ChangeNotes changeNotes,
+      CurrentUser currentUser) {
+    if (postReviewOp == null) {
+      // Replying removes the current user from the attention set.
+      removeFromAttentionSet(
+          bu, changeNotes, currentUser.getAccountId(), "removed on reply", false);
+    } else {
+      // If the current user voted on an outdated patch set and some of the votes were not copied to
+      // the current patch set the current user should stay in the attention set, or be added to the
+      // attention set. In case the user stays in the attention set, this updates the reason for
+      // being in the attention set.
+      AttentionSetUpdateCondition addOrKeepCondition =
+          () ->
+              postReviewOp
+                  .getResult()
+                  .appliedVotesOnOutdatedPatchSetThatWereNotCopiedToCurrentPatchSet();
+      maybeAddToAttentionSet(
+          bu,
+          addOrKeepCondition,
+          changeNotes,
+          currentUser.getAccountId(),
+          "Some votes were not copied to the current patch set",
+          false);
+
+      // Otherwise replying removes the current user from the attention set.
+      AttentionSetUpdateCondition removeCondition = () -> !addOrKeepCondition.check();
+      maybeRemoveFromAttentionSet(
+          bu, removeCondition, changeNotes, currentUser.getAccountId(), "removed on reply", false);
     }
-    if (!owner.equals(uploader) && !currentUser.getAccountId().equals(uploader)) {
-      addToAttentionSet(bu, changeNotes, uploader, "Someone else replied on the change", false);
+  }
+
+  /**
+   * Adds the owner and uploader to the attention set if someone else replied.
+   *
+   * <p>Replying means they either updated the votes on the current patch set (either directly on
+   * the current patch set or the votes were copied to the current patch set), they posted a change
+   * message, they marked the change as ready or they posted new comments.
+   */
+  private void addOwnerAndUploaderToAttentionSetIfSomeoneElseReplied(
+      BatchUpdate bu,
+      @Nullable PostReviewOp postReviewOp,
+      ChangeNotes changeNotes,
+      CurrentUser currentUser,
+      boolean readyForReview,
+      ImmutableSet<HumanComment> allNewComments) {
+    AttentionSetUpdateCondition condition =
+        postReviewOp != null
+            ? () ->
+                postReviewOp.getResult().updatedAnyVoteOnCurrentPatchSet()
+                    || postReviewOp.getResult().postedChangeMessage()
+                    || (changeNotes.getChange().isWorkInProgress() && readyForReview)
+                    || !allNewComments.isEmpty()
+            : () ->
+                (changeNotes.getChange().isWorkInProgress() && readyForReview)
+                    || !allNewComments.isEmpty();
+
+    Account.Id owner = changeNotes.getChange().getOwner();
+    if (!currentUser.getAccountId().equals(owner)) {
+      maybeAddToAttentionSet(
+          bu, condition, changeNotes, owner, "Someone else replied on the change", false);
     }
 
-    addAllAuthorsOfCommentThreads(bu, changeNotes, allNewComments);
+    Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
+    if (!owner.equals(uploader) && !currentUser.getAccountId().equals(uploader)) {
+      maybeAddToAttentionSet(
+          bu, condition, changeNotes, uploader, "Someone else replied on the change", false);
+    }
   }
 
   /** Adds all authors of all comment threads that received a reply during this update */
@@ -266,20 +372,26 @@
 
   /**
    * Bots don't process automatic rules, the only attention set change they do is this rule: Add
-   * owner and uploader when a bot votes negatively, but only if the change is open.
+   * owner and uploader when a bot votes negatively on the current patch set, but only if the change
+   * is open.
    */
   private void botsWithNegativeLabelsAddOwnerAndUploader(
-      BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input) {
+      BatchUpdate bu, PostReviewOp postReviewOp, ChangeNotes changeNotes) {
     if (changeNotes.getChange().isClosed()) {
       return;
     }
-    if (input.labels != null && input.labels.values().stream().anyMatch(vote -> vote < 0)) {
-      Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
-      Account.Id owner = changeNotes.getChange().getOwner();
-      addToAttentionSet(bu, changeNotes, owner, "A robot voted negatively on a label", false);
-      if (!owner.equals(uploader)) {
-        addToAttentionSet(bu, changeNotes, uploader, "A robot voted negatively on a label", false);
-      }
+
+    AttentionSetUpdateCondition condition =
+        () -> postReviewOp.getResult().updatedAnyNegativeVoteOnCurrentPatchSet();
+
+    Account.Id owner = changeNotes.getChange().getOwner();
+    maybeAddToAttentionSet(
+        bu, condition, changeNotes, owner, "A robot voted negatively on a label", false);
+
+    Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
+    if (!owner.equals(uploader)) {
+      maybeAddToAttentionSet(
+          bu, condition, changeNotes, uploader, "A robot voted negatively on a label", false);
     }
   }
 
@@ -299,6 +411,28 @@
   }
 
   /**
+   * Adds the user to the attention set if the given condition is true.
+   *
+   * @param bu BatchUpdate to perform the updates to the attention set
+   * @param condition condition that decides whether the attention set update should be performed
+   * @param changeNotes current change
+   * @param user user to add to the attention set
+   * @param reason reason for adding
+   * @param notify whether or not to notify about this addition
+   */
+  private void maybeAddToAttentionSet(
+      BatchUpdate bu,
+      AttentionSetUpdateCondition condition,
+      ChangeNotes changeNotes,
+      Account.Id user,
+      String reason,
+      boolean notify) {
+    AddToAttentionSetOp addToAttentionSet =
+        addToAttentionSetOpFactory.create(user, reason, notify).setCondition(condition);
+    bu.addOp(changeNotes.getChangeId(), addToAttentionSet);
+  }
+
+  /**
    * Removes the user from the attention set
    *
    * @param bu BatchUpdate to perform the updates to the attention set.
@@ -314,6 +448,28 @@
     bu.addOp(changeNotes.getChangeId(), removeFromAttentionSetOp);
   }
 
+  /**
+   * Removes the user from the attention set if the given condition is true.
+   *
+   * @param bu BatchUpdate to perform the updates to the attention set.
+   * @param condition condition that decides whether the attention set update should be performed
+   * @param changeNotes current change.
+   * @param user user to add remove from the attention set.
+   * @param reason reason for removing.
+   * @param notify whether or not to notify about this removal.
+   */
+  private void maybeRemoveFromAttentionSet(
+      BatchUpdate bu,
+      AttentionSetUpdateCondition condition,
+      ChangeNotes changeNotes,
+      Account.Id user,
+      String reason,
+      boolean notify) {
+    RemoveFromAttentionSetOp removeFromAttentionSetOp =
+        removeFromAttentionSetOpFactory.create(user, reason, notify).setCondition(condition);
+    bu.addOp(changeNotes.getChangeId(), removeFromAttentionSetOp);
+  }
+
   private static boolean isReadyForReview(ChangeNotes changeNotes, ReviewInput input) {
     return (!changeNotes.getChange().isWorkInProgress() && !input.workInProgress) || input.ready;
   }
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 5bf0e8b..f04042c 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -25,7 +25,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -81,7 +81,6 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.Comparator;
 import java.util.Iterator;
 import java.util.List;
@@ -236,7 +235,7 @@
       throws RestApiException, IOException, UpdateException, ConfigInvalidException,
           StorageException, PermissionBackendException {
 
-    Multimap<BranchNameKey, ChangeData> changesPerProjectAndBranch = ArrayListMultimap.create();
+    ListMultimap<BranchNameKey, ChangeData> changesPerProjectAndBranch = ArrayListMultimap.create();
     changeData.stream().forEach(c -> changesPerProjectAndBranch.put(c.change().getDest(), c));
     cherryPickInput = createCherryPickInput(revertInput);
     Instant timestamp = TimeUtil.now();
@@ -246,8 +245,7 @@
       cherryPickInput.base = null;
       Project.NameKey project = projectAndBranch.project();
       cherryPickInput.destination = projectAndBranch.branch();
-      Collection<ChangeData> changesInProjectAndBranch =
-          changesPerProjectAndBranch.get(projectAndBranch);
+      List<ChangeData> changesInProjectAndBranch = changesPerProjectAndBranch.get(projectAndBranch);
 
       // Sort the changes topologically.
       Iterator<PatchSetData> sortedChangesInProjectAndBranch =
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index a6600d7..819ae72 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -203,7 +203,7 @@
     int numberOfRelevantChanges = config.getInt("suggest", "relevantChanges", 50);
     // Get the user's last numberOfRelevantChanges changes, check reviewers
     try {
-      List<ChangeData> result =
+      ImmutableList<ChangeData> result =
           queryProvider
               .get()
               .setLimit(numberOfRelevantChanges)
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewers.java b/java/com/google/gerrit/server/restapi/change/Reviewers.java
index 9da7c88..864a559 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewers.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -29,7 +30,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collection;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -86,7 +86,7 @@
     throw new ResourceNotFoundException(id);
   }
 
-  private Collection<Account.Id> fetchAccountIds(ChangeResource rsrc) {
+  private ImmutableSet<Account.Id> fetchAccountIds(ChangeResource rsrc) {
     return approvalsUtil.getReviewers(rsrc.getNotes()).all();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 8dd20c9..05648d5 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -254,6 +254,8 @@
     return Account.id(Integer.valueOf(f.<String>getValue(AccountField.ID_STR_FIELD_SPEC)));
   }
 
+  // More accounts are suggested here than the requested limit because
+  // visibility filtering will be applied later.
   private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers)
       throws BadRequestException {
     try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
@@ -278,7 +280,7 @@
                   QueryOptions.create(
                       indexConfig,
                       0,
-                      suggestReviewers.getLimit(),
+                      suggestReviewers.getLimit() + 30,
                       ImmutableSet.of(idField.getName())))
               .readRaw();
       List<Account.Id> matches =
diff --git a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
index 97383cda..de0014a 100644
--- a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -31,7 +32,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collection;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -83,7 +83,7 @@
         throw e;
       }
     }
-    Collection<Account.Id> reviewers = approvalsUtil.getReviewers(rsrc.getNotes()).all();
+    ImmutableSet<Account.Id> reviewers = approvalsUtil.getReviewers(rsrc.getNotes()).all();
     // See if the id exists as a reviewer for this change
     if (reviewers.contains(accountId)) {
       return resourceFactory.create(rsrc, accountId);
diff --git a/java/com/google/gerrit/server/restapi/change/RobotComments.java b/java/com/google/gerrit/server/restapi/change/RobotComments.java
index 9f81d0a..e02a39f 100644
--- a/java/com/google/gerrit/server/restapi/change/RobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/RobotComments.java
@@ -27,6 +27,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+@Deprecated
 @Singleton
 public class RobotComments implements ChildCollection<RevisionResource, RobotCommentResource> {
   private final DynamicMap<RestView<RobotCommentResource>> views;
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index be2fae3..36b859c 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -73,7 +74,7 @@
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.Map;
+import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -120,6 +121,8 @@
   private final ProjectCache projectCache;
   private final ChangeJson.Factory json;
 
+  private final boolean useMergeabilityCheck;
+
   @Inject
   Submit(
       GitRepositoryManager repoManager,
@@ -166,6 +169,7 @@
     this.psUtil = psUtil;
     this.projectCache = projectCache;
     this.json = json;
+    this.useMergeabilityCheck = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
   }
 
   @Override
@@ -278,6 +282,9 @@
         }
       }
 
+      if (!useMergeabilityCheck) {
+        return null;
+      }
       Collection<ChangeData> unmergeable = getUnmergeableChanges(cs);
       if (unmergeable == null) {
         return CLICK_FAILURE_TOOLTIP;
@@ -347,10 +354,10 @@
     // cd.setMergeable(null);
     // That was done in unmergeableChanges which was called by problemsForSubmittingChangeset, so
     // now it is safe to read from the cache, as it yields the same result.
-    Boolean enabled = cd.isMergeable();
+    Boolean enabled = useMergeabilityCheck ? cd.isMergeable() : true;
 
     if (treatWithTopic) {
-      Map<String, String> params =
+      ImmutableMap<String, String> params =
           ImmutableMap.of(
               "topicSize", String.valueOf(topicSize),
               "submitSize", String.valueOf(cs.size()));
@@ -360,7 +367,7 @@
           .setVisible(true)
           .setEnabled(Boolean.TRUE.equals(enabled));
     }
-    Map<String, String> params =
+    ImmutableMap<String, String> params =
         ImmutableMap.of(
             "patchSet", String.valueOf(resource.getPatchSet().number()),
             "branch", change.getDest().shortName(),
@@ -384,7 +391,7 @@
     }
     ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
     for (BranchNameKey branch : cbb.keySet()) {
-      Collection<ChangeData> targetBranch = cbb.get(branch);
+      List<ChangeData> targetBranch = cbb.get(branch);
       HashMap<Change.Id, RevCommit> commits = mapToCommits(targetBranch, branch.project());
       Set<ObjectId> allParents =
           commits.values().stream()
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index 2ce82ab..6e58948 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -188,9 +188,12 @@
     // ChangeData or check if any of the ChangeDatas was loaded from the database and allow
     // lazyloading if so.
     for (ChangeData cd : cds) {
-      cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_LENIENT);
-      cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_STRICT);
-      cd.currentPatchSet();
+      @SuppressWarnings("unused")
+      var unused = cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_LENIENT);
+      unused = cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_STRICT);
+
+      @SuppressWarnings("unused")
+      var unused2 = cd.currentPatchSet();
     }
     return cds;
   }
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
index 26a0415..4d0026f 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
@@ -53,6 +54,7 @@
       name = "--exclude-groups",
       aliases = {"-e"},
       usage = "exclude groups from query")
+  @CanIgnoreReturnValue
   public SuggestChangeReviewers setExcludeGroups(boolean excludeGroups) {
     this.excludeGroups = excludeGroups;
     return this;
@@ -63,6 +65,7 @@
       usage =
           "The type of reviewers that should be suggested"
               + " (can be 'REVIEWER' or 'CC', default is 'REVIEWER')")
+  @CanIgnoreReturnValue
   public SuggestChangeReviewers setReviewerState(ReviewerState reviewerState) {
     this.reviewerState = reviewerState;
     return this;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/restapi/change/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/restapi/change/package-info.java
index 0709b86..14992b8 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.restapi.change;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
index 3de05e9..a17305f 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
@@ -15,6 +15,9 @@
 package com.google.gerrit.server.restapi.config;
 
 import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
+import static com.google.gerrit.server.config.ExperimentResource.EXPERIMENT_KIND;
+import static com.google.gerrit.server.config.IndexResource.INDEX_KIND;
+import static com.google.gerrit.server.config.IndexVersionResource.INDEX_VERSION_KIND;
 import static com.google.gerrit.server.config.TaskResource.TASK_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -27,12 +30,19 @@
   protected void configure() {
     DynamicMap.mapOf(binder(), CapabilityResource.CAPABILITY_KIND);
     DynamicMap.mapOf(binder(), CONFIG_KIND);
+    DynamicMap.mapOf(binder(), EXPERIMENT_KIND);
     DynamicMap.mapOf(binder(), TASK_KIND);
     DynamicMap.mapOf(binder(), TopMenuResource.TOP_MENU_KIND);
+    DynamicMap.mapOf(binder(), INDEX_KIND);
+    DynamicMap.mapOf(binder(), INDEX_VERSION_KIND);
 
     child(CONFIG_KIND, "capabilities").to(CapabilitiesCollection.class);
     post(CONFIG_KIND, "check.consistency").to(CheckConsistency.class);
     put(CONFIG_KIND, "email.confirm").to(ConfirmEmail.class);
+
+    child(CONFIG_KIND, "experiments").to(ExperimentsCollection.class);
+    get(EXPERIMENT_KIND).to(GetExperiment.class);
+
     post(CONFIG_KIND, "index.changes").to(IndexChanges.class);
     get(CONFIG_KIND, "info").to(GetServerInfo.class);
     get(CONFIG_KIND, "preferences").to(GetPreferences.class);
@@ -42,6 +52,7 @@
     get(CONFIG_KIND, "preferences.edit").to(GetEditPreferences.class);
     put(CONFIG_KIND, "preferences.edit").to(SetEditPreferences.class);
     post(CONFIG_KIND, "reload").to(ReloadConfig.class);
+    post(CONFIG_KIND, "snapshot.indexes").to(SnapshotIndexes.class);
 
     child(CONFIG_KIND, "tasks").to(TasksCollection.class);
     delete(TASK_KIND).to(DeleteTask.class);
@@ -50,6 +61,15 @@
     child(CONFIG_KIND, "top-menus").to(TopMenuCollection.class);
     get(CONFIG_KIND, "version").to(GetVersion.class);
 
+    child(CONFIG_KIND, "indexes").to(IndexCollection.class);
+    post(INDEX_KIND, "snapshot").to(SnapshotIndex.class);
+    get(INDEX_KIND).to(GetIndex.class);
+
+    child(INDEX_KIND, "versions").to(IndexVersionsCollection.class);
+    get(INDEX_VERSION_KIND).to(GetIndexVersion.class);
+    post(INDEX_VERSION_KIND, "snapshot").to(SnapshotIndexVersion.class);
+    post(INDEX_VERSION_KIND, "reindex").to(ReindexIndexVersion.class);
+
     // The caches and summary REST endpoints are bound via RestCacheAdminModule.
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/ExperimentsCollection.java b/java/com/google/gerrit/server/restapi/config/ExperimentsCollection.java
new file mode 100644
index 0000000..0fb141c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ExperimentsCollection.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.ExperimentResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.inject.Inject;
+
+public class ExperimentsCollection implements ChildCollection<ConfigResource, ExperimentResource> {
+  private final PermissionBackend permissionBackend;
+  private final DynamicMap<RestView<ExperimentResource>> views;
+  private final ListExperiments list;
+
+  @Inject
+  ExperimentsCollection(
+      PermissionBackend permissionBackend,
+      DynamicMap<RestView<ExperimentResource>> views,
+      ListExperiments list) {
+    this.permissionBackend = permissionBackend;
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public RestView<ConfigResource> list() throws RestApiException {
+    return list;
+  }
+
+  @Override
+  public ExperimentResource parse(ConfigResource parent, IdString id)
+      throws ResourceNotFoundException, Exception {
+    permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    if (ListExperiments.getExperiments().stream().noneMatch(id.get()::equalsIgnoreCase)) {
+      throw new ResourceNotFoundException(id.get());
+    }
+
+    return new ExperimentResource(id.get());
+  }
+
+  @Override
+  public DynamicMap<RestView<ExperimentResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetExperiment.java b/java/com/google/gerrit/server/restapi/config/GetExperiment.java
new file mode 100644
index 0000000..1c3bd0a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetExperiment.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.common.ExperimentInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.ExperimentResource;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetExperiment implements RestReadView<ExperimentResource> {
+  private final ExperimentFeatures experimentFeatures;
+
+  @Inject
+  public GetExperiment(ExperimentFeatures experimentFeatures) {
+    this.experimentFeatures = experimentFeatures;
+  }
+
+  @Override
+  public Response<ExperimentInfo> apply(ExperimentResource resource) {
+    return Response.ok(getExperimentInfo(resource.getName()));
+  }
+
+  public ExperimentInfo getExperimentInfo(String experimentName) {
+    ExperimentInfo experimentInfo = new ExperimentInfo();
+    experimentInfo.enabled = experimentFeatures.isFeatureEnabled(experimentName);
+    return experimentInfo;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetIndex.java b/java/com/google/gerrit/server/restapi/config/GetIndex.java
new file mode 100644
index 0000000..c96c66e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetIndex.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.IndexResource;
+
+public class GetIndex implements RestReadView<IndexResource> {
+
+  @Override
+  public Response<IndexInfo> apply(IndexResource rsrc)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    return Response.ok(IndexInfo.fromIndexDefinition(rsrc.getIndexDefinition()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetIndexVersion.java b/java/com/google/gerrit/server/restapi/config/GetIndexVersion.java
new file mode 100644
index 0000000..1955511
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/GetIndexVersion.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.server.config.IndexVersionResource;
+import com.google.gerrit.server.restapi.config.IndexInfo.IndexVersionInfo;
+
+public class GetIndexVersion implements RestReadView<IndexVersionResource> {
+
+  @Override
+  public Response<IndexVersionInfo> apply(IndexVersionResource rsrc)
+      throws ResourceNotFoundException {
+    IndexCollection<?, ?, ?> indexCollection = rsrc.getIndexDefinition().getIndexCollection();
+    int version = rsrc.getIndex().getSchema().getVersion();
+    boolean isSearch = indexCollection.getSearchIndex().getSchema().getVersion() == version;
+    boolean isWrite = indexCollection.getWriteIndex(version) != null;
+    return Response.ok(IndexInfo.IndexVersionInfo.create(isWrite, isSearch));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 9e0eac7..a9f16e7 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.ContributorAgreement;
@@ -67,7 +68,6 @@
 import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
@@ -175,7 +175,7 @@
     info.gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
 
     if (info.useContributorAgreements != null) {
-      Collection<ContributorAgreement> agreements =
+      ImmutableCollection<ContributorAgreement> agreements =
           projectCache.getAllProjects().getConfig().getContributorAgreements().values();
       if (!agreements.isEmpty()) {
         info.contributorAgreements = Lists.newArrayListWithCapacity(agreements.size());
@@ -252,6 +252,7 @@
   private DownloadSchemeInfo getDownloadSchemeInfo(DownloadScheme scheme) {
     DownloadSchemeInfo info = new DownloadSchemeInfo();
     info.url = scheme.getUrl("${project}");
+    info.description = scheme.getDescription();
     info.isAuthRequired = toBoolean(scheme.isAuthRequired());
     info.isAuthSupported = toBoolean(scheme.isAuthSupported());
 
diff --git a/java/com/google/gerrit/server/restapi/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
index faa3871..77af0f3 100644
--- a/java/com/google/gerrit/server/restapi/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -33,9 +33,7 @@
 import java.net.InetAddress;
 import java.net.UnknownHostException;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -75,7 +73,7 @@
   }
 
   private TaskSummaryInfo getTaskSummary() {
-    Collection<Task<?>> pending = workQueue.getTasks();
+    List<Task<?>> pending = workQueue.getTasks();
     int tasksTotal = pending.size();
     int tasksStopping = 0;
     int tasksRunning = 0;
@@ -203,7 +201,7 @@
       // Ignored
     }
 
-    jvmSummary.currentWorkingDirectory = path(Paths.get(".").toAbsolutePath().getParent());
+    jvmSummary.currentWorkingDirectory = path(Path.of(".").toAbsolutePath().getParent());
     jvmSummary.site = path(sitePath);
     return jvmSummary;
   }
diff --git a/java/com/google/gerrit/server/restapi/config/IndexCollection.java b/java/com/google/gerrit/server/restapi/config/IndexCollection.java
new file mode 100644
index 0000000..99a5718
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/IndexCollection.java
@@ -0,0 +1,71 @@
+// 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.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.IndexResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Collection;
+
+@RequiresCapability(MAINTAIN_SERVER)
+@Singleton
+public class IndexCollection implements ChildCollection<ConfigResource, IndexResource> {
+  private final DynamicMap<RestView<IndexResource>> views;
+  private final Provider<ListIndexes> list;
+  private final Collection<IndexDefinition<?, ?, ?>> defs;
+
+  @Inject
+  IndexCollection(
+      DynamicMap<RestView<IndexResource>> views,
+      Provider<ListIndexes> list,
+      Collection<IndexDefinition<?, ?, ?>> defs) {
+    this.views = views;
+    this.list = list;
+    this.defs = defs;
+  }
+
+  @Override
+  public IndexResource parse(ConfigResource parent, IdString id) throws ResourceNotFoundException {
+    String indexName = id.toString();
+    for (IndexDefinition<?, ?, ?> def : defs) {
+      if (def.getName().equals(indexName)) {
+        return new IndexResource(def);
+      }
+    }
+    throw new ResourceNotFoundException("Unknown index requested: " + indexName);
+  }
+
+  @Override
+  public RestView<ConfigResource> list() throws RestApiException {
+    return list.get();
+  }
+
+  @Override
+  public DynamicMap<RestView<IndexResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/IndexInfo.java b/java/com/google/gerrit/server/restapi/config/IndexInfo.java
new file mode 100644
index 0000000..5d52fe1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/IndexInfo.java
@@ -0,0 +1,63 @@
+// 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.config;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.index.IndexDefinition;
+
+@AutoValue
+public abstract class IndexInfo {
+
+  public static IndexInfo fromIndexCollection(
+      String name, IndexCollection<?, ?, ?> indexCollection) {
+    ImmutableSortedMap.Builder<Integer, IndexVersionInfo> versions =
+        ImmutableSortedMap.naturalOrder();
+    int searchIndexVersion = indexCollection.getSearchIndex().getSchema().getVersion();
+    boolean searchIndexAdded = false;
+    for (Index<?, ?> index : indexCollection.getWriteIndexes()) {
+      boolean isSearchIndex = index.getSchema().getVersion() == searchIndexVersion;
+      versions.put(index.getSchema().getVersion(), IndexVersionInfo.create(true, isSearchIndex));
+      searchIndexAdded = searchIndexAdded || isSearchIndex;
+    }
+    if (!searchIndexAdded) {
+      versions.put(searchIndexVersion, IndexVersionInfo.create(false, true));
+    }
+
+    return new AutoValue_IndexInfo(name, versions.build());
+  }
+
+  public static IndexInfo fromIndexDefinition(IndexDefinition<?, ?, ?> def) {
+    return fromIndexCollection(def.getName(), def.getIndexCollection());
+  }
+
+  public abstract String getName();
+
+  public abstract ImmutableMap<Integer, IndexVersionInfo> getVersions();
+
+  @AutoValue
+  public abstract static class IndexVersionInfo {
+    static IndexVersionInfo create(boolean write, boolean search) {
+      return new AutoValue_IndexInfo_IndexVersionInfo(write, search);
+    }
+
+    abstract boolean isWrite();
+
+    abstract boolean isSearch();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/IndexVersionsCollection.java b/java/com/google/gerrit/server/restapi/config/IndexVersionsCollection.java
new file mode 100644
index 0000000..44c49f3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/IndexVersionsCollection.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.config.IndexResource;
+import com.google.gerrit.server.config.IndexVersionResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@RequiresCapability(MAINTAIN_SERVER)
+@Singleton
+public class IndexVersionsCollection
+    implements ChildCollection<IndexResource, IndexVersionResource> {
+
+  private final DynamicMap<RestView<IndexVersionResource>> views;
+  private final Provider<ListIndexVersions> list;
+
+  @Inject
+  IndexVersionsCollection(
+      DynamicMap<RestView<IndexVersionResource>> views, Provider<ListIndexVersions> list) {
+    this.views = views;
+    this.list = list;
+  }
+
+  @Override
+  public RestView<IndexResource> list() throws RestApiException {
+    return list.get();
+  }
+
+  @Override
+  public IndexVersionResource parse(IndexResource parent, IdString id)
+      throws ResourceNotFoundException, Exception {
+    try {
+      int version = Integer.parseInt(id.get());
+      IndexDefinition<?, ?, ?> def = parent.getIndexDefinition();
+      IndexCollection<?, ?, ?> indexCollection = def.getIndexCollection();
+      Index<?, ?> index = indexCollection.getWriteIndex(version);
+      if (index == null) {
+        Index<?, ?> searchIndex = indexCollection.getSearchIndex();
+        if (searchIndex.getSchema().getVersion() == version) {
+          index = searchIndex;
+        }
+      }
+      if (index != null) {
+        return new IndexVersionResource(def, index);
+      }
+    } catch (NumberFormatException e) {
+      throw new ResourceNotFoundException("'" + id.get() + "' is not a number", e);
+    }
+
+    throw new ResourceNotFoundException();
+  }
+
+  @Override
+  public DynamicMap<RestView<IndexVersionResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ListExperiments.java b/java/com/google/gerrit/server/restapi/config/ListExperiments.java
new file mode 100644
index 0000000..a41b917
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ListExperiments.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.common.ExperimentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.function.Function;
+import org.kohsuke.args4j.Option;
+
+/** List capabilities visible to the calling user. */
+public class ListExperiments implements RestReadView<ConfigResource> {
+  public static ImmutableList<String> getExperiments() {
+    return Arrays.stream(ExperimentFeaturesConstants.class.getDeclaredFields())
+        .filter(field -> field.getType().equals(String.class))
+        .map(
+            field -> {
+              try {
+                return (String) field.get(null);
+              } catch (IllegalAccessException e) {
+                return null;
+              }
+            })
+        .filter(Objects::nonNull)
+        .sorted()
+        .collect(toImmutableList());
+  }
+
+  private final PermissionBackend permissionBackend;
+  private final ExperimentFeatures experimentFeatures;
+  private final GetExperiment getExperiment;
+
+  private boolean enabledOnly;
+
+  @Option(name = "--enabled-only", usage = "only return enabled experiments")
+  public void setEnabledOnly(boolean enabledOnly) {
+    this.enabledOnly = enabledOnly;
+  }
+
+  @Inject
+  public ListExperiments(
+      PermissionBackend permissionBackend,
+      ExperimentFeatures experimentFeatures,
+      GetExperiment getExperiment) {
+    this.permissionBackend = permissionBackend;
+    this.experimentFeatures = experimentFeatures;
+    this.getExperiment = getExperiment;
+  }
+
+  @Override
+  public Response<ImmutableMap<String, ExperimentInfo>> apply(ConfigResource resource)
+      throws AuthException, PermissionBackendException {
+    permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+    return Response.ok(
+        getExperiments().stream()
+            .filter(
+                experimentName ->
+                    !enabledOnly || experimentFeatures.isFeatureEnabled(experimentName))
+            .collect(toImmutableMap(Function.identity(), getExperiment::getExperimentInfo)));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ListIndexVersions.java b/java/com/google/gerrit/server/restapi/config/ListIndexVersions.java
new file mode 100644
index 0000000..91bdae0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ListIndexVersions.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.IndexResource;
+import java.util.Map;
+
+@RequiresCapability(MAINTAIN_SERVER)
+public class ListIndexVersions implements RestReadView<IndexResource> {
+
+  @Override
+  public Response<Map<Integer, IndexInfo.IndexVersionInfo>> apply(IndexResource rsrc)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+    IndexInfo info = IndexInfo.fromIndexDefinition(rsrc.getIndexDefinition());
+    return Response.ok(info.getVersions());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ListIndexes.java b/java/com/google/gerrit/server/restapi/config/ListIndexes.java
new file mode 100644
index 0000000..2e6664b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ListIndexes.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.inject.Inject;
+import java.util.Collection;
+
+@RequiresCapability(MAINTAIN_SERVER)
+public class ListIndexes implements RestReadView<ConfigResource> {
+  private final Collection<IndexDefinition<?, ?, ?>> defs;
+
+  @Inject
+  public ListIndexes(Collection<IndexDefinition<?, ?, ?>> defs) {
+    this.defs = defs;
+  }
+
+  private ImmutableList<IndexInfo> getIndexInfos() {
+    ImmutableList.Builder<IndexInfo> indexInfos = ImmutableList.builder();
+    for (IndexDefinition<?, ?, ?> def : defs) {
+      indexInfos.add(IndexInfo.fromIndexDefinition(def));
+    }
+    return indexInfos.build();
+  }
+
+  @Override
+  public Response<Object> apply(ConfigResource rsrc) {
+    return Response.ok(getIndexInfos());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/PostCaches.java b/java/com/google/gerrit/server/restapi/config/PostCaches.java
index c9480c5..59dcc24 100644
--- a/java/com/google/gerrit/server/restapi/config/PostCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/PostCaches.java
@@ -103,7 +103,8 @@
       if (FlushCache.WEB_SESSIONS.equals(cacheResource.getName())) {
         continue;
       }
-      flushCache.apply(cacheResource, null);
+      @SuppressWarnings("unused")
+      var unused = flushCache.apply(cacheResource, null);
     }
   }
 
@@ -129,7 +130,8 @@
     }
 
     for (CacheResource rsrc : cacheResources) {
-      flushCache.apply(rsrc, null);
+      @SuppressWarnings("unused")
+      var unused = flushCache.apply(rsrc, null);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/ReindexIndexVersion.java b/java/com/google/gerrit/server/restapi/config/ReindexIndexVersion.java
new file mode 100644
index 0000000..21cd1c1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/ReindexIndexVersion.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.config.IndexVersionResource;
+import com.google.gerrit.server.index.IndexVersionReindexer;
+import com.google.gerrit.server.restapi.config.ReindexIndexVersion.Input;
+import com.google.inject.Inject;
+
+public class ReindexIndexVersion implements RestModifyView<IndexVersionResource, Input> {
+  public static class Input {
+    public boolean reuse;
+    public boolean notifyListeners;
+  }
+
+  private final IndexVersionReindexer indexVersionReindexer;
+
+  @Inject
+  ReindexIndexVersion(IndexVersionReindexer indexVersionReindexer) {
+    this.indexVersionReindexer = indexVersionReindexer;
+  }
+
+  @Override
+  public Response<?> apply(IndexVersionResource rsrc, Input input)
+      throws ResourceNotFoundException {
+    IndexDefinition<?, ?, ?> def = rsrc.getIndexDefinition();
+    int version = rsrc.getIndex().getSchema().getVersion();
+    @SuppressWarnings("unused")
+    var unused = indexVersionReindexer.reindex(def, version, input.reuse, input.notifyListeners);
+    return Response.accepted(
+        String.format("Index %s version %d submitted for reindexing", def.getName(), version));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ReloadConfig.java b/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
index c8f2ed6..9c773fe 100644
--- a/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
+++ b/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
@@ -16,7 +16,8 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.api.config.ConfigUpdateEntryInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
@@ -52,7 +53,7 @@
   public Response<Map<String, List<ConfigUpdateEntryInfo>>> apply(
       ConfigResource resource, Input input) throws RestApiException, PermissionBackendException {
     permissions.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
-    Multimap<UpdateResult, ConfigUpdateEntry> updates = config.reloadConfig();
+    ListMultimap<UpdateResult, ConfigUpdateEntry> updates = config.reloadConfig();
     if (updates.isEmpty()) {
       return Response.ok(Collections.emptyMap());
     }
@@ -64,7 +65,7 @@
                     e -> toEntryInfos(e.getValue()))));
   }
 
-  private static List<ConfigUpdateEntryInfo> toEntryInfos(
+  private static ImmutableList<ConfigUpdateEntryInfo> toEntryInfos(
       Collection<ConfigUpdateEntry> updateEntries) {
     return updateEntries.stream()
         .map(ReloadConfig::toConfigUpdateEntryInfo)
diff --git a/java/com/google/gerrit/server/restapi/config/SnapshotIndex.java b/java/com/google/gerrit/server/restapi/config/SnapshotIndex.java
new file mode 100644
index 0000000..c50367f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/SnapshotIndex.java
@@ -0,0 +1,62 @@
+// 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.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
+
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.config.IndexResource;
+import com.google.gerrit.server.restapi.config.SnapshotIndex.Input;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+@RequiresCapability(MAINTAIN_SERVER)
+@Singleton
+public class SnapshotIndex implements RestModifyView<IndexResource, Input> {
+  private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss");
+
+  @Override
+  public Response<?> apply(IndexResource rsrc, Input input) throws IOException {
+    String id = input.id;
+    if (id == null) {
+      id = LocalDateTime.now(ZoneId.systemDefault()).format(formatter);
+    }
+    IndexDefinition<?, ?, ?> def = rsrc.getIndexDefinition();
+    for (Index<?, ?> index : def.getIndexCollection().getWriteIndexes()) {
+      try {
+        @SuppressWarnings("unused")
+        var unused = index.snapshot(id);
+      } catch (FileAlreadyExistsException e) {
+        return Response.withStatusCode(SC_CONFLICT, "Snapshot with same ID already exists.");
+      }
+    }
+    SnapshotInfo info = new SnapshotInfo();
+    info.id = id;
+    return Response.ok(info);
+  }
+
+  public static class Input {
+    String id;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/SnapshotIndexVersion.java b/java/com/google/gerrit/server/restapi/config/SnapshotIndexVersion.java
new file mode 100644
index 0000000..9f66ab3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/SnapshotIndexVersion.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
+
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.server.config.IndexVersionResource;
+import com.google.gerrit.server.restapi.config.SnapshotIndexVersion.Input;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+@RequiresCapability(MAINTAIN_SERVER)
+@Singleton
+public class SnapshotIndexVersion implements RestModifyView<IndexVersionResource, Input> {
+  private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss");
+
+  @Override
+  public Response<?> apply(IndexVersionResource rsrc, Input input)
+      throws IOException, ResourceNotFoundException {
+    String id = input.id;
+    if (id == null) {
+      id = LocalDateTime.now(ZoneId.systemDefault()).format(formatter);
+    }
+    Index<?, ?> index = rsrc.getIndex();
+    var unused = index.snapshot(id);
+    SnapshotInfo info = new SnapshotInfo();
+    info.id = id;
+    return Response.ok(info);
+  }
+
+  public static class Input {
+    String id;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/SnapshotIndexes.java b/java/com/google/gerrit/server/restapi/config/SnapshotIndexes.java
new file mode 100644
index 0000000..6a2c5f8
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/SnapshotIndexes.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.config;
+
+import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.restapi.config.SnapshotIndexes.Input;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Collection;
+
+@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
+@Singleton
+public class SnapshotIndexes implements RestModifyView<ConfigResource, Input> {
+  private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss");
+
+  public static class Input {
+    String id;
+  }
+
+  private final Collection<IndexDefinition<?, ?, ?>> defs;
+
+  @Inject
+  SnapshotIndexes(Collection<IndexDefinition<?, ?, ?>> defs) {
+    this.defs = defs;
+  }
+
+  @Override
+  public Response<?> apply(ConfigResource resource, Input input) throws IOException {
+    String id = input.id;
+    if (id == null) {
+      id = LocalDateTime.now(ZoneId.systemDefault()).format(formatter);
+    }
+    for (IndexDefinition<?, ?, ?> def : defs) {
+      for (Index<?, ?> index : def.getIndexCollection().getWriteIndexes()) {
+        try {
+          @SuppressWarnings("unused")
+          var unused = index.snapshot(id);
+        } catch (FileAlreadyExistsException e) {
+          return Response.withStatusCode(SC_CONFLICT, "Snapshot with same ID already exists.");
+        }
+      }
+    }
+    SnapshotInfo info = new SnapshotInfo();
+    info.id = id;
+    return Response.ok(info);
+  }
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/restapi/config/SnapshotInfo.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/restapi/config/SnapshotInfo.java
index 0709b86..addc559 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/restapi/config/SnapshotInfo.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+package com.google.gerrit.server.restapi.config;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+public class SnapshotInfo {
+  public String id;
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/restapi/config/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/restapi/config/package-info.java
index 0709b86..ba06458 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/restapi/config/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.restapi.config;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index 4110eff..11b3788 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -17,6 +17,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
@@ -115,11 +116,13 @@
     this.sequences = sequences;
   }
 
+  @CanIgnoreReturnValue
   public CreateGroup addOption(ListGroupsOption o) {
     json.addOption(o);
     return this;
   }
 
+  @CanIgnoreReturnValue
   public CreateGroup addOptions(Collection<ListGroupsOption> o) {
     json.addOptions(o);
     return this;
diff --git a/java/com/google/gerrit/server/restapi/group/GroupJson.java b/java/com/google/gerrit/server/restapi/group/GroupJson.java
index 6d3fa01..4edbc95 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupJson.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupJson.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.base.Suppliers;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.client.ListGroupsOption;
@@ -65,11 +66,13 @@
     options = EnumSet.noneOf(ListGroupsOption.class);
   }
 
+  @CanIgnoreReturnValue
   public GroupJson addOption(ListGroupsOption o) {
     options.add(o);
     return this;
   }
 
+  @CanIgnoreReturnValue
   public GroupJson addOptions(Collection<ListGroupsOption> o) {
     options.addAll(o);
     return this;
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index 4d9a1e9..3b16129 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -20,6 +20,8 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -286,7 +288,8 @@
     if (limit > 0) {
       existingGroups = existingGroups.limit(limit);
     }
-    List<GroupDescription.Internal> relevantGroups = existingGroups.collect(toImmutableList());
+    ImmutableList<GroupDescription.Internal> relevantGroups =
+        existingGroups.collect(toImmutableList());
     List<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(relevantGroups.size());
     for (GroupDescription.Internal group : relevantGroups) {
       groupInfos.add(json.addOptions(options).format(group));
@@ -376,7 +379,7 @@
     if (limit > 0) {
       foundGroups = foundGroups.limit(limit);
     }
-    List<GroupDescription.Internal> ownedGroups = foundGroups.collect(toImmutableList());
+    ImmutableList<GroupDescription.Internal> ownedGroups = foundGroups.collect(toImmutableList());
     List<GroupInfo> groupInfos = new ArrayList<>(ownedGroups.size());
     for (GroupDescription.Internal group : ownedGroups) {
       groupInfos.add(json.addOptions(options).format(group));
@@ -384,7 +387,8 @@
     return groupInfos;
   }
 
-  private Set<GroupDescription.Internal> loadGroups(Collection<AccountGroup.UUID> groupUuids) {
+  private ImmutableSet<GroupDescription.Internal> loadGroups(
+      Collection<AccountGroup.UUID> groupUuids) {
     return groupCache.get(groupUuids).values().stream()
         .map(InternalGroupDescription::new)
         .collect(toImmutableSet());
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
index 1882fc5..c6da92f 100644
--- a/java/com/google/gerrit/server/restapi/group/ListMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
@@ -60,6 +61,7 @@
     this.accountLoaderFactory = accountLoaderFactory;
   }
 
+  @CanIgnoreReturnValue
   public ListMembers setRecursive(boolean recursive) {
     this.recursive = recursive;
     return this;
@@ -111,7 +113,7 @@
       GroupDescription.Internal group, GroupControl groupControl)
       throws PermissionBackendException {
     checkSameGroup(group, groupControl);
-    Set<Account.Id> directMembers = getDirectMemberIds(group, groupControl);
+    ImmutableSet<Account.Id> directMembers = getDirectMemberIds(group, groupControl);
     return toAccountInfos(directMembers);
   }
 
@@ -138,32 +140,32 @@
       GroupDescription.Internal group,
       GroupControl groupControl,
       HashSet<AccountGroup.UUID> seenGroups) {
-    Set<Account.Id> directMembers = getDirectMemberIds(group, groupControl);
+    ImmutableSet<Account.Id> directMembers = getDirectMemberIds(group, groupControl);
 
     if (!groupControl.canSeeGroup()) {
       return directMembers;
     }
 
-    Set<Account.Id> indirectMembers = getIndirectMemberIds(group, seenGroups);
+    ImmutableSet<Account.Id> indirectMembers = getIndirectMemberIds(group, seenGroups);
     return Sets.union(directMembers, indirectMembers);
   }
 
-  private static Set<Account.Id> getDirectMemberIds(
+  private static ImmutableSet<Account.Id> getDirectMemberIds(
       GroupDescription.Internal group, GroupControl groupControl) {
     return group.getMembers().stream().filter(groupControl::canSeeMember).collect(toImmutableSet());
   }
 
-  private Set<Account.Id> getIndirectMemberIds(
+  private ImmutableSet<Account.Id> getIndirectMemberIds(
       GroupDescription.Internal group, HashSet<AccountGroup.UUID> seenGroups) {
-    Set<Account.Id> indirectMembers = new HashSet<>();
-    Set<AccountGroup.UUID> subgroupMembersToLoad = new HashSet<>();
+    ImmutableSet.Builder<Account.Id> indirectMembers = ImmutableSet.builder();
+    ImmutableSet.Builder<AccountGroup.UUID> subgroupMembersToLoad = ImmutableSet.builder();
     for (AccountGroup.UUID subgroupUuid : group.getSubgroups()) {
       if (!seenGroups.contains(subgroupUuid)) {
         seenGroups.add(subgroupUuid);
         subgroupMembersToLoad.add(subgroupUuid);
       }
     }
-    groupCache.get(subgroupMembersToLoad).values().stream()
+    groupCache.get(subgroupMembersToLoad.build()).values().stream()
         .map(InternalGroupDescription::new)
         .forEach(
             subgroup -> {
@@ -171,7 +173,7 @@
               indirectMembers.addAll(getTransitiveMemberIds(subgroup, subgroupControl, seenGroups));
             });
 
-    return indirectMembers;
+    return indirectMembers.build();
   }
 
   private static void checkSameGroup(GroupDescription.Internal group, GroupControl groupControl) {
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
index fed2302..547ccd67 100644
--- a/java/com/google/gerrit/server/restapi/group/QueryGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.client.ListGroupsOption;
@@ -115,13 +116,11 @@
       queryProcessor.setStart(start);
     }
 
-    if (limit != 0) {
-      queryProcessor.setUserProvidedLimit(limit);
-    }
+    queryProcessor.setUserProvidedLimit(limit, /* applyDefaultLimit */ true);
 
     try {
       QueryResult<InternalGroup> result = queryProcessor.query(queryBuilder.parse(query));
-      List<InternalGroup> groups = result.entities();
+      ImmutableList<InternalGroup> groups = result.entities();
 
       ArrayList<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(groups.size());
       json.addOptions(options);
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/restapi/group/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/restapi/group/package-info.java
index 0709b86..5372db2 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/restapi/group/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.restapi.group;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/restapi/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/restapi/package-info.java
index 0709b86..28b6003 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/restapi/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.restapi;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 338ff0d..3e8002b 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -14,17 +14,11 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
-
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -33,89 +27,35 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.approval.ApprovalsUtil;
-import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
 public class CreateAccessChange implements RestModifyView<ProjectResource, ProjectAccessInput> {
-  private final PermissionBackend permissionBackend;
-  private final Sequences seq;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final BatchUpdate.Factory updateFactory;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final SetAccessUtil setAccess;
   private final ChangeJson.Factory jsonFactory;
-  private final ProjectCache projectCache;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   CreateAccessChange(
-      PermissionBackend permissionBackend,
-      ChangeInserter.Factory changeInserterFactory,
-      BatchUpdate.Factory updateFactory,
-      Sequences seq,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       SetAccessUtil accessUtil,
       ChangeJson.Factory jsonFactory,
-      ProjectCache projectCache,
-      ProjectConfig.Factory projectConfigFactory) {
-    this.permissionBackend = permissionBackend;
-    this.seq = seq;
-    this.changeInserterFactory = changeInserterFactory;
-    this.updateFactory = updateFactory;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.setAccess = accessUtil;
     this.jsonFactory = jsonFactory;
-    this.projectCache = projectCache;
-    this.projectConfigFactory = projectConfigFactory;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<ChangeInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
-      throws PermissionBackendException, AuthException, IOException, ConfigInvalidException,
-          InvalidNameException, UpdateException, RestApiException {
-    PermissionBackend.ForProject forProject =
-        permissionBackend.user(rsrc.getUser()).project(rsrc.getNameKey());
-    if (!check(forProject, ProjectPermission.READ_CONFIG)) {
-      throw new AuthException(RefNames.REFS_CONFIG + " not visible");
-    }
-    if (!check(forProject, ProjectPermission.WRITE_CONFIG)) {
-      try {
-        forProject.ref(RefNames.REFS_CONFIG).check(RefPermission.CREATE_CHANGE);
-      } catch (AuthException denied) {
-        throw new AuthException("cannot create change for " + RefNames.REFS_CONFIG, denied);
-      }
-    }
-    projectCache
-        .get(rsrc.getNameKey())
-        .orElseThrow(illegalState(rsrc.getNameKey()))
-        .checkStatePermitsWrite();
-
-    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
+      throws PermissionBackendException, IOException, ConfigInvalidException, InvalidNameException,
+          UpdateException, RestApiException {
     ImmutableList<AccessSection> removals =
         setAccess.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
     ImmutableList<AccessSection> additions =
@@ -123,81 +63,30 @@
 
     Project.NameKey newParentProjectName =
         input.parent == null ? null : Project.nameKey(input.parent);
-
-    try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
-      ObjectId oldCommit = config.getRevision();
-      String oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
-
-      setAccess.validateChanges(config, removals, additions);
-      setAccess.applyChanges(config, removals, additions);
-      try {
-        setAccess.setParentName(
-            rsrc.getUser().asIdentifiedUser(),
-            config,
-            rsrc.getNameKey(),
-            newParentProjectName,
-            false);
-      } catch (AuthException e) {
-        throw new IllegalStateException(e);
-      }
-
-      if (!Strings.isNullOrEmpty(input.message)) {
-        if (!input.message.endsWith("\n")) {
-          input.message += "\n";
-        }
-        md.setMessage(input.message);
-      } else {
-        md.setMessage("Review access change\n");
-      }
-
-      md.setInsertChangeId(true);
-      Change.Id changeId = Change.id(seq.nextChangeId());
-      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-        RevCommit commit =
-            config.commitToNewRef(
-                md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
-
-        if (commit.name().equals(oldCommitSha1)) {
-          throw new BadRequestException("no change");
-        }
-
-        try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
-            ObjectReader objReader = objInserter.newReader();
-            RevWalk rw = new RevWalk(objReader);
-            BatchUpdate bu =
-                updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.now())) {
-          bu.setRepository(md.getRepository(), rw, objInserter);
-          ChangeInserter ins = newInserter(changeId, commit);
-          bu.insertChange(ins);
-          bu.execute();
-          return Response.created(jsonFactory.noOptions().format(ins.getChange()));
-        }
-      }
+    String message = !Strings.isNullOrEmpty(input.message) ? input.message : "Review access change";
+    try {
+      Change change =
+          repoMetaDataUpdater.updateAndCreateChangeForReview(
+              rsrc.getNameKey(),
+              rsrc.getUser(),
+              message,
+              config -> {
+                setAccess.validateChanges(config, removals, additions);
+                setAccess.applyChanges(config, removals, additions);
+                try {
+                  setAccess.setParentName(
+                      rsrc.getUser().asIdentifiedUser(),
+                      config,
+                      rsrc.getNameKey(),
+                      newParentProjectName,
+                      false);
+                } catch (AuthException e) {
+                  throw new IllegalStateException(e);
+                }
+              });
+      return Response.created(jsonFactory.noOptions().format(change));
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
     }
   }
-
-  // ProjectConfig doesn't currently support fusing into a BatchUpdate.
-  @SuppressWarnings("deprecation")
-  private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
-    return changeInserterFactory
-        .create(changeId, commit, RefNames.REFS_CONFIG)
-        .setMessage(
-            // Same message as in ReceiveCommits.CreateRequest.
-            ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
-        .setValidate(false)
-        .setUpdateRef(false);
-  }
-
-  private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
-      throws PermissionBackendException {
-    try {
-      perm.check(p);
-      return true;
-    } catch (AuthException denied) {
-      return false;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index ad79a14..a233834 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -176,7 +176,8 @@
 
     if (input.copyCondition != null) {
       try {
-        approvalQueryBuilder.parse(input.copyCondition);
+        @SuppressWarnings("unused")
+        var unused = approvalQueryBuilder.parse(input.copyCondition);
       } catch (QueryParseException e) {
         throw new BadRequestException(
             "unable to parse copy condition. got: " + input.copyCondition + ". " + e.getMessage(),
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 04819d8..8be96b5 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -186,7 +186,9 @@
         ConfigInput in = new ConfigInput();
         in.pluginConfigValues = input.pluginConfigValues;
         in.description = args.projectDescription;
-        putConfig.get().apply(projectState, in);
+
+        @SuppressWarnings("unused")
+        var unused = putConfig.get().apply(projectState, in);
       }
       return Response.created(json.format(projectState));
     } finally {
diff --git a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
index 2aeba89..a46211c 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
@@ -41,7 +42,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -136,7 +136,7 @@
             .setAllowOverrideInChildProjects(input.allowOverrideInChildProjects)
             .build();
 
-    List<String> validationMessages =
+    ImmutableList<String> validationMessages =
         submitRequirementExpressionsValidator.validateExpressions(submitRequirement);
     if (!validationMessages.isEmpty()) {
       throw new BadRequestException(
diff --git a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
index 7457eb7..b9dcc5c 100644
--- a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
+++ b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
@@ -85,7 +85,8 @@
         new Runnable() {
           @Override
           public void run() {
-            runGC(project, input, null);
+            @SuppressWarnings("unused")
+            var unused = runGC(project, input, null);
           }
 
           @Override
diff --git a/java/com/google/gerrit/server/restapi/project/GetHead.java b/java/com/google/gerrit/server/restapi/project/GetHead.java
index 4e0a144..be84e6b 100644
--- a/java/com/google/gerrit/server/restapi/project/GetHead.java
+++ b/java/com/google/gerrit/server/restapi/project/GetHead.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -54,8 +55,9 @@
 
   @Override
   public Response<String> apply(ProjectResource rsrc)
-      throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
-    rsrc.getProjectState().statePermitsRead();
+      throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException,
+          ResourceConflictException {
+    rsrc.getProjectState().checkStatePermitsRead();
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       Ref head = repo.getRefDatabase().exactRef(Constants.HEAD);
       if (head == null) {
diff --git a/java/com/google/gerrit/server/restapi/project/IndexChanges.java b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
index 6ad0005..532bd24 100644
--- a/java/com/google/gerrit/server/restapi/project/IndexChanges.java
+++ b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.util.io.NullOutputStream;
@@ -42,18 +41,18 @@
 public class IndexChanges implements RestModifyView<ProjectResource, Input> {
 
   private final MultiProgressMonitor.Factory multiProgressMonitorFactory;
-  private final Provider<AllChangesIndexer> allChangesIndexerProvider;
+  private final AllChangesIndexer.Factory allChangesIndexerFactory;
   private final ChangeIndexer indexer;
   private final ListeningExecutorService executor;
 
   @Inject
   IndexChanges(
       MultiProgressMonitor.Factory multiProgressMonitorFactory,
-      Provider<AllChangesIndexer> allChangesIndexerProvider,
+      AllChangesIndexer.Factory allChangesIndexerFactory,
       ChangeIndexer indexer,
       @IndexExecutor(BATCH) ListeningExecutorService executor) {
     this.multiProgressMonitorFactory = multiProgressMonitorFactory;
-    this.allChangesIndexerProvider = allChangesIndexerProvider;
+    this.allChangesIndexerFactory = allChangesIndexerFactory;
     this.indexer = indexer;
     this.executor = executor;
   }
@@ -65,7 +64,7 @@
         multiProgressMonitorFactory
             .create(ByteStreams.nullOutputStream(), TaskKind.INDEXING, "Reindexing project")
             .beginSubTask("", MultiProgressMonitor.UNKNOWN);
-    AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
+    AllChangesIndexer allChangesIndexer = allChangesIndexerFactory.create();
     allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
     // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
     // return value.
diff --git a/java/com/google/gerrit/server/restapi/project/ListBranches.java b/java/com/google/gerrit/server/restapi/project/ListBranches.java
index 3cb412a..1c2ce5a 100644
--- a/java/com/google/gerrit/server/restapi/project/ListBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/ListBranches.java
@@ -56,7 +56,6 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Base64;
-import java.util.Collection;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Optional;
@@ -232,7 +231,7 @@
       throws IOException, ResourceNotFoundException {
     List<Ref> refs;
     try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
-      Collection<Ref> heads = db.getRefDatabase().getRefsByPrefix(Constants.R_HEADS);
+      List<Ref> heads = db.getRefDatabase().getRefsByPrefix(Constants.R_HEADS);
       refs = new ArrayList<>(heads.size() + 3);
       refs.addAll(heads);
       refs.addAll(
diff --git a/java/com/google/gerrit/server/restapi/project/ListDashboards.java b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
index 8cedd60..59a33a2 100644
--- a/java/com/google/gerrit/server/restapi/project/ListDashboards.java
+++ b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
@@ -74,7 +74,7 @@
     List<List<DashboardInfo>> all = new ArrayList<>();
     boolean setDefault = true;
     for (ProjectState ps : tree(rsrc)) {
-      List<DashboardInfo> list = scan(ps, project, setDefault);
+      ImmutableList<DashboardInfo> list = scan(ps, project, setDefault);
       for (DashboardInfo d : list) {
         if (d.isDefault != null && Boolean.TRUE.equals(d.isDefault)) {
           setDefault = false;
diff --git a/java/com/google/gerrit/server/restapi/project/ListLabels.java b/java/com/google/gerrit/server/restapi/project/ListLabels.java
index 56ee4cd..683c107 100644
--- a/java/com/google/gerrit/server/restapi/project/ListLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/ListLabels.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.common.collect.ImmutableCollection;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -29,7 +30,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Comparator;
 import java.util.List;
 import org.kohsuke.args4j.Option;
@@ -80,7 +80,8 @@
   }
 
   private List<LabelDefinitionInfo> listLabels(ProjectState projectState) {
-    Collection<LabelType> labelTypes = projectState.getConfig().getLabelSections().values();
+    ImmutableCollection<LabelType> labelTypes =
+        projectState.getConfig().getLabelSections().values();
     List<LabelDefinitionInfo> labels = new ArrayList<>(labelTypes.size());
     for (LabelType labelType : labelTypes) {
       labels.add(LabelDefinitionJson.format(projectState.getNameKey(), labelType));
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index 83d29de..24bbb24 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.restapi.project;
 
 import static com.google.gerrit.entities.RefNames.isConfigRef;
-import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -42,6 +42,7 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Constants;
@@ -58,6 +59,7 @@
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final WebLinks links;
+  private final TagSorter tagSorter;
 
   @Option(
       name = "--limit",
@@ -78,6 +80,14 @@
   }
 
   @Option(
+      name = "--descending",
+      aliases = {"-d"},
+      usage = "return the tags in descending order")
+  public void setDescendingOrder(boolean descendingOrder) {
+    this.descendingOrder = descendingOrder;
+  }
+
+  @Option(
       name = "--match",
       aliases = {"-m"},
       metaVar = "MATCH",
@@ -95,24 +105,40 @@
     this.matchRegex = matchRegex;
   }
 
+  @Option(
+      name = "--sort-by",
+      aliases = {"-sortby"},
+      usage = "sort the tags")
+  private void setSortBy(ListTagSortOption sortBy) {
+    this.sortBy = sortBy;
+  }
+
   private int limit;
   private int start;
+  private boolean descendingOrder;
   private String matchSubstring;
   private String matchRegex;
+  private ListTagSortOption sortBy = ListTagSortOption.REF;
 
   @Inject
   public ListTags(
-      GitRepositoryManager repoManager, PermissionBackend permissionBackend, WebLinks webLinks) {
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      WebLinks webLinks,
+      TagSorter tagSorter) {
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.links = webLinks;
+    this.tagSorter = tagSorter;
   }
 
   public ListTags request(ListRefsRequest<TagInfo> request) {
     this.setLimit(request.getLimit());
     this.setStart(request.getStart());
+    this.setDescendingOrder(request.getDescendingOrder());
     this.setMatchSubstring(request.getSubstring());
     this.setMatchRegex(request.getRegex());
+    this.setSortBy(request.getSortBy());
     return this;
   }
 
@@ -136,7 +162,10 @@
       }
     }
 
-    tags.sort(comparing(t -> t.ref));
+    tagSorter.sort(sortBy, tags, descendingOrder);
+    if (descendingOrder) {
+      Collections.reverse(tags);
+    }
 
     return Response.ok(
         new RefFilter<>(Constants.R_TAGS, (TagInfo tag) -> tag.ref)
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
index 6cb912e..3616f4b 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -112,7 +112,8 @@
           if (labelInput.commitMessage != null) {
             throw new BadRequestException("commit message on label definition input not supported");
           }
-          createLabel.createLabel(config, labelInput.name.trim(), labelInput);
+          @SuppressWarnings("unused")
+          var unused = createLabel.createLabel(config, labelInput.name.trim(), labelInput);
         }
         dirty = true;
       }
@@ -126,9 +127,11 @@
           if (e.getValue().commitMessage != null) {
             throw new BadRequestException("commit message on label definition input not supported");
           }
-          setLabel.updateLabel(config, labelType, e.getValue());
+
+          if (setLabel.updateLabel(config, labelType, e.getValue())) {
+            dirty = true;
+          }
         }
-        dirty = true;
       }
 
       if (input.commitMessage != null) {
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index d4b30c2..d5f61ce 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -221,7 +221,16 @@
             oldValue = Joiner.on("\n").join(l);
             value = Joiner.on("\n").join(v.getValue().values);
           }
-          if (Strings.emptyToNull(value) != null) {
+
+          String defaultValue = projectConfigEntry.getDefaultValue();
+          if (defaultValue != null && defaultValue.equals(value)) {
+            // If the value is equal to the default, unset in case it existed.
+            if (oldValue != null) {
+              validateProjectConfigEntryIsEditable(
+                  projectConfigEntry, projectState, v.getKey(), pluginName);
+              projectConfig.updatePluginConfig(pluginName, cfg -> cfg.unset(v.getKey()));
+            }
+          } else if (Strings.emptyToNull(value) != null) {
             if (!value.equals(oldValue)) {
               validateProjectConfigEntryIsEditable(
                   projectConfigEntry, projectState, v.getKey(), pluginName);
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index dc7499d..2ead807 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -115,15 +116,13 @@
       queryProcessor.setStart(start);
     }
 
-    if (limit != 0) {
-      queryProcessor.setUserProvidedLimit(limit);
-    }
+    queryProcessor.setUserProvidedLimit(limit, /* applyDefaultLimit */ true);
 
     try {
       QueryResult<ProjectData> result =
           queryProcessor.query(
               !Strings.isNullOrEmpty(query) ? queryBuilder.parse(query) : Predicate.any());
-      List<ProjectData> pds = result.entities();
+      ImmutableList<ProjectData> pds = result.entities();
 
       ArrayList<ProjectInfo> projectInfos = Lists.newArrayListWithCapacity(pds.size());
       for (ProjectData pd : pds) {
diff --git a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
new file mode 100644
index 0000000..c45a009
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
@@ -0,0 +1,215 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.InvalidNameException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.CreateGroupPermissionSyncer;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.MetaDataUpdate.User;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.io.IOException;
+import javax.inject.Inject;
+import javax.inject.Provider;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Updates repo refs/meta/config content. */
+@Singleton
+public class RepoMetaDataUpdater {
+  private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
+  private final Provider<User> metaDataUpdateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final Sequences seq;
+
+  private final BatchUpdate.Factory updateFactory;
+
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  RepoMetaDataUpdater(
+      CreateGroupPermissionSyncer createGroupPermissionSyncer,
+      Provider<User> metaDataUpdateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache,
+      ChangeInserter.Factory changeInserterFactory,
+      Sequences seq,
+      BatchUpdate.Factory updateFactory,
+      PermissionBackend permissionBackend) {
+    this.createGroupPermissionSyncer = createGroupPermissionSyncer;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+    this.changeInserterFactory = changeInserterFactory;
+    this.seq = seq;
+    this.updateFactory = updateFactory;
+    this.permissionBackend = permissionBackend;
+  }
+
+  public Change updateAndCreateChangeForReview(
+      Project.NameKey projectName,
+      CurrentUser user,
+      String message,
+      ProjectConfigUpdater projectConfigUpdater)
+      throws ConfigInvalidException, IOException, RestApiException, UpdateException,
+          InvalidNameException, PermissionBackendException {
+    checkArgument(!message.isBlank(), "The message must not be empty");
+    message = validateMessage(message);
+
+    PermissionBackend.ForProject forProject = permissionBackend.user(user).project(projectName);
+    if (!check(forProject, ProjectPermission.READ_CONFIG)) {
+      throw new AuthException(RefNames.REFS_CONFIG + " not visible");
+    }
+    if (!check(forProject, ProjectPermission.WRITE_CONFIG)) {
+      try {
+        forProject.ref(RefNames.REFS_CONFIG).check(RefPermission.CREATE_CHANGE);
+      } catch (AuthException denied) {
+        throw new AuthException("cannot create change for " + RefNames.REFS_CONFIG, denied);
+      }
+    }
+    projectCache.get(projectName).orElseThrow(illegalState(projectName)).checkStatePermitsWrite();
+
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
+      ProjectConfig config = projectConfigFactory.read(md);
+      ObjectId oldCommit = config.getRevision();
+      String oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
+
+      projectConfigUpdater.update(config);
+      md.setMessage(message);
+      md.setInsertChangeId(true);
+
+      Change.Id changeId = Change.id(seq.nextChangeId());
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        RevCommit commit =
+            config.commitToNewRef(
+                md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
+
+        if (commit.name().equals(oldCommitSha1)) {
+          throw new BadRequestException("no change");
+        }
+
+        try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
+            ObjectReader objReader = objInserter.newReader();
+            RevWalk rw = new RevWalk(objReader);
+            BatchUpdate bu = updateFactory.create(projectName, user, TimeUtil.now())) {
+          bu.setRepository(md.getRepository(), rw, objInserter);
+          ChangeInserter ins = newInserter(changeId, commit);
+          bu.insertChange(ins);
+          bu.execute();
+          return ins.getChange();
+        }
+      }
+    }
+  }
+
+  public void updateWithoutReview(
+      Project.NameKey projectName, String message, ProjectConfigUpdater projectConfigUpdater)
+      throws ConfigInvalidException, IOException, PermissionBackendException, AuthException,
+          ResourceConflictException, InvalidNameException, BadRequestException {
+    updateWithoutReview(
+        projectName, message, /*skipPermissionsCheck=*/ false, projectConfigUpdater);
+  }
+
+  public void updateWithoutReview(
+      Project.NameKey projectName,
+      String message,
+      boolean skipPermissionsCheck,
+      ProjectConfigUpdater projectConfigUpdater)
+      throws ConfigInvalidException, IOException, PermissionBackendException, AuthException,
+          ResourceConflictException, InvalidNameException, BadRequestException {
+    message = validateMessage(message);
+    if (!skipPermissionsCheck) {
+      permissionBackend.currentUser().project(projectName).check(ProjectPermission.WRITE_CONFIG);
+    }
+
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      projectConfigUpdater.update(config);
+      md.setMessage(message);
+      config.commit(md);
+      projectCache.evictAndReindex(config.getProject());
+      createGroupPermissionSyncer.syncIfNeeded();
+    }
+  }
+
+  private String validateMessage(String message) {
+    if (!message.endsWith("\n")) {
+      return message + "\n";
+    }
+    return message;
+  }
+
+  // ProjectConfig doesn't currently support fusing into a BatchUpdate.
+  @SuppressWarnings("deprecation")
+  private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
+    return changeInserterFactory
+        .create(changeId, commit, RefNames.REFS_CONFIG)
+        .setMessage(
+            // Same message as in ReceiveCommits.CreateRequest.
+            ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
+        .setValidate(false)
+        .setUpdateRef(false);
+  }
+
+  private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
+      throws PermissionBackendException {
+    try {
+      perm.check(p);
+      return true;
+    } catch (AuthException denied) {
+      return false;
+    }
+  }
+
+  @FunctionalInterface
+  public interface ProjectConfigUpdater {
+    void update(ProjectConfig config)
+        throws BadRequestException, InvalidNameException, PermissionBackendException,
+            ResourceConflictException, AuthException;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 6957275..75fe280 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Project;
@@ -28,20 +29,15 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -49,92 +45,72 @@
 public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessInput> {
   protected final GroupBackend groupBackend;
   private final PermissionBackend permissionBackend;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final GetAccess getAccess;
-  private final ProjectCache projectCache;
   private final Provider<IdentifiedUser> identifiedUser;
   private final SetAccessUtil accessUtil;
-  private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   private SetAccess(
       GroupBackend groupBackend,
       PermissionBackend permissionBackend,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      ProjectCache projectCache,
       GetAccess getAccess,
       Provider<IdentifiedUser> identifiedUser,
       SetAccessUtil accessUtil,
-      CreateGroupPermissionSyncer createGroupPermissionSyncer,
-      ProjectConfig.Factory projectConfigFactory) {
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.groupBackend = groupBackend;
     this.permissionBackend = permissionBackend;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.getAccess = getAccess;
-    this.projectCache = projectCache;
     this.identifiedUser = identifiedUser;
     this.accessUtil = accessUtil;
-    this.createGroupPermissionSyncer = createGroupPermissionSyncer;
-    this.projectConfigFactory = projectConfigFactory;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<ProjectAccessInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
       throws Exception {
-    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
-
     validateInput(input);
 
-    ProjectConfig config;
-
-    List<AccessSection> removals =
+    ImmutableList<AccessSection> removals =
         accessUtil.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
-    List<AccessSection> additions =
+    ImmutableList<AccessSection> additions =
         accessUtil.getAccessSections(input.add, /* rejectNonResolvableGroups= */ true);
-    try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
-      config = projectConfigFactory.read(md);
-
-      // Check that the user has the right permissions.
-      boolean checkedAdmin = false;
-      for (AccessSection section : Iterables.concat(additions, removals)) {
-        boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
-        if (isGlobalCapabilities) {
-          if (!checkedAdmin) {
-            permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
-            checkedAdmin = true;
-          }
-        } else {
-          permissionBackend
-              .currentUser()
-              .project(rsrc.getNameKey())
-              .ref(section.getName())
-              .check(RefPermission.WRITE_CONFIG);
-        }
-      }
-
-      accessUtil.validateChanges(config, removals, additions);
-      accessUtil.applyChanges(config, removals, additions);
-
-      accessUtil.setParentName(
-          identifiedUser.get(),
-          config,
+    String message = !Strings.isNullOrEmpty(input.message) ? input.message : "Modify access rules";
+    try {
+      this.repoMetaDataUpdater.updateWithoutReview(
           rsrc.getNameKey(),
-          input.parent == null ? null : Project.nameKey(input.parent),
-          !checkedAdmin);
+          message,
+          /*skipPermissionsCheck=*/ true,
+          config -> {
+            // Check that the user has the right permissions.
+            boolean checkedAdmin = false;
+            for (AccessSection section : Iterables.concat(additions, removals)) {
+              boolean isGlobalCapabilities =
+                  AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+              if (isGlobalCapabilities) {
+                if (!checkedAdmin) {
+                  permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+                  checkedAdmin = true;
+                }
+              } else {
+                permissionBackend
+                    .currentUser()
+                    .project(rsrc.getNameKey())
+                    .ref(section.getName())
+                    .check(RefPermission.WRITE_CONFIG);
+              }
+            }
 
-      if (!Strings.isNullOrEmpty(input.message)) {
-        if (!input.message.endsWith("\n")) {
-          input.message += "\n";
-        }
-        md.setMessage(input.message);
-      } else {
-        md.setMessage("Modify access rules\n");
-      }
+            accessUtil.validateChanges(config, removals, additions);
+            accessUtil.applyChanges(config, removals, additions);
 
-      config.commit(md);
-      projectCache.evictAndReindex(config.getProject());
-      createGroupPermissionSyncer.syncIfNeeded();
+            accessUtil.setParentName(
+                identifiedUser.get(),
+                config,
+                rsrc.getNameKey(),
+                input.parent == null ? null : Project.nameKey(input.parent),
+                !checkedAdmin);
+          });
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
     } catch (ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 547a214..b685f08 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
@@ -45,7 +46,6 @@
 import com.google.inject.Singleton;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 
 @Singleton
 public class SetAccessUtil {
@@ -266,7 +266,7 @@
       }
       return true;
     }
-    Set<String> pluginPermissions =
+    ImmutableSet<String> pluginPermissions =
         pluginPermissionsUtil.collectPluginProjectPermissions().keySet();
     return pluginPermissions.contains(name);
   }
@@ -275,7 +275,8 @@
     if (GlobalCapability.isGlobalCapability(name)) {
       return true;
     }
-    Set<String> pluginCapabilities = pluginPermissionsUtil.collectPluginCapabilities().keySet();
+    ImmutableSet<String> pluginCapabilities =
+        pluginPermissionsUtil.collectPluginCapabilities().keySet();
     return pluginCapabilities.contains(name);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index 6c4976d..edd165d 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -190,7 +190,8 @@
     input.copyCondition = Strings.emptyToNull(input.copyCondition);
     if (input.copyCondition != null) {
       try {
-        approvalQueryBuilder.parse(input.copyCondition);
+        @SuppressWarnings("unused")
+        var unused = approvalQueryBuilder.parse(input.copyCondition);
       } catch (QueryParseException e) {
         throw new BadRequestException(
             "unable to parse copy condition. got: " + input.copyCondition + ". " + e.getMessage(),
diff --git a/java/com/google/gerrit/server/restapi/project/TagSorter.java b/java/com/google/gerrit/server/restapi/project/TagSorter.java
new file mode 100644
index 0000000..4776ce1
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/TagSorter.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static java.util.Comparator.comparing;
+
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.ListTagSortOption;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.util.Comparator;
+import java.util.List;
+
+public class TagSorter {
+  @Inject
+  public TagSorter() {}
+
+  /** Sort the tags by the given sort option, in place */
+  public void sort(ListTagSortOption sortBy, List<TagInfo> tags, boolean descendingOrder) {
+    switch (sortBy) {
+      case CREATION_TIME:
+        Comparator<Timestamp> nullsComparator =
+            descendingOrder
+                ? Comparator.nullsFirst(Comparator.naturalOrder())
+                : Comparator.nullsLast(Comparator.naturalOrder());
+        tags.sort(comparing(t -> t.created, nullsComparator));
+        break;
+      case REF:
+      default:
+        tags.sort(comparing(t -> t.ref));
+        break;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
index bbd617c..3e1104e 100644
--- a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
@@ -38,7 +39,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -131,7 +131,7 @@
             .setAllowOverrideInChildProjects(input.allowOverrideInChildProjects)
             .build();
 
-    List<String> validationMessages =
+    ImmutableList<String> validationMessages =
         submitRequirementExpressionsValidator.validateExpressions(submitRequirement);
     if (!validationMessages.isEmpty()) {
       throw new BadRequestException(
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/restapi/project/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/restapi/project/package-info.java
index 0709b86..7def13d 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.restapi.project;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 8cd0a58..1efb8a6 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -26,7 +27,6 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 import java.util.Optional;
 
@@ -67,7 +67,7 @@
           t.getName(),
           cd.getId());
 
-      Collection<PatchSetApproval> approvalsForLabel = getApprovalsForLabel(approvals, t);
+      ImmutableList<PatchSetApproval> approvalsForLabel = getApprovalsForLabel(approvals, t);
       SubmitRecord.Label label = labelFunction.check(t, approvalsForLabel);
       submitRecord.labels.add(label);
 
@@ -87,7 +87,7 @@
     return Optional.of(submitRecord);
   }
 
-  private static List<PatchSetApproval> getApprovalsForLabel(
+  private static ImmutableList<PatchSetApproval> getApprovalsForLabel(
       List<PatchSetApproval> approvals, LabelType t) {
     return approvals.stream()
         .filter(input -> input.label().equals(t.getLabelId().get()))
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
index 216405d..c915e6e 100644
--- a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
@@ -75,7 +76,7 @@
         continue;
       }
 
-      Collection<PatchSetApproval> allApprovalsForLabel = filterApprovalsByLabel(approvals, t);
+      ImmutableList<PatchSetApproval> allApprovalsForLabel = filterApprovalsByLabel(approvals, t);
       SubmitRecord.Label allApprovalsCheckResult = labelFunction.check(t, allApprovalsForLabel);
       SubmitRecord.Label ignoreSelfApprovalCheckResult =
           labelFunction.check(t, filterOutPositiveApprovalsOfUser(allApprovalsForLabel, uploader));
@@ -119,7 +120,7 @@
   }
 
   @VisibleForTesting
-  static Collection<PatchSetApproval> filterOutPositiveApprovalsOfUser(
+  static ImmutableList<PatchSetApproval> filterOutPositiveApprovalsOfUser(
       Collection<PatchSetApproval> approvals, Account.Id user) {
     return approvals.stream()
         .filter(input -> input.value() < 0 || !input.accountId().equals(user))
@@ -127,7 +128,7 @@
   }
 
   @VisibleForTesting
-  static Collection<PatchSetApproval> filterApprovalsByLabel(
+  static ImmutableList<PatchSetApproval> filterApprovalsByLabel(
       Collection<PatchSetApproval> approvals, LabelType t) {
     return approvals.stream()
         .filter(input -> input.labelId().get().equals(t.getLabelId().get()))
diff --git a/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java b/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java
index a94fb6e..a568371 100644
--- a/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java
+++ b/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java
@@ -20,22 +20,29 @@
 
 /** Provides prolog-related operations to different callers. */
 public interface PrologSubmitRuleUtil {
+  /** Returns true if prolog rules are enabled for the project. */
+  boolean isProjectRulesEnabled();
 
   /**
    * Returns the submit-type of a change depending on the change data and the definition of the
    * prolog rules file.
+   *
+   * <p>Must only be called when Prolog rules are enabled on the Gerrit server.
    */
   SubmitTypeRecord getSubmitType(ChangeData cd);
 
   /**
    * Returns the submit-type of a change depending on the change data and the definition of the
    * prolog rules file.
+   *
+   * <p>Must only be called when Prolog rules are enabled on the Gerrit server.
    */
   SubmitTypeRecord getSubmitType(ChangeData cd, String ruleToTest, boolean skipFilters);
 
-  /** Evaluates a submit rule. */
+  /**
+   * Evaluates a submit rule.
+   *
+   * <p>Must only be called when Prolog rules are enabled on the Gerrit server.
+   */
   SubmitRecord evaluate(ChangeData cd, String ruleToTest, boolean skipFilters);
-
-  /** Returns true if prolog rules are enabled for the project. */
-  boolean isProjectRulesEnabled();
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/rules/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/rules/package-info.java
index 0709b86..bee88f9 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/rules/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.rules;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/rules/prolog/BUILD b/java/com/google/gerrit/server/rules/prolog/BUILD
index 5e38d06..620a023 100644
--- a/java/com/google/gerrit/server/rules/prolog/BUILD
+++ b/java/com/google/gerrit/server/rules/prolog/BUILD
@@ -15,6 +15,7 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/server/rules/prolog/PredicateClassLoader.java b/java/com/google/gerrit/server/rules/prolog/PredicateClassLoader.java
index 67b8a60..28ec78f 100644
--- a/java/com/google/gerrit/server/rules/prolog/PredicateClassLoader.java
+++ b/java/com/google/gerrit/server/rules/prolog/PredicateClassLoader.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.SetMultimap;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
-import java.util.Collection;
+import java.util.Set;
 
 /** Loads the classes for Prolog predicates. */
 class PredicateClassLoader extends ClassLoader {
@@ -38,8 +38,7 @@
 
   @Override
   protected Class<?> findClass(String className) throws ClassNotFoundException {
-    final Collection<ClassLoader> classLoaders =
-        packageClassLoaderMap.get(getPackageName(className));
+    final Set<ClassLoader> classLoaders = packageClassLoaderMap.get(getPackageName(className));
     for (ClassLoader cl : classLoaders) {
       try {
         return Class.forName(className, true, cl);
diff --git a/java/com/google/gerrit/server/rules/prolog/PrologRule.java b/java/com/google/gerrit/server/rules/prolog/PrologRule.java
index 13814bb..c560fd2 100644
--- a/java/com/google/gerrit/server/rules/prolog/PrologRule.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologRule.java
@@ -30,15 +30,22 @@
 class PrologRule implements SubmitRule {
   private final PrologRuleEvaluator.Factory factory;
   private final ProjectCache projectCache;
+  private final boolean isProjectRulesEnabled;
 
   @Inject
-  private PrologRule(PrologRuleEvaluator.Factory factory, ProjectCache projectCache) {
+  private PrologRule(
+      PrologRuleEvaluator.Factory factory, ProjectCache projectCache, RulesCache rulesCache) {
     this.factory = factory;
     this.projectCache = projectCache;
+    this.isProjectRulesEnabled = rulesCache.isProjectRulesEnabled();
   }
 
   @Override
   public Optional<SubmitRecord> evaluate(ChangeData cd) {
+    if (!isProjectRulesEnabled) {
+      return Optional.empty();
+    }
+
     ProjectState projectState =
         projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
     // We only want to run the Prolog engine if we have at least one rules.pl file to use.
@@ -49,15 +56,11 @@
     return Optional.of(evaluate(cd, PrologOptions.defaultOptions()));
   }
 
-  public SubmitRecord evaluate(ChangeData cd, PrologOptions opts) {
+  SubmitRecord evaluate(ChangeData cd, PrologOptions opts) {
     return getEvaluator(cd, opts).evaluate();
   }
 
-  public SubmitTypeRecord getSubmitType(ChangeData cd) {
-    return getSubmitType(cd, PrologOptions.defaultOptions());
-  }
-
-  public SubmitTypeRecord getSubmitType(ChangeData cd, PrologOptions opts) {
+  SubmitTypeRecord getSubmitType(ChangeData cd, PrologOptions opts) {
     return getEvaluator(cd, opts).getSubmitType();
   }
 
diff --git a/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java b/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
index 3d017e2..6be71f8 100644
--- a/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.rules.prolog;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -25,7 +27,6 @@
 @Singleton
 public class PrologSubmitRuleUtilImpl implements PrologSubmitRuleUtil {
   private final PrologRule prologRule;
-
   private final RulesCache rulesCache;
 
   @Inject
@@ -35,22 +36,25 @@
   }
 
   @Override
+  public boolean isProjectRulesEnabled() {
+    return rulesCache.isProjectRulesEnabled();
+  }
+
+  @Override
   public SubmitTypeRecord getSubmitType(ChangeData cd) {
-    return prologRule.getSubmitType(cd);
+    checkState(isProjectRulesEnabled(), "prolog rules disabled");
+    return prologRule.getSubmitType(cd, PrologOptions.defaultOptions());
   }
 
   @Override
   public SubmitTypeRecord getSubmitType(ChangeData cd, String ruleToTest, boolean skipFilters) {
+    checkState(isProjectRulesEnabled(), "prolog rules disabled");
     return prologRule.getSubmitType(cd, PrologOptions.dryRunOptions(ruleToTest, skipFilters));
   }
 
   @Override
   public SubmitRecord evaluate(ChangeData cd, String ruleToTest, boolean skipFilters) {
+    checkState(isProjectRulesEnabled(), "prolog rules disabled");
     return prologRule.evaluate(cd, PrologOptions.dryRunOptions(ruleToTest, skipFilters));
   }
-
-  @Override
-  public boolean isProjectRulesEnabled() {
-    return rulesCache.isProjectRulesEnabled();
-  }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/rules/prolog/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/rules/prolog/package-info.java
index 0709b86..08595d3 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/rules/prolog/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.rules.prolog;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index cfb9754..8db5b1a 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -17,6 +17,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.GroupReference;
@@ -124,6 +125,7 @@
     public abstract ImmutableMap.Builder<BooleanProjectConfig, InheritableBoolean>
         booleanProjectConfigsBuilder();
 
+    @CanIgnoreReturnValue
     public Builder addBooleanProjectConfig(
         BooleanProjectConfig booleanProjectConfig, InheritableBoolean inheritableBoolean) {
       booleanProjectConfigsBuilder().put(booleanProjectConfig, inheritableBoolean);
diff --git a/java/com/google/gerrit/server/schema/BUILD b/java/com/google/gerrit/server/schema/BUILD
index ce445e1..b344e6d 100644
--- a/java/com/google/gerrit/server/schema/BUILD
+++ b/java/com/google/gerrit/server/schema/BUILD
@@ -24,6 +24,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:dbcp",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
     ],
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
index 46a6857..927d3fd1 100644
--- a/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
+++ b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
@@ -29,8 +29,8 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectLevelConfig;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -127,8 +127,7 @@
    *     parsed
    */
   public void execute(Project.NameKey projectName) throws IOException, ConfigInvalidException {
-    ProjectLevelConfig.Bare projectConfig =
-        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    VersionedConfigFile projectConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
     try (Repository repo = repoManager.openRepository(projectName);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo)) {
       boolean isAlreadyMigrated = hasMigrationAlreadyRun(repo);
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
index 2ca79342..d8da13d 100644
--- a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -28,8 +28,8 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.VersionedConfigFile;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectLevelConfig;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Collection;
@@ -130,8 +130,7 @@
       ui.message(String.format("Skipping project %s because it has prolog rules", project));
       return Status.HAS_PROLOG;
     }
-    ProjectLevelConfig.Bare projectConfig =
-        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    VersionedConfigFile projectConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
     boolean migrationPerformed = false;
     try (Repository repo = repoManager.openRepository(project);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo)) {
@@ -275,7 +274,7 @@
     cfg.setString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION, function);
   }
 
-  private void commit(ProjectLevelConfig.Bare projectConfig, MetaDataUpdate md) throws IOException {
+  private void commit(VersionedConfigFile projectConfig, MetaDataUpdate md) throws IOException {
     md.getCommitBuilder().setAuthor(serverUser);
     md.getCommitBuilder().setCommitter(serverUser);
     md.setMessage(COMMIT_MSG);
diff --git a/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java b/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java
deleted file mode 100644
index 468c26b..0000000
--- a/java/com/google/gerrit/server/schema/VersionedAccountPreferences.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.schema;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.server.git.meta.VersionedMetaData;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-
-/** Preferences for user accounts during schema migrations. */
-class VersionedAccountPreferences extends VersionedMetaData {
-  static final String PREFERENCES = "preferences.config";
-
-  static VersionedAccountPreferences forUser(Account.Id id) {
-    return new VersionedAccountPreferences(RefNames.refsUsers(id));
-  }
-
-  static VersionedAccountPreferences forDefault() {
-    return new VersionedAccountPreferences(RefNames.REFS_USERS_DEFAULT);
-  }
-
-  private final String ref;
-  private Config cfg;
-
-  protected VersionedAccountPreferences(String ref) {
-    this.ref = ref;
-  }
-
-  @Override
-  protected String getRefName() {
-    return ref;
-  }
-
-  Config getConfig() {
-    return cfg;
-  }
-
-  @Override
-  protected void onLoad() throws IOException, ConfigInvalidException {
-    cfg = readConfig(PREFERENCES);
-  }
-
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-    if (Strings.isNullOrEmpty(commit.getMessage())) {
-      commit.setMessage("Updated preferences\n");
-    }
-    saveConfig(PREFERENCES, cfg);
-    return true;
-  }
-}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/schema/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/schema/package-info.java
index 0709b86..2b2ff5f 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/schema/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.schema;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/schema/testing/BUILD b/java/com/google/gerrit/server/schema/testing/BUILD
index 77bb777..8f2c2ff 100644
--- a/java/com/google/gerrit/server/schema/testing/BUILD
+++ b/java/com/google/gerrit/server/schema/testing/BUILD
@@ -11,6 +11,7 @@
         "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/schema/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/schema/testing/package-info.java
index 0709b86..d2c2885 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.schema.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/securestore/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/securestore/package-info.java
index 0709b86..c281f66 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/securestore/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.securestore;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/securestore/testing/BUILD b/java/com/google/gerrit/server/securestore/testing/BUILD
index 9afc44a..814736e 100644
--- a/java/com/google/gerrit/server/securestore/testing/BUILD
+++ b/java/com/google/gerrit/server/securestore/testing/BUILD
@@ -9,5 +9,6 @@
     deps = [
         "//java/com/google/gerrit/server",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/securestore/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/securestore/testing/package-info.java
index 0709b86..8a7b2d6 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/securestore/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.securestore.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/ssh/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/ssh/package-info.java
index 0709b86..116396a 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/ssh/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.ssh;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 47fef1a..ce26552 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -114,7 +114,8 @@
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", change.getId());
     } finally {
-      requestContext.setContext(old);
+      @SuppressWarnings("unused")
+      var unused = requestContext.setContext(old);
     }
   }
 
diff --git a/java/com/google/gerrit/server/submit/GitModules.java b/java/com/google/gerrit/server/submit/GitModules.java
index f8f6bc4..601e9ee 100644
--- a/java/com/google/gerrit/server/submit/GitModules.java
+++ b/java/com/google/gerrit/server/submit/GitModules.java
@@ -27,8 +27,8 @@
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
+import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BlobBasedConfig;
@@ -89,8 +89,8 @@
     }
   }
 
-  Collection<SubmoduleSubscription> subscribedTo(BranchNameKey src) {
-    Collection<SubmoduleSubscription> ret = new ArrayList<>();
+  List<SubmoduleSubscription> subscribedTo(BranchNameKey src) {
+    List<SubmoduleSubscription> ret = new ArrayList<>();
     for (SubmoduleSubscription s : subscriptions) {
       if (s.getSubmodule().equals(src)) {
         ret.add(s);
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 86d6c674..2e66941 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -106,8 +106,8 @@
   @Override
   public ChangeSet completeWithoutTopic(
       MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user) throws IOException {
-    Collection<ChangeData> visibleChanges = new ArrayList<>();
-    Collection<ChangeData> nonVisibleChanges = new ArrayList<>();
+    List<ChangeData> visibleChanges = new ArrayList<>();
+    List<ChangeData> nonVisibleChanges = new ArrayList<>();
 
     // For each target branch we run a separate rev walk to find open changes
     // reachable from changes already in the merge super set.
@@ -194,7 +194,7 @@
       Set<String> nonVisibleHashes,
       CurrentUser user)
       throws IOException {
-    List<ChangeData> potentiallyVisibleChanges =
+    ImmutableList<ChangeData> potentiallyVisibleChanges =
         byCommitsOnBranchNotMerged(or, branch, visibleHashes);
     List<ChangeData> invisibleChanges =
         new ArrayList<>(byCommitsOnBranchNotMerged(or, branch, nonVisibleHashes));
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 2b8a662..eb41690 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -16,16 +16,22 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.update.RetryableAction.ActionType.INDEX_QUERY;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.MERGE_CHANGE;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toSet;
 
 import com.github.rholder.retry.Attempt;
 import com.github.rholder.retry.RetryListener;
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Joiner;
+import com.google.common.base.Stopwatch;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -33,8 +39,11 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
@@ -63,6 +72,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.validators.MergeValidationException;
@@ -73,6 +85,7 @@
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -80,6 +93,7 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdates;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.SubmissionExecutor;
@@ -93,23 +107,30 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.time.Instant;
+import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Deque;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * Merges changes in submission order into a single branch.
@@ -134,6 +155,8 @@
     private final ImmutableSetMultimap<BranchNameKey, Change.Id> byBranch;
     private final Map<Change.Id, CodeReviewCommit> commits;
     private final ListMultimap<Change.Id, String> problems;
+    private final Set<SimpleImmutableEntry<Project.NameKey, BranchNameKey>> implicitMergeProblems;
+
     private final boolean allowClosed;
 
     private CommitStatus(ChangeSet cs, boolean allowClosed) {
@@ -147,6 +170,7 @@
       byBranch = bb.build();
       commits = new HashMap<>();
       problems = MultimapBuilder.treeKeys(comparing(Change.Id::get)).arrayListValues(1).build();
+      implicitMergeProblems = new HashSet<>();
       this.allowClosed = allowClosed;
     }
 
@@ -181,8 +205,12 @@
       problems.put(id, msg);
     }
 
+    public void addImplicitMerge(Project.NameKey projectName, BranchNameKey branchName) {
+      implicitMergeProblems.add(new SimpleImmutableEntry<>(projectName, branchName));
+    }
+
     public boolean isOk() {
-      return problems.isEmpty();
+      return problems.isEmpty() && implicitMergeProblems.isEmpty();
     }
 
     public List<SubmitRecord> getSubmitRecords(Change.Id id) {
@@ -214,6 +242,21 @@
       for (Change.Id id : problems.keySet()) {
         ps.add("Change " + id + ": " + Joiner.on("; ").join(problems.get(id)));
       }
+      if (ps.isEmpty()) {
+        // An implicit merge can be also detected when there are another problems with changes(e.g.
+        // the parent change is deleted). It can confuse the user if gerrit reports both the correct
+        // problem and implicit merge problem at the same time - so report implicit merge problem
+        // only if no other problems are reported.
+        for (SimpleImmutableEntry<Project.NameKey, BranchNameKey> projectBranch :
+            implicitMergeProblems) {
+          // TODO(dmfilippov): Make message more clear to the user and add the exact change id.
+          ps.add(
+              String.format(
+                  "submit makes implicit merge to the branch %s of the project %s from some other "
+                      + "branch",
+                  projectBranch.getValue().shortName(), projectBranch.getKey().get()));
+        }
+      }
       throw new ResourceConflictException(msg + Joiner.on('\n').join(ps));
     }
 
@@ -234,6 +277,7 @@
 
   private final ChangeMessagesUtil cmUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final BatchUpdates batchUpdates;
   private final InternalUser.Factory internalUserFactory;
   private final MergeSuperSet mergeSuperSet;
   private final MergeValidators.Factory mergeValidatorsFactory;
@@ -252,6 +296,11 @@
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
 
+  private final ExperimentFeatures experimentFeatures;
+
+  private final ProjectCache projectCache;
+  private final long hasImplicitMergeTimeoutSeconds;
+
   private Instant ts;
   private SubmissionId submissionId;
   private IdentifiedUser caller;
@@ -260,7 +309,7 @@
   private CommitStatus commitStatus;
   private SubmitInput submitInput;
   private NotifyResolver.Result notify;
-  private Set<Project.NameKey> projects;
+  private ImmutableSet<Project.NameKey> projects;
   private boolean dryrun;
   private TopicMetrics topicMetrics;
 
@@ -268,6 +317,7 @@
   MergeOp(
       ChangeMessagesUtil cmUtil,
       BatchUpdate.Factory batchUpdateFactory,
+      BatchUpdates batchUpdates,
       InternalUser.Factory internalUserFactory,
       MergeSuperSet mergeSuperSet,
       MergeValidators.Factory mergeValidatorsFactory,
@@ -283,9 +333,13 @@
       RetryHelper retryHelper,
       ChangeData.Factory changeDataFactory,
       StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory,
-      MergeMetrics mergeMetrics) {
+      MergeMetrics mergeMetrics,
+      ProjectCache projectCache,
+      ExperimentFeatures experimentFeatures,
+      @GerritServerConfig Config config) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.batchUpdates = batchUpdates;
     this.internalUserFactory = internalUserFactory;
     this.mergeSuperSet = mergeSuperSet;
     this.mergeValidatorsFactory = mergeValidatorsFactory;
@@ -302,6 +356,12 @@
     this.updatedChanges = new HashMap<>();
     this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
     this.mergeMetrics = mergeMetrics;
+    this.projectCache = projectCache;
+    this.experimentFeatures = experimentFeatures;
+    // Undocumented - experimental, can be removed.
+    hasImplicitMergeTimeoutSeconds =
+        ConfigUtil.getTimeUnit(
+            config, "change", null, "implicitMergeCalculationTimeout", 60, TimeUnit.SECONDS);
   }
 
   @Override
@@ -438,6 +498,7 @@
    * @throws IOException an error occurred reading from NoteDb.
    * @return the merged change
    */
+  @CanIgnoreReturnValue
   public Change merge(
       Change change,
       IdentifiedUser caller,
@@ -500,37 +561,40 @@
         }
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListeners);
+            new SubmissionExecutor(batchUpdates, dryrun, superprojectUpdateSubmissionListeners);
         RetryTracker retryTracker = new RetryTracker();
-        retryHelper
-            .changeUpdate(
-                "integrateIntoHistory",
-                updateFactory -> {
-                  long attempt = retryTracker.lastAttemptNumber + 1;
-                  boolean isRetry = attempt > 1;
-                  if (isRetry) {
-                    logger.atFine().log("Retrying, attempt #%d; skipping merged changes", attempt);
-                    this.ts = TimeUtil.now();
-                    openRepoManager();
-                  }
-                  this.commitStatus = new CommitStatus(filteredNoteDbChangeSet, isRetry);
-                  if (checkSubmitRules) {
-                    logger.atFine().log("Checking submit rules and state");
-                    checkSubmitRulesAndState(filteredNoteDbChangeSet, isRetry);
-                  } else {
-                    logger.atFine().log("Bypassing submit rules");
-                    bypassSubmitRulesAndRequirements(filteredNoteDbChangeSet);
-                  }
-                  integrateIntoHistory(
-                      filteredNoteDbChangeSet, submissionExecutor, checkSubmitRules);
-                  return null;
-                })
-            .listener(retryTracker)
-            // Up to the entire submit operation is retried, including possibly many projects.
-            // Multiply the timeout by the number of projects we're actually attempting to
-            // submit. Times 2 to retry more persistently, to increase success rate.
-            .defaultTimeoutMultiplier(filteredNoteDbChangeSet.projects().size() * 2)
-            .call();
+        @SuppressWarnings("unused")
+        var unused =
+            retryHelper
+                .changeUpdate(
+                    "integrateIntoHistory",
+                    updateFactory -> {
+                      long attempt = retryTracker.lastAttemptNumber + 1;
+                      boolean isRetry = attempt > 1;
+                      if (isRetry) {
+                        logger.atFine().log(
+                            "Retrying, attempt #%d; skipping merged changes", attempt);
+                        this.ts = TimeUtil.now();
+                        openRepoManager();
+                      }
+                      this.commitStatus = new CommitStatus(filteredNoteDbChangeSet, isRetry);
+                      if (checkSubmitRules) {
+                        logger.atFine().log("Checking submit rules and state");
+                        checkSubmitRulesAndState(filteredNoteDbChangeSet, isRetry);
+                      } else {
+                        logger.atFine().log("Bypassing submit rules");
+                        bypassSubmitRulesAndRequirements(filteredNoteDbChangeSet);
+                      }
+                      integrateIntoHistory(
+                          filteredNoteDbChangeSet, submissionExecutor, checkSubmitRules);
+                      return null;
+                    })
+                .listener(retryTracker)
+                // Up to the entire submit operation is retried, including possibly many projects.
+                // Multiply the timeout by the number of projects we're actually attempting to
+                // submit. Times 2 to retry more persistently, to increase success rate.
+                .defaultTimeoutMultiplier(filteredNoteDbChangeSet.projects().size() * 2)
+                .call();
         submissionExecutor.afterExecutions(orm);
 
         if (projects > 1) {
@@ -754,7 +818,7 @@
       boolean dryrun)
       throws IntegrationConflictException, NoSuchProjectException, IOException {
     List<SubmitStrategy> strategies = new ArrayList<>();
-    Set<BranchNameKey> allBranches = updateOrderCalculator.getBranchesInOrder();
+    ImmutableSet<BranchNameKey> allBranches = updateOrderCalculator.getBranchesInOrder();
     Set<CodeReviewCommit> allCommits =
         toSubmit.values().stream().map(BranchBatch::commits).flatMap(Set::stream).collect(toSet());
 
@@ -767,7 +831,10 @@
         requireNonNull(
             submitting.submitType(),
             String.format("null submit type for %s; expected to previously fail fast", submitting));
-        Set<CodeReviewCommit> commitsToSubmit = submitting.commits();
+        ImmutableSet<CodeReviewCommit> commitsToSubmit = submitting.commits();
+        checkImplicitMerges(
+            branch, or.rw, submitting.commits(), submitting.submitType(), ob.oldTip);
+
         ob.mergeTip = new MergeTip(ob.oldTip, commitsToSubmit);
         SubmitStrategy strategy =
             submitStrategyFactory.create(
@@ -793,6 +860,202 @@
     return strategies;
   }
 
+  private void checkImplicitMerges(
+      BranchNameKey branch,
+      RevWalk rw,
+      Set<CodeReviewCommit> commitsToSubmit,
+      SubmitType submitType,
+      @Nullable RevCommit branchTip)
+      throws IOException {
+    if (branchTip == null) {
+      // The branch doesn't exist.
+      return;
+    }
+    Project.NameKey project = branch.project();
+    if (!experimentFeatures.isFeatureEnabled(
+        GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE, project)) {
+      return;
+    }
+    if (submitType == SubmitType.CHERRY_PICK || submitType == SubmitType.REBASE_ALWAYS) {
+      return;
+    }
+
+    boolean projectConfigRejectImplicitMerges =
+        projectCache
+            .get(project)
+            .orElseThrow(illegalState(project))
+            .is(BooleanProjectConfig.REJECT_IMPLICIT_MERGES);
+    boolean rejectImplicitMergesOnMerges =
+        experimentFeatures.isFeatureEnabled(
+                GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE, project)
+            && (experimentFeatures.isFeatureEnabled(
+                    GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE, project)
+                || projectConfigRejectImplicitMerges);
+    try {
+      if (hasImplicitMerges(branch, rw, commitsToSubmit, branchTip)) {
+        if (rejectImplicitMergesOnMerges) {
+          commitStatus.addImplicitMerge(project, branch);
+        } else {
+          String allCommits =
+              commitsToSubmit.stream()
+                  .map(CodeReviewCommit::getId)
+                  .map(c -> ObjectId.toString(c))
+                  .collect(joining(", "));
+          logger.atWarning().log(
+              "Implicit merge was detected for the branch %s of the project %s. "
+                  + "Commits to be merged are: %s",
+              branch.shortName(), project, allCommits);
+        }
+      }
+    } catch (Exception e) {
+      if (rejectImplicitMergesOnMerges) {
+        throw e;
+      }
+      logger.atWarning().withCause(e).log("Error while checking for implicit merges");
+    }
+  }
+
+  private boolean isMergedInBranchAsSubmittedChange(RevCommit commit, BranchNameKey dest) {
+    List<ChangeData> changes = queryProvider.get().byBranchCommit(dest, commit.getId().getName());
+    for (ChangeData change : changes) {
+      if (change.change().isMerged()) {
+        logger.atFine().log(
+            "Dependency %s associated with merged change %s.", commit.getName(), change.getId());
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Checks if merging {@code commitsToSubmit} into the target branch leads to implicit merge.
+   *
+   * <p>All commits in the {@code commitsToSubmit} have {@code targetBranch} as a target. When
+   * multiple changes are submitted together, the {@code commitsToSubmit} contains transitive
+   * dependencies, not a single change (the method is never called for the cherry pick strategy
+   * because the strategy always submit a single change).
+   */
+  private boolean hasImplicitMerges(
+      BranchNameKey targetBranch,
+      RevWalk rw,
+      Set<CodeReviewCommit> commitsToSubmit,
+      RevCommit branchTip)
+      throws IOException {
+
+    // rootCommits - top level commits in chains. It is all commits which don't have children in
+    // the commitsToSubmit set (no commits have them as parents).
+    Set<CodeReviewCommit> rootCommits = new HashSet<>(commitsToSubmit);
+    Set<RevCommit> allParents = new HashSet<>();
+    for (CodeReviewCommit commit : commitsToSubmit) {
+      rw.parseBody(commit);
+      for (RevCommit parent : commit.getParents()) {
+        rootCommits.remove(parent);
+        allParents.add(parent);
+      }
+    }
+
+    // Calculate all "external" parents of commitsToSubmit - i.e. all parents which already
+    // present in the repository.
+    // targetBranchParents - all "external" parents which were merged into the targetBranch (
+    // they are reachable from the targetBranchTip).
+    Set<RevCommit> targetBranchParents = new HashSet<>();
+    int nonTargetBranchParentsCount = 0;
+    try {
+      for (RevCommit parent : Sets.difference(allParents, commitsToSubmit)) {
+        if (rw.isMergedInto(parent, branchTip)) {
+          targetBranchParents.add(parent);
+        } else {
+          // Special case: user created chain of changes and then submit first changes from the
+          // chain. It should be allowed for the user to submit remaining changes of the chain
+          // without rebasing them (otherwise votes can be lost).
+          // When a rebase... strategy is used in this scenario, submitting the first few changes of
+          // the chain creates new patchset(s), but all others changes are not rebased on top of new
+          // patchset(s). In this situation isMergedInto check is not enough and additional
+          // isMergedInBranchAsSubmittedChange check should be used.
+          if (isMergedInBranchAsSubmittedChange(parent, targetBranch)) {
+            targetBranchParents.add(parent);
+          } else {
+            nonTargetBranchParentsCount++;
+          }
+        }
+      }
+    } finally {
+      // It's unclear why resetting the RevWalk here is needed, but if we don't do this MergeSorter
+      // and RebaseSorter which are invoked later with the same RevWalk instance may fail while
+      // marking commits as uninteresting.
+      rw.reset();
+    }
+
+    if (nonTargetBranchParentsCount == 0) {
+      // All parents are in target branch, no implicit merge is possible.
+      return false;
+    }
+    // There are some parents not in the target branch.
+    if (rootCommits.size() == 1) {
+      // There is only one root commit - this is the case when a single chain of changes is
+      // submitted to the branch.
+      // If the target branch is not reachable from the root commit then it means that there is no
+      // explicit merge with the target branch and the merge operation will create an implicit merge
+      // (except if rebase is used; but for consistency between different strategies we reject
+      // merge even for rebase).
+      return targetBranchParents.isEmpty();
+    }
+    // There are multiple root commits - check that a target branch is reachable from each root
+    // commit. This situation means that multiple chain of changes are submitted (e.g. as a part
+    // of a single topic).
+    // reachableCommits contains pairs of commit: the first item in pair is always one of the root
+    // commits. The second item in pair - a commit reachable from this root (following parents).
+    // Loop implements breadth-search.
+    Deque<Entry<CodeReviewCommit, RevCommit>> reachableCommits =
+        new ArrayDeque<>(rootCommits.size());
+    rootCommits.forEach(commit -> reachableCommits.add(Map.entry(commit, commit)));
+    // Tracks all chains roots which can lead to implicit merge.
+    Set<CodeReviewCommit> implicitMergesRoots = new HashSet<>(rootCommits);
+    int iterationCount = 0;
+    Stopwatch sw = Stopwatch.createStarted();
+    while (!reachableCommits.isEmpty()) {
+      iterationCount++;
+      if (hasImplicitMergeTimeoutSeconds != 0
+          && sw.elapsed(TimeUnit.SECONDS) >= hasImplicitMergeTimeoutSeconds) {
+        String allCommits =
+            commitsToSubmit.stream()
+                .map(CodeReviewCommit::getId)
+                .map(c -> ObjectId.toString(c))
+                .collect(joining(", "));
+        logger.atWarning().log(
+            "Timeout during hasImplicitMerge calculation. Number of iterations: %s, commitsToSubmit: %s",
+            iterationCount, allCommits);
+        return true;
+      }
+      Entry<CodeReviewCommit, RevCommit> entry = reachableCommits.pop();
+      if (!implicitMergesRoots.contains(entry.getKey())) {
+        // We already know that from the given root (key in the entry) one of the
+        // targetBranchParents is reachable and this is not an implicit merge.
+        continue;
+      }
+      if (targetBranchParents.contains(entry.getValue())) {
+        // The target branch is reachable from the root. We don't need to process other items
+        // in the queue for this root.
+        implicitMergesRoots.remove(entry.getKey());
+        continue;
+      }
+      if (entry.getValue() == null) {
+        logger.atSevere().log("The entry value is null for the key %s", entry.getKey());
+      }
+      rw.parseBody(entry.getValue());
+      if (entry.getValue().getParents() == null) {
+        logger.atSevere().log(
+            "The entry value has null parents. The value is: %s", entry.getValue());
+      }
+      for (RevCommit parent : entry.getValue().getParents()) {
+        reachableCommits.push(Map.entry(entry.getKey(), parent));
+      }
+    }
+    // only commits which don't have parents in the targetBranch remains in the implicitMergesRoots.
+    // If there are at least one commit - this is an implicit merge.
+    return !implicitMergesRoots.isEmpty();
+  }
+
   private Set<RevCommit> getAlreadyAccepted(OpenRepo or, CodeReviewCommit branchTip) {
     Set<RevCommit> alreadyAccepted = new HashSet<>();
 
diff --git a/java/com/google/gerrit/server/submit/RebaseSorter.java b/java/com/google/gerrit/server/submit/RebaseSorter.java
index 3645d3f..d769ddd 100644
--- a/java/com/google/gerrit/server/submit/RebaseSorter.java
+++ b/java/com/google/gerrit/server/submit/RebaseSorter.java
@@ -31,6 +31,7 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
 
@@ -65,8 +66,10 @@
   public List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort) throws IOException {
     final List<CodeReviewCommit> sorted = new ArrayList<>();
     final Set<CodeReviewCommit> sort = new HashSet<>(toSort);
+    final Set<ObjectId> uninterestingParents = new HashSet<>();
     while (!sort.isEmpty()) {
       final CodeReviewCommit n = removeOne(sort);
+      collectUninterestingParents(n, uninterestingParents);
 
       rw.resetRetain(canMergeFlag);
       rw.markStart(n);
@@ -77,6 +80,8 @@
       CodeReviewCommit c;
       final List<CodeReviewCommit> contents = new ArrayList<>();
       while ((c = rw.next()) != null) {
+        collectUninterestingParents(c, uninterestingParents);
+
         if (!c.has(canMergeFlag) || !incoming.contains(c)) {
           if (isMergedInBranchAsSubmittedChange(c, n.change().getDest())
               || isAlreadyMergedInAnyBranch(c)) {
@@ -106,9 +111,27 @@
       sorted.removeAll(contents);
       sorted.addAll(contents);
     }
+    sorted.removeAll(uninterestingParents);
     return sorted;
   }
 
+  /**
+   * Rebasing merge commits is done by rebasing their first parent commit, i.e. the first parent is
+   * updated to the new base while the second parent stays intact. This means for chains of changes
+   * we only need to rebase changes that are reachable via the first parents. Changes that are
+   * reachable via second parents do not need to be rebased (since the second parent of merge
+   * commits stays intact) which is why we filter them out here by marking them as uninteresting.
+   */
+  private void collectUninterestingParents(CodeReviewCommit c, Set<ObjectId> uninterestingParents)
+      throws IOException {
+    if (c.getParentCount() > 0) {
+      for (int parent = 1; parent < c.getParentCount(); parent++) {
+        uninterestingParents.add(c.getParent(parent));
+        rw.markUninteresting(c.getParent(parent));
+      }
+    }
+  }
+
   private boolean isAlreadyMergedInAnyBranch(CodeReviewCommit commit) throws IOException {
     try (CodeReviewRevWalk mirw = CodeReviewCommit.newRevWalk(rw.getObjectReader())) {
       mirw.reset();
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 87de810..e3d7fc4 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -15,9 +15,7 @@
 package com.google.gerrit.server.submit;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.submit.CommitMergeStatus.EMPTY_COMMIT;
-import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
@@ -32,6 +30,7 @@
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.MergeTip;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -41,11 +40,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;
-import org.eclipse.jgit.revwalk.RevCommit;
 
 /** This strategy covers RebaseAlways and RebaseIfNecessary ones. */
 public class RebaseSubmitStrategy extends SubmitStrategy {
@@ -65,39 +60,6 @@
       throw new StorageException("Commit sorting failed", e);
     }
 
-    // We cannot rebase merge commits. This is why we integrate merge changes into the target branch
-    // the same way as if MERGE_IF_NECESSARY was the submit strategy. This means if needed we create
-    // a merge commit that integrates the merge change into the target branch.
-    // If we integrate a change series that consists out of a normal change and a merge change,
-    // where the merge change depends on the normal change, we must skip rebasing the normal change,
-    // because it already gets integrated by merging the merge change. If the rebasing of the normal
-    // change is not skipped, it would appear twice in the history after the submit is done (once
-    // through its rebased commit, and once through its original commit which is a parent of the
-    // merge change that was merged into the target branch. To skip the rebasing of the normal
-    // change, we call MergeUtil#reduceToMinimalMerge, as it excludes commits which will be
-    // implicitly integrated by merging the series. Then we use the MergeIfNecessaryOp to integrate
-    // the whole series.
-    // If on the other hand, we integrate a change series that consists out of a merge change and a
-    // normal change, where the normal change depends on the merge change, we can first integrate
-    // the merge change by a merge and then integrate the normal change by a rebase. In this case we
-    // do not want to call MergeUtil#reduceToMinimalMerge as we are not intending to integrate the
-    // whole series by a merge, but rather do the integration of the commits one by one.
-    boolean foundNonMerge = false;
-    for (CodeReviewCommit c : sorted) {
-      if (c.getParentCount() > 1) {
-        if (!foundNonMerge) {
-          // found a merge change, but it doesn't depend on a normal change, this means we are not
-          // required to merge the whole series at once
-          continue;
-        }
-        // found a merge commit that depends on a normal change, this means we are required to merge
-        // the whole series at once
-        sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
-        return sorted.stream().map(n -> new MergeIfNecessaryOp(n)).collect(toImmutableList());
-      }
-      foundNonMerge = true;
-    }
-
     ImmutableList.Builder<SubmitStrategyOp> ops =
         ImmutableList.builderWithExpectedSize(sorted.size());
     boolean first = true;
@@ -109,10 +71,8 @@
         ops.add(new FastForwardOp(args, n));
       } else if (n.getParentCount() == 0) {
         ops.add(new RebaseRootOp(n));
-      } else if (n.getParentCount() == 1) {
-        ops.add(new RebaseOneOp(n));
       } else {
-        ops.add(new MergeIfNecessaryOp(n));
+        ops.add(new RebaseOneOp(n));
       }
       first = false;
     }
@@ -144,91 +104,62 @@
     @Override
     public void updateRepoImpl(RepoContext ctx)
         throws InvalidChangeOperationException, RestApiException, IOException,
-            PermissionBackendException {
-      if (args.mergeUtil.canFastForward(
-          args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
-        if (!rebaseAlways) {
-          if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
-              && toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
-            toMerge.setStatusCode(EMPTY_COMMIT);
-            return;
-          }
+            PermissionBackendException, DiffNotAvailableException {
+      if (!rebaseAlways
+          && args.mergeUtil.canFastForward(
+              args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
+        if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
+            && toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
+          toMerge.setStatusCode(EMPTY_COMMIT);
+          return;
+        }
 
-          args.mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
-          toMerge.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
-          acceptMergeTip(args.mergeTip);
-          return;
-        }
-        // RebaseAlways means we modify commit message.
-        args.rw.parseBody(toMerge);
-        newPatchSetId =
-            ChangeUtil.nextPatchSetIdFromChangeRefs(
-                ctx.getRepoView().getRefs(getId().toRefPrefix()).keySet(),
-                toMerge.change().currentPatchSetId());
-        RevCommit mergeTip = args.mergeTip.getCurrentTip();
-        args.rw.parseBody(mergeTip);
-        String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
-        PersonIdent committer =
-            Optional.ofNullable(toMerge.getCommitterIdent())
-                .map(ident -> ctx.newCommitterIdent(ident.getEmailAddress(), args.caller))
-                .orElseGet(() -> ctx.newCommitterIdent(args.caller));
-        try {
-          newCommit =
-              args.mergeUtil.createCherryPickFromCommit(
-                  ctx.getInserter(),
-                  ctx.getRepoView().getConfig(),
-                  args.mergeTip.getCurrentTip(),
-                  toMerge,
-                  committer,
-                  cherryPickCmtMsg,
-                  args.rw,
-                  0,
-                  true,
-                  false);
-        } catch (MergeConflictException mce) {
-          // Unlike in Cherry-pick case, this should never happen.
-          toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
-          throw new IllegalStateException(
-              "MergeConflictException on message edit must not happen", mce);
-        } catch (MergeIdenticalTreeException mie) {
-          // this should not happen
-          toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
-          return;
-        }
-        ctx.addRefUpdate(ObjectId.zeroId(), newCommit, newPatchSetId.toRefName());
-      } else {
-        // Stale read of patch set is ok; see comments in RebaseChangeOp.
-        PatchSet origPs = args.psUtil.get(toMerge.getNotes(), toMerge.getPatchsetId());
-        rebaseOp =
-            args.rebaseFactory
-                .create(toMerge.notes(), origPs, args.mergeTip.getCurrentTip())
-                .setFireRevisionCreated(false)
-                // Bypass approval copier since SubmitStrategyOp copy all approvals
-                // later anyway.
-                .setValidate(false)
-                .setCheckAddPatchSetPermission(false)
-                // RebaseAlways should set always modify commit message like
-                // Cherry-Pick strategy.
-                .setDetailedCommitMessage(rebaseAlways)
-                // Do not post message after inserting new patchset because there
-                // will be one about change being merged already.
-                .setPostMessage(false)
-                .setSendEmail(false)
-                .setMatchAuthorToCommitterDate(
-                    args.project.is(BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE))
-                // The votes are automatically copied and they don't count as copied votes. See
-                // method's javadoc.
-                .setStoreCopiedVotes(/* storeCopiedVotes = */ false);
-        try {
-          rebaseOp.updateRepo(ctx);
-        } catch (MergeConflictException | NoSuchChangeException e) {
-          toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
-          throw new IntegrationConflictException(
-              "Cannot rebase " + toMerge.name() + ": " + e.getMessage(), e);
-        }
-        newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
-        newPatchSetId = rebaseOp.getPatchSetId();
+        args.mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
+        toMerge.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
+        acceptMergeTip(args.mergeTip);
+        return;
       }
+
+      args.rw.parseBody(toMerge);
+      newPatchSetId =
+          ChangeUtil.nextPatchSetIdFromChangeRefs(
+              ctx.getRepoView().getRefs(getId().toRefPrefix()).keySet(),
+              toMerge.change().currentPatchSetId());
+      // Stale read of patch set is ok; see comments in RebaseChangeOp.
+      PatchSet origPs = args.psUtil.get(toMerge.getNotes(), toMerge.getPatchsetId());
+      rebaseOp =
+          args.rebaseFactory
+              .create(toMerge.notes(), origPs, args.mergeTip.getCurrentTip())
+              .setFireRevisionCreated(false)
+              // Bypass approval copier since SubmitStrategyOp copy all approvals
+              // later anyway.
+              .setValidate(false)
+              .setCheckAddPatchSetPermission(false)
+              // RebaseAlways should set always modify commit message like
+              // Cherry-Pick strategy.
+              .setDetailedCommitMessage(rebaseAlways)
+              // Do not post message after inserting new patchset because there
+              // will be one about change being merged already.
+              .setPostMessage(false)
+              .setSendEmail(false)
+              .setMatchAuthorToCommitterDate(
+                  args.project.is(BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE))
+              // The votes are automatically copied and they don't count as copied votes. See
+              // method's javadoc.
+              .setStoreCopiedVotes(/* storeCopiedVotes = */ false)
+              .setVerifyNeedsRebase(/* verifyNeedsRebase= */ !rebaseAlways);
+
+      try {
+        rebaseOp.updateRepo(ctx);
+      } catch (MergeConflictException | NoSuchChangeException e) {
+        toMerge.setStatusCode(CommitMergeStatus.REBASE_MERGE_CONFLICT);
+        throw new IntegrationConflictException(
+            "Cannot rebase " + toMerge.name() + ": " + e.getMessage(), e);
+      }
+
+      newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
+      newPatchSetId = rebaseOp.getPatchSetId();
+
       if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
           && newCommit.getTree().equals(newCommit.getParent(0).getTree())) {
         toMerge.setStatusCode(EMPTY_COMMIT);
@@ -255,7 +186,9 @@
 
       PatchSet newPs;
       if (rebaseOp != null) {
-        rebaseOp.updateChange(ctx);
+        @SuppressWarnings("unused")
+        var unused = rebaseOp.updateChange(ctx);
+
         newPs = rebaseOp.getPatchSet();
       } else {
         // CherryPick
@@ -285,49 +218,6 @@
     }
   }
 
-  private class MergeIfNecessaryOp extends SubmitStrategyOp {
-    private MergeIfNecessaryOp(CodeReviewCommit toMerge) {
-      super(RebaseSubmitStrategy.this.args, toMerge);
-    }
-
-    @Override
-    public void updateRepoImpl(RepoContext ctx) throws IntegrationConflictException, IOException {
-      // There are multiple parents, so this is a merge commit. We don't want
-      // to rebase the merge as clients can't easily rebase their history with
-      // that merge present and replaced by an equivalent merge with a different
-      // first parent. So instead behave as though MERGE_IF_NECESSARY was
-      // configured.
-      // TODO(tandrii): this is not in spirit of RebaseAlways strategy because
-      // the commit messages can not be modified in the process. It's also
-      // possible to implement rebasing of merge commits. E.g., the Cherry Pick
-      // REST endpoint already supports cherry-picking of merge commits.
-      // For now, users of RebaseAlways strategy for whom changed commit footers
-      // are important would be well advised to prohibit uploading patches with
-      // merge commits.
-      MergeTip mergeTip = args.mergeTip;
-      if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
-          && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
-        mergeTip.moveTipTo(toMerge, toMerge);
-      } else {
-        PersonIdent caller = ctx.newCommitterIdent();
-        CodeReviewCommit newTip =
-            args.mergeUtil.mergeOneCommit(
-                caller,
-                caller,
-                args.rw,
-                ctx.getInserter(),
-                ctx.getRepoView().getConfig(),
-                args.destBranch,
-                mergeTip.getCurrentTip(),
-                toMerge);
-        mergeTip.moveTipTo(amendGitlink(newTip), toMerge);
-      }
-      args.mergeUtil.markCleanMerges(
-          args.rw, args.canMergeFlag, mergeTip.getCurrentTip(), args.alreadyAccepted);
-      acceptMergeTip(mergeTip);
-    }
-  }
-
   private void acceptMergeTip(MergeTip mergeTip) {
     args.alreadyAccepted.add(mergeTip.getCurrentTip());
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index bdda3fc5..d4dd67a 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -264,7 +264,7 @@
    *     place.
    */
   public final void addOps(BatchUpdate bu, Set<CodeReviewCommit> toMerge) {
-    List<SubmitStrategyOp> ops = buildOps(toMerge);
+    ImmutableList<SubmitStrategyOp> ops = buildOps(toMerge);
     Set<CodeReviewCommit> added = Sets.newHashSetWithExpectedSize(ops.size());
 
     for (SubmitStrategyOp op : ops) {
diff --git a/java/com/google/gerrit/server/submit/SubmoduleCommits.java b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
index 1fd3ad6..88fb1d4 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleCommits.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
@@ -18,6 +18,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.SubmoduleSubscription;
@@ -214,6 +215,7 @@
   }
 
   @Nullable
+  @CanIgnoreReturnValue
   private RevCommit updateSubmodule(
       DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
       throws SubmoduleConflictException, IOException {
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index cebb5e3..2402357 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdates;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
@@ -39,13 +39,16 @@
 
   @Singleton
   public static class Factory {
+    private final BatchUpdates batchUpdates;
     private final SubscriptionGraph.Factory subscriptionGraphFactory;
     private final SubmoduleCommits.Factory submoduleCommitsFactory;
 
     @Inject
     Factory(
+        BatchUpdates batchUpdates,
         SubscriptionGraph.Factory subscriptionGraphFactory,
         SubmoduleCommits.Factory submoduleCommitsFactory) {
+      this.batchUpdates = batchUpdates;
       this.subscriptionGraphFactory = subscriptionGraphFactory;
       this.submoduleCommitsFactory = submoduleCommitsFactory;
     }
@@ -54,6 +57,7 @@
         Map<BranchNameKey, ReceiveCommand> updatedBranches, MergeOpRepoManager orm)
         throws SubmoduleConflictException {
       return new SubmoduleOp(
+          batchUpdates,
           updatedBranches,
           orm,
           subscriptionGraphFactory.compute(updatedBranches.keySet(), orm),
@@ -61,6 +65,7 @@
     }
   }
 
+  private final BatchUpdates batchUpdates;
   private final Map<BranchNameKey, ReceiveCommand> updatedBranches;
   private final MergeOpRepoManager orm;
   private final SubscriptionGraph subscriptionGraph;
@@ -68,10 +73,12 @@
   private final UpdateOrderCalculator updateOrderCalculator;
 
   private SubmoduleOp(
+      BatchUpdates batchUpdates,
       Map<BranchNameKey, ReceiveCommand> updatedBranches,
       MergeOpRepoManager orm,
       SubscriptionGraph subscriptionGraph,
       SubmoduleCommits submoduleCommits) {
+    this.batchUpdates = batchUpdates;
     this.updatedBranches = updatedBranches;
     this.orm = orm;
     this.subscriptionGraph = subscriptionGraph;
@@ -107,7 +114,7 @@
         }
       }
       try (RefUpdateContext ctx = RefUpdateContext.open(UPDATE_SUPERPROJECT)) {
-        BatchUpdate.execute(
+        batchUpdates.execute(
             orm.batchUpdates(superProjects, /* refLogMessage= */ "merged"),
             ImmutableList.of(),
             dryrun);
diff --git a/java/com/google/gerrit/server/submit/SubscriptionGraph.java b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
index d434890..711950c 100644
--- a/java/com/google/gerrit/server/submit/SubscriptionGraph.java
+++ b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
@@ -37,7 +37,6 @@
 import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Deque;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -283,7 +282,7 @@
 
       currentVisited.add(current);
       try {
-        Collection<SubmoduleSubscription> subscriptions =
+        List<SubmoduleSubscription> subscriptions =
             superProjectSubscriptionsForSubmoduleBranch(current, branchGitModules, orm);
         for (SubmoduleSubscription sub : subscriptions) {
           BranchNameKey superBranch = sub.getSuperProject();
@@ -310,7 +309,7 @@
       allVisited.add(current);
     }
 
-    private Collection<BranchNameKey> getDestinationBranches(
+    private ImmutableSet<BranchNameKey> getDestinationBranches(
         BranchNameKey src, SubscribeSection s, MergeOpRepoManager orm) throws IOException {
       OpenRepo or;
       try {
@@ -326,12 +325,12 @@
       return s.getDestinationBranches(src, refs);
     }
 
-    private Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
+    private List<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
         BranchNameKey srcBranch,
         Map<BranchNameKey, GitModules> branchGitModules,
         MergeOpRepoManager orm)
         throws IOException {
-      Collection<SubmoduleSubscription> ret = new ArrayList<>();
+      List<SubmoduleSubscription> ret = new ArrayList<>();
       if (RefNames.isGerritRef(srcBranch.branch())) return ret;
 
       Project.NameKey srcProject = srcBranch.project();
@@ -340,7 +339,7 @@
               .get(srcProject)
               .orElseThrow(illegalState(srcProject))
               .getSubscribeSections(srcBranch)) {
-        Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s, orm);
+        ImmutableSet<BranchNameKey> branches = getDestinationBranches(srcBranch, s, orm);
         for (BranchNameKey targetBranch : branches) {
           Project.NameKey targetProject = targetBranch.project();
           try {
diff --git a/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java b/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java
index 517c708..2f584aa 100644
--- a/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java
+++ b/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmoduleSubscription;
-import java.util.Collection;
 import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.Set;
@@ -68,7 +67,8 @@
     current.add(project);
     Set<Project.NameKey> subprojects = new HashSet<>();
     for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
-      Collection<SubmoduleSubscription> subscriptions = subscriptionGraph.getSubscriptions(branch);
+      ImmutableSet<SubmoduleSubscription> subscriptions =
+          subscriptionGraph.getSubscriptions(branch);
       for (SubmoduleSubscription s : subscriptions) {
         subprojects.add(s.getSubmodule().project());
       }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/submit/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/submit/package-info.java
index 0709b86..d3dac18 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/submit/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.submit;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/submitrequirement/predicate/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/submitrequirement/predicate/package-info.java
index 0709b86..16257e5 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.submitrequirement.predicate;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/tools/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/tools/package-info.java
index 0709b86..df59822 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/tools/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.tools;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 532b345..813246c 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
 import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.common.UsedAt.Project.GOOGLE;
 import static java.util.Comparator.comparing;
@@ -35,7 +34,6 @@
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Multiset;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -51,9 +49,6 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
@@ -73,12 +68,7 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.LimitExceededException;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.NoSuchRefException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -91,10 +81,8 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.TreeMap;
-import java.util.function.Function;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -103,7 +91,6 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushCertificate;
 import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.transport.ReceiveCommand.Result;
 
 /**
  * Helper for a set of change updates that should be applied to the NoteDb database.
@@ -142,118 +129,6 @@
     BatchUpdate create(Project.NameKey project, CurrentUser user, Instant when);
   }
 
-  public static void execute(
-      Collection<BatchUpdate> updates, ImmutableList<BatchUpdateListener> listeners, boolean dryrun)
-      throws UpdateException, RestApiException {
-    requireNonNull(listeners);
-    if (updates.isEmpty()) {
-      return;
-    }
-
-    checkDifferentProject(updates);
-
-    try {
-      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>();
-      List<ChangesHandle> changesHandles = new ArrayList<>(updates.size());
-      try {
-        for (BatchUpdate u : updates) {
-          u.executeUpdateRepo();
-        }
-        notifyAfterUpdateRepo(listeners);
-        for (BatchUpdate u : updates) {
-          changesHandles.add(u.executeChangeOps(listeners, dryrun));
-        }
-        for (ChangesHandle h : changesHandles) {
-          h.execute();
-          if (h.requiresReindex()) {
-            indexFutures.addAll(h.startIndexFutures());
-          }
-        }
-        notifyAfterUpdateRefs(listeners);
-        notifyAfterUpdateChanges(listeners);
-      } finally {
-        for (ChangesHandle h : changesHandles) {
-          h.close();
-        }
-      }
-
-      Map<Change.Id, ChangeData> changeDatas =
-          Futures.allAsList(indexFutures).get().stream()
-              // filter out null values that were returned for change deletions
-              .filter(Objects::nonNull)
-              .collect(toMap(cd -> cd.change().getId(), Function.identity()));
-
-      // Fire ref update events only after all mutations are finished, since callers may assume a
-      // patch set ref being created means the change was created, or a branch advancing meaning
-      // some changes were closed.
-      updates.forEach(BatchUpdate::fireRefChangeEvents);
-
-      if (!dryrun) {
-        for (BatchUpdate u : updates) {
-          u.executePostOps(changeDatas);
-        }
-      }
-    } catch (Exception e) {
-      wrapAndThrowException(e);
-    }
-  }
-
-  private static void notifyAfterUpdateRepo(ImmutableList<BatchUpdateListener> listeners)
-      throws Exception {
-    for (BatchUpdateListener listener : listeners) {
-      listener.afterUpdateRepos();
-    }
-  }
-
-  private static void notifyAfterUpdateRefs(ImmutableList<BatchUpdateListener> listeners)
-      throws Exception {
-    for (BatchUpdateListener listener : listeners) {
-      listener.afterUpdateRefs();
-    }
-  }
-
-  private static void notifyAfterUpdateChanges(ImmutableList<BatchUpdateListener> listeners)
-      throws Exception {
-    for (BatchUpdateListener listener : listeners) {
-      listener.afterUpdateChanges();
-    }
-  }
-
-  private static void checkDifferentProject(Collection<BatchUpdate> updates) {
-    Multiset<Project.NameKey> projectCounts =
-        updates.stream().map(u -> u.project).collect(toImmutableMultiset());
-    checkArgument(
-        projectCounts.entrySet().size() == updates.size(),
-        "updates must all be for different projects, got: %s",
-        projectCounts);
-  }
-
-  private static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
-    // Convert common non-REST exception types with user-visible messages to corresponding REST
-    // exception types.
-    if (e instanceof InvalidChangeOperationException || e instanceof LimitExceededException) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    } else if (e instanceof NoSuchChangeException
-        || e instanceof NoSuchRefException
-        || e instanceof NoSuchProjectException) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-    } else if (e instanceof CommentsRejectedException) {
-      // SC_BAD_REQUEST is not ideal because it's not a syntactic error, but there is no better
-      // status code and it's isolated in monitoring.
-      throw new BadRequestException(e.getMessage(), e);
-    }
-
-    Throwables.throwIfUnchecked(e);
-
-    // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
-    // ResourceConflictException to indicate an atomic update failure.
-    Throwables.throwIfInstanceOf(e, UpdateException.class);
-    Throwables.throwIfInstanceOf(e, RestApiException.class);
-
-    // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
-    throw new UpdateException(e);
-  }
-
   class ContextImpl implements Context {
     private final CurrentUser contextUser;
 
@@ -409,6 +284,7 @@
     DELETED
   }
 
+  private final BatchUpdates batchUpdates;
   private final GitRepositoryManager repoManager;
   private final AccountCache accountCache;
   private final ChangeData.Factory changeDataFactory;
@@ -445,6 +321,7 @@
 
   @Inject
   BatchUpdate(
+      BatchUpdates batchUpdates,
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverIdent,
       AccountCache accountCache,
@@ -461,6 +338,7 @@
       @Assisted CurrentUser user,
       @Assisted Instant when) {
     this.gerritConfig = gerritConfig;
+    this.batchUpdates = batchUpdates;
     this.repoManager = repoManager;
     this.accountCache = accountCache;
     this.changeDataFactory = changeDataFactory;
@@ -484,29 +362,35 @@
     }
   }
 
-  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), ImmutableList.of(listener), false);
+  @CanIgnoreReturnValue
+  public BatchUpdates.Result execute(BatchUpdateListener listener)
+      throws UpdateException, RestApiException {
+    return batchUpdates.execute(ImmutableList.of(this), ImmutableList.of(listener), false);
   }
 
-  public void execute() throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), ImmutableList.of(), false);
+  @CanIgnoreReturnValue
+  public BatchUpdates.Result execute() throws UpdateException, RestApiException {
+    return batchUpdates.execute(ImmutableList.of(this), ImmutableList.of(), false);
   }
 
   public boolean isExecuted() {
     return executed;
   }
 
+  @CanIgnoreReturnValue
   public BatchUpdate setRepository(Repository repo, RevWalk revWalk, ObjectInserter inserter) {
     checkState(this.repoView == null, "repo already set");
     repoView = new RepoView(repo, revWalk, inserter);
     return this;
   }
 
+  @CanIgnoreReturnValue
   public BatchUpdate setPushCertificate(@Nullable PushCertificate pushCert) {
     this.pushCert = pushCert;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public BatchUpdate setRefLogMessage(@Nullable String refLogMessage) {
     this.refLogMessage = refLogMessage;
     return this;
@@ -518,6 +402,7 @@
    * @param notify notification settings.
    * @return this.
    */
+  @CanIgnoreReturnValue
   public BatchUpdate setNotify(NotifyResolver.Result notify) {
     this.notify = requireNonNull(notify);
     return this;
@@ -533,6 +418,7 @@
    * @param notifyHandling notify handling.
    * @return this.
    */
+  @CanIgnoreReturnValue
   public BatchUpdate setNotifyHandling(Change.Id changeId, NotifyHandling notifyHandling) {
     this.perChangeNotifyHandling.put(changeId, requireNonNull(notifyHandling));
     return this;
@@ -542,6 +428,7 @@
    * Add a validation step for intended ref operations, which will be performed at the end of {@link
    * RepoOnlyOp#updateRepo(RepoContext)} step.
    */
+  @CanIgnoreReturnValue
   public BatchUpdate setOnSubmitValidators(OnSubmitValidators onSubmitValidators) {
     this.onSubmitValidators = onSubmitValidators;
     return this;
@@ -578,7 +465,7 @@
    */
   public Map<BranchNameKey, ReceiveCommand> getSuccessfullyUpdatedBranches(boolean dryrun) {
     return getRefUpdates().entrySet().stream()
-        .filter(entry -> dryrun || entry.getValue().getResult() == Result.OK)
+        .filter(entry -> dryrun || entry.getValue().getResult() == ReceiveCommand.Result.OK)
         .collect(
             toMap(entry -> BranchNameKey.create(project, entry.getKey()), Map.Entry::getValue));
   }
@@ -636,7 +523,7 @@
     return this;
   }
 
-  private void executeUpdateRepo() throws UpdateException, RestApiException {
+  void executeUpdateRepo() throws UpdateException, RestApiException {
     try {
       logDebug("Executing updateRepo on %d ops", ops.size());
       for (Map.Entry<Change.Id, OpData<BatchUpdateOp>> e : ops.entries()) {
@@ -678,7 +565,7 @@
         && gerritConfig.getBoolean("index", "indexChangesAsync", false);
   }
 
-  private void fireRefChangeEvents() {
+  void fireRefChangeEvents() {
     batchRefUpdate.forEach(
         (projectName, bru) -> gitRefUpdated.fire(projectName, bru, getAccount().orElse(null)));
   }
@@ -695,7 +582,7 @@
     }
   }
 
-  private class ChangesHandle implements AutoCloseable {
+  class ChangesHandle implements AutoCloseable {
     private final NoteDbUpdateManager manager;
     private final boolean dryrun;
     private final Map<Change.Id, ChangeResult> results;
@@ -780,7 +667,7 @@
     }
   }
 
-  private ChangesHandle executeChangeOps(
+  ChangesHandle executeChangeOps(
       ImmutableList<BatchUpdateListener> batchUpdateListeners, boolean dryrun) throws Exception {
     logDebug("Executing change ops");
     initRepository();
@@ -821,9 +708,11 @@
           ctx.distinctUpdates.values().forEach(changeUpdates::add);
           ctx = newChangeContext(opData.user(), id);
         }
+        Class<? extends BatchUpdateOp> opClass = opData.op().getClass();
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
-                opData.getClass().getSimpleName() + "#updateChange",
+                (opClass.isAnonymousClass() ? opClass.getName() : opClass.getSimpleName())
+                    + "#updateChange",
                 Metadata.builder().projectName(project.get()).changeId(id.get()).build())) {
           dirty |= opData.op().updateChange(ctx);
           deleted |= ctx.deleted;
@@ -908,7 +797,7 @@
     return new ChangeContextImpl(contextUser, notes);
   }
 
-  private void executePostOps(Map<Change.Id, ChangeData> changeDatas) throws Exception {
+  void executePostOps(Map<Change.Id, ChangeData> changeDatas) throws Exception {
     for (OpData<BatchUpdateOp> opData : ops.values()) {
       PostUpdateContextImpl ctx = new PostUpdateContextImpl(opData.user(), changeDatas);
       try (TraceContext.TraceTimer ignored =
diff --git a/java/com/google/gerrit/server/update/BatchUpdates.java b/java/com/google/gerrit/server/update/BatchUpdates.java
new file mode 100644
index 0000000..2f9ef84
--- /dev/null
+++ b/java/com/google/gerrit/server/update/BatchUpdates.java
@@ -0,0 +1,199 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toMap;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multiset;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.notedb.LimitExceededException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.NoSuchRefException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate.ChangesHandle;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+
+@Singleton
+public class BatchUpdates {
+  public class Result {
+    private final Map<Change.Id, ChangeData> changeDatas;
+
+    private Result() {
+      this(new HashMap<>());
+    }
+
+    private Result(Map<Change.Id, ChangeData> changeDatas) {
+      this.changeDatas = changeDatas;
+    }
+
+    /**
+     * Returns the updated {@link ChangeData} for the given project and change ID.
+     *
+     * <p>If the requested {@link ChangeData} was already loaded after the {@link BatchUpdate} has
+     * been executed the cached {@link ChangeData} instance is returned, otherwise the requested
+     * {@link ChangeData} is loaded and put into the cache.
+     */
+    public ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId) {
+      return changeDatas.computeIfAbsent(
+          changeId, id -> changeDataFactory.create(projectName, changeId));
+    }
+  }
+
+  private final ChangeData.Factory changeDataFactory;
+
+  @Inject
+  BatchUpdates(ChangeData.Factory changeDataFactory) {
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  @CanIgnoreReturnValue
+  public Result execute(
+      Collection<BatchUpdate> updates, ImmutableList<BatchUpdateListener> listeners, boolean dryrun)
+      throws UpdateException, RestApiException {
+    requireNonNull(listeners);
+    if (updates.isEmpty()) {
+      return new Result();
+    }
+
+    checkDifferentProject(updates);
+
+    try {
+      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>();
+      List<ChangesHandle> changesHandles = new ArrayList<>(updates.size());
+      try {
+        for (BatchUpdate u : updates) {
+          u.executeUpdateRepo();
+        }
+        notifyAfterUpdateRepo(listeners);
+        for (BatchUpdate u : updates) {
+          changesHandles.add(u.executeChangeOps(listeners, dryrun));
+        }
+        for (ChangesHandle h : changesHandles) {
+          h.execute();
+          if (h.requiresReindex()) {
+            indexFutures.addAll(h.startIndexFutures());
+          }
+        }
+        notifyAfterUpdateRefs(listeners);
+        notifyAfterUpdateChanges(listeners);
+      } finally {
+        for (ChangesHandle h : changesHandles) {
+          h.close();
+        }
+      }
+
+      Map<Change.Id, ChangeData> changeDatas =
+          Futures.allAsList(indexFutures).get().stream()
+              // filter out null values that were returned for change deletions
+              .filter(Objects::nonNull)
+              .collect(toMap(cd -> cd.change().getId(), Function.identity()));
+
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates.forEach(BatchUpdate::fireRefChangeEvents);
+
+      if (!dryrun) {
+        for (BatchUpdate u : updates) {
+          u.executePostOps(changeDatas);
+        }
+      }
+
+      return new Result(changeDatas);
+    } catch (Exception e) {
+      wrapAndThrowException(e);
+      return new Result();
+    }
+  }
+
+  private static void notifyAfterUpdateRepo(ImmutableList<BatchUpdateListener> listeners)
+      throws Exception {
+    for (BatchUpdateListener listener : listeners) {
+      listener.afterUpdateRepos();
+    }
+  }
+
+  private static void notifyAfterUpdateRefs(ImmutableList<BatchUpdateListener> listeners)
+      throws Exception {
+    for (BatchUpdateListener listener : listeners) {
+      listener.afterUpdateRefs();
+    }
+  }
+
+  private static void notifyAfterUpdateChanges(ImmutableList<BatchUpdateListener> listeners)
+      throws Exception {
+    for (BatchUpdateListener listener : listeners) {
+      listener.afterUpdateChanges();
+    }
+  }
+
+  private static void checkDifferentProject(Collection<BatchUpdate> updates) {
+    Multiset<Project.NameKey> projectCounts =
+        updates.stream().map(u -> u.getProject()).collect(toImmutableMultiset());
+    checkArgument(
+        projectCounts.entrySet().size() == updates.size(),
+        "updates must all be for different projects, got: %s",
+        projectCounts);
+  }
+
+  private static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
+    // Convert common non-REST exception types with user-visible messages to corresponding REST
+    // exception types.
+    if (e instanceof InvalidChangeOperationException || e instanceof LimitExceededException) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    } else if (e instanceof NoSuchChangeException
+        || e instanceof NoSuchRefException
+        || e instanceof NoSuchProjectException) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    } else if (e instanceof CommentsRejectedException) {
+      // SC_BAD_REQUEST is not ideal because it's not a syntactic error, but there is no better
+      // status code and it's isolated in monitoring.
+      throw new BadRequestException(e.getMessage(), e);
+    }
+
+    Throwables.throwIfUnchecked(e);
+
+    // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
+    // ResourceConflictException to indicate an atomic update failure.
+    Throwables.throwIfInstanceOf(e, UpdateException.class);
+    Throwables.throwIfInstanceOf(e, RestApiException.class);
+
+    // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
+    throw new UpdateException(e);
+  }
+}
diff --git a/java/com/google/gerrit/server/update/RepoView.java b/java/com/google/gerrit/server/update/RepoView.java
index da9b083..7e861b6 100644
--- a/java/com/google/gerrit/server/update/RepoView.java
+++ b/java/com/google/gerrit/server/update/RepoView.java
@@ -227,5 +227,10 @@
     public void close() {
       // Do nothing; the delegate is closed separately.
     }
+
+    @Override
+    public String toString() {
+      return String.format("%s (wrapped inserter: %s)", super.toString(), delegate.toString());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index 7e6974c..c5621ed 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.query.group.InternalGroupQuery;
 import com.google.gerrit.server.update.RetryableAction.Action;
 import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.gerrit.server.update.RetryableChangeAction.ChangeAction;
@@ -188,6 +189,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final Provider<InternalAccountQuery> internalAccountQuery;
   private final Provider<InternalChangeQuery> internalChangeQuery;
+  private final Provider<InternalGroupQuery> internalGroupQuery;
   private final PluginSetContext<ExceptionHook> exceptionHooks;
   private final Duration defaultTimeout;
   private final Map<String, Duration> defaultTimeouts;
@@ -202,13 +204,15 @@
       PluginSetContext<ExceptionHook> exceptionHooks,
       BatchUpdate.Factory updateFactory,
       Provider<InternalAccountQuery> internalAccountQuery,
-      Provider<InternalChangeQuery> internalChangeQuery) {
+      Provider<InternalChangeQuery> internalChangeQuery,
+      Provider<InternalGroupQuery> internalGroupQuery) {
     this(
         cfg,
         metrics,
         updateFactory,
         internalAccountQuery,
         internalChangeQuery,
+        internalGroupQuery,
         exceptionHooks,
         null);
   }
@@ -220,6 +224,7 @@
       BatchUpdate.Factory updateFactory,
       Provider<InternalAccountQuery> internalAccountQuery,
       Provider<InternalChangeQuery> internalChangeQuery,
+      Provider<InternalGroupQuery> internalGroupQuery,
       PluginSetContext<ExceptionHook> exceptionHooks,
       @Nullable Consumer<RetryerBuilder<?>> overwriteDefaultRetryerStrategySetup) {
     this.cfg = cfg;
@@ -227,6 +232,7 @@
     this.updateFactory = updateFactory;
     this.internalAccountQuery = internalAccountQuery;
     this.internalChangeQuery = internalChangeQuery;
+    this.internalGroupQuery = internalGroupQuery;
     this.exceptionHooks = exceptionHooks;
     this.defaultTimeout =
         Duration.ofMillis(
@@ -386,6 +392,22 @@
   }
 
   /**
+   * Creates an action for querying the group index that is executed with retrying when called.
+   *
+   * <p>The index query action gets a {@link InternalGroupQuery} provided that can be used to query
+   * the account index.
+   *
+   * @param actionName the name of the action, used as metric bucket
+   * @param indexQueryAction the action that should be executed
+   * @return the retryable action, callers need to call {@link RetryableIndexQueryAction#call()} to
+   *     execute the action
+   */
+  public <T> RetryableIndexQueryAction<InternalGroupQuery, T> groupIndexQuery(
+      String actionName, IndexQueryAction<T, InternalGroupQuery> indexQueryAction) {
+    return new RetryableIndexQueryAction<>(this, internalGroupQuery, actionName, indexQueryAction);
+  }
+
+  /**
    * Returns the default timeout for an action type.
    *
    * <p>The default timeout for an action type is defined by the 'retry.<action-type>.timeout'
@@ -454,17 +476,30 @@
               actionType,
               opts,
               t -> {
+                String actionName = opts.actionName().orElse("N/A");
+                String cause = formatCause(t);
+
+                // Do not retry if retrying was already done and failed.
+                if (Throwables.getCausalChain(t).stream()
+                    .anyMatch(RetryException.class::isInstance)) {
+                  return false;
+                }
+
                 // exceptionPredicate checks for temporary errors for which the operation should be
                 // retried (e.g. LockFailure). The retry has good chances to succeed.
                 if (exceptionPredicate.test(t)) {
+                  logger.atFine().withCause(t).log(
+                      "Retry: %s failed with possibly temporary error (cause = %s)",
+                      actionName, cause);
                   return true;
                 }
 
-                String actionName = opts.actionName().orElse("N/A");
-
                 // Exception hooks may identify additional exceptions for retry.
                 if (exceptionHooks.stream()
                     .anyMatch(h -> h.shouldRetry(actionType, actionName, t))) {
+                  logger.atFine().withCause(t).log(
+                      "Retry: %s failed with possibly temporary error (cause = %s)",
+                      actionName, cause);
                   return true;
                 }
 
@@ -480,7 +515,6 @@
                     return false;
                   }
 
-                  String cause = formatCause(t);
                   if (!TraceContext.isTracing()) {
                     String traceId = "retry-on-failure-" + new RequestId();
                     traceContext.addTag(RequestId.Type.TRACE_ID, traceId).forceLogging();
@@ -568,6 +602,9 @@
             actionType,
             opts.actionName().orElse("N/A"),
             listener.getOriginalCause().map(this::formatCause).orElse("_unknown"));
+
+        // Re-throw the RetryException so that retrying is not re-attempted on an outer level.
+        throw e;
       }
       if (e.getCause() != null) {
         Throwables.throwIfUnchecked(e.getCause());
diff --git a/java/com/google/gerrit/server/update/RetryableAction.java b/java/com/google/gerrit/server/update/RetryableAction.java
index f79a849..1a713d2 100644
--- a/java/com/google/gerrit/server/update/RetryableAction.java
+++ b/java/com/google/gerrit/server/update/RetryableAction.java
@@ -18,7 +18,11 @@
 
 import com.github.rholder.retry.RetryListener;
 import com.google.common.base.Throwables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.server.ExceptionHook;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.function.Consumer;
@@ -71,6 +75,8 @@
   private final RetryHelper.Options.Builder options = RetryHelper.options();
   private final List<Predicate<Throwable>> exceptionPredicates = new ArrayList<>();
 
+  private int numberOfCalls;
+
   RetryableAction(
       RetryHelper retryHelper, ActionType actionType, String actionName, Action<T> action) {
     this(retryHelper, requireNonNull(actionType, "actionType").name(), actionName, action);
@@ -79,7 +85,15 @@
   RetryableAction(RetryHelper retryHelper, String actionType, String actionName, Action<T> action) {
     this.retryHelper = requireNonNull(retryHelper, "retryHelper");
     this.actionType = requireNonNull(actionType, "actionType");
-    this.action = requireNonNull(action, "action");
+    this.action =
+        () -> {
+          numberOfCalls++;
+          try (TraceTimer timer =
+              TraceContext.newTimer(
+                  actionName, Metadata.builder().attempt(numberOfCalls).build())) {
+            return requireNonNull(action, "action").call();
+          }
+        };
     options.actionName(requireNonNull(actionName, "actionName"));
   }
 
@@ -97,6 +111,7 @@
    *     exception
    * @return this instance to enable chaining of calls
    */
+  @CanIgnoreReturnValue
   public RetryableAction<T> retryOn(Predicate<Throwable> exceptionPredicate) {
     exceptionPredicates.add(exceptionPredicate);
     return this;
@@ -117,6 +132,7 @@
    *     for a given exception
    * @return this instance to enable chaining of calls
    */
+  @CanIgnoreReturnValue
   public RetryableAction<T> retryWithTrace(Predicate<Throwable> exceptionPredicate) {
     options.retryWithTrace(exceptionPredicate);
     return this;
@@ -132,6 +148,7 @@
    * @param traceIdConsumer trace ID consumer
    * @return this instance to enable chaining of calls
    */
+  @CanIgnoreReturnValue
   public RetryableAction<T> onAutoTrace(Consumer<String> traceIdConsumer) {
     options.onAutoTrace(traceIdConsumer);
     return this;
@@ -145,6 +162,7 @@
    * @param retryListener retry listener
    * @return this instance to enable chaining of calls
    */
+  @CanIgnoreReturnValue
   public RetryableAction<T> listener(RetryListener retryListener) {
     options.listener(retryListener);
     return this;
@@ -158,6 +176,7 @@
    * @param multiplier multiplier for the default timeout
    * @return this instance to enable chaining of calls
    */
+  @CanIgnoreReturnValue
   public RetryableAction<T> defaultTimeoutMultiplier(int multiplier) {
     options.timeout(retryHelper.getDefaultTimeout(actionType).multipliedBy(multiplier));
     return this;
diff --git a/java/com/google/gerrit/server/update/SubmissionExecutor.java b/java/com/google/gerrit/server/update/SubmissionExecutor.java
index 39eda58..762de57 100644
--- a/java/com/google/gerrit/server/update/SubmissionExecutor.java
+++ b/java/com/google/gerrit/server/update/SubmissionExecutor.java
@@ -22,12 +22,16 @@
 import java.util.stream.Collectors;
 
 public class SubmissionExecutor {
-
+  private final BatchUpdates batchUpdates;
   private final ImmutableList<SubmissionListener> submissionListeners;
   private final boolean dryrun;
   private ImmutableList<BatchUpdateListener> additionalListeners = ImmutableList.of();
 
-  public SubmissionExecutor(boolean dryrun, ImmutableList<SubmissionListener> submissionListeners) {
+  public SubmissionExecutor(
+      BatchUpdates batchUpdates,
+      boolean dryrun,
+      ImmutableList<SubmissionListener> submissionListeners) {
+    this.batchUpdates = batchUpdates;
     this.dryrun = dryrun;
     this.submissionListeners = submissionListeners;
     if (dryrun) {
@@ -58,7 +62,7 @@
                     .map(Optional::get)
                     .collect(Collectors.toList()))
             .build();
-    BatchUpdate.execute(updates, listeners, dryrun);
+    batchUpdates.execute(updates, listeners, dryrun);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/update/context/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/update/context/package-info.java
index 0709b86..2637a62 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/update/context/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.update.context;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/update/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/update/package-info.java
index 0709b86..b80bffd 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/update/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.update;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 82e5bd1..95fc246 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -179,7 +179,8 @@
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", changeId);
       } finally {
-        requestContext.setContext(old);
+        @SuppressWarnings("unused")
+        var unused = requestContext.setContext(old);
       }
     }
 
diff --git a/java/com/google/gerrit/server/util/LabelVote.java b/java/com/google/gerrit/server/util/LabelVote.java
index fbcf3ce..a227d7a 100644
--- a/java/com/google/gerrit/server/util/LabelVote.java
+++ b/java/com/google/gerrit/server/util/LabelVote.java
@@ -18,6 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
 
@@ -56,6 +57,7 @@
     return create(text.substring(0, e), Short.parseShort(text.substring(e + 1)));
   }
 
+  @CanIgnoreReturnValue
   public static StringBuilder appendTo(StringBuilder sb, String label, short value) {
     if (value == (short) 0) {
       return sb.append('-').append(label);
diff --git a/java/com/google/gerrit/server/util/ManualRequestContext.java b/java/com/google/gerrit/server/util/ManualRequestContext.java
index 7790b5f..2873c57 100644
--- a/java/com/google/gerrit/server/util/ManualRequestContext.java
+++ b/java/com/google/gerrit/server/util/ManualRequestContext.java
@@ -42,6 +42,7 @@
 
   @Override
   public void close() {
-    requestContext.setContext(old);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(old);
   }
 }
diff --git a/java/com/google/gerrit/server/util/RequestScopePropagator.java b/java/com/google/gerrit/server/util/RequestScopePropagator.java
index 2f03b07..acdafee 100644
--- a/java/com/google/gerrit/server/util/RequestScopePropagator.java
+++ b/java/com/google/gerrit/server/util/RequestScopePropagator.java
@@ -173,7 +173,8 @@
       try {
         return callable.call();
       } finally {
-        local.setContext(old);
+        @SuppressWarnings("unused")
+        var unused = local.setContext(old);
       }
     };
   }
diff --git a/java/com/google/gerrit/server/util/git/BUILD b/java/com/google/gerrit/server/util/git/BUILD
index 83a230d..7046382 100644
--- a/java/com/google/gerrit/server/util/git/BUILD
+++ b/java/com/google/gerrit/server/util/git/BUILD
@@ -8,6 +8,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/util/git/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/util/git/package-info.java
index 0709b86..3b98bfa 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/util/git/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.util.git;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/util/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/util/package-info.java
index 0709b86..ae74688 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/util/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.util;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/util/time/BUILD b/java/com/google/gerrit/server/util/time/BUILD
index f3e0091..7fd6ecf 100644
--- a/java/com/google/gerrit/server/util/time/BUILD
+++ b/java/com/google/gerrit/server/util/time/BUILD
@@ -8,5 +8,6 @@
         "//java/com/google/gerrit/server/util/git",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/util/time/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/util/time/package-info.java
index 0709b86..a0ac845 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/util/time/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.util.time;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/validators/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/validators/package-info.java
index 0709b86..f677a65 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/validators/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.validators;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/version/BUILD b/java/com/google/gerrit/server/version/BUILD
index c7f659c..1176e33 100644
--- a/java/com/google/gerrit/server/version/BUILD
+++ b/java/com/google/gerrit/server/version/BUILD
@@ -12,6 +12,7 @@
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/schema",
+        "//lib/errorprone:annotations",
         "@guice-library//jar",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/version/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/server/version/package-info.java
index 0709b86..fd5d18c 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/version/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.server.version;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index 7a11131c..0a97549 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -37,6 +37,7 @@
         "//lib/auto:auto-value-annotations",
         "//lib/bouncycastle:bcprov-neverlink",
         "//lib/dropwizard:dropwizard-core",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/sshd/CommandModule.java b/java/com/google/gerrit/sshd/CommandModule.java
index 4242c71..7becc2b 100644
--- a/java/com/google/gerrit/sshd/CommandModule.java
+++ b/java/com/google/gerrit/sshd/CommandModule.java
@@ -21,7 +21,11 @@
 
 /** Module to register commands in the SSH daemon. */
 public abstract class CommandModule extends LifecycleModule {
-  protected boolean slaveMode;
+  protected final boolean slaveMode;
+
+  protected CommandModule(boolean slaveMode) {
+    this.slaveMode = slaveMode;
+  }
 
   /**
    * Configure a command to be invoked by name.
diff --git a/java/com/google/gerrit/sshd/PluginCommandModule.java b/java/com/google/gerrit/sshd/PluginCommandModule.java
index f0dc17a..9fd068c 100644
--- a/java/com/google/gerrit/sshd/PluginCommandModule.java
+++ b/java/com/google/gerrit/sshd/PluginCommandModule.java
@@ -14,24 +14,19 @@
 
 package com.google.gerrit.sshd;
 
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.gerrit.extensions.annotations.PluginName;
-import com.google.inject.Inject;
 import com.google.inject.binder.LinkedBindingBuilder;
 import org.apache.sshd.server.command.Command;
 
 public abstract class PluginCommandModule extends CommandModule {
-  private CommandName command;
+  private final CommandName command;
 
-  @Inject
-  void setPluginName(@PluginName String name) {
-    this.command = Commands.named(name);
+  public PluginCommandModule(String pluginName) {
+    super(/* slaveMode= */ false);
+    this.command = Commands.named(pluginName);
   }
 
   @Override
   protected final void configure() {
-    checkState(command != null, "@PluginName must be provided");
     bind(Commands.key(command)).toProvider(new DispatchCommandProvider(command));
     configureCommands();
   }
diff --git a/java/com/google/gerrit/sshd/SingleCommandPluginModule.java b/java/com/google/gerrit/sshd/SingleCommandPluginModule.java
index edc797c..4e16fbc 100644
--- a/java/com/google/gerrit/sshd/SingleCommandPluginModule.java
+++ b/java/com/google/gerrit/sshd/SingleCommandPluginModule.java
@@ -14,10 +14,6 @@
 
 package com.google.gerrit.sshd;
 
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.gerrit.extensions.annotations.PluginName;
-import com.google.inject.Inject;
 import com.google.inject.binder.LinkedBindingBuilder;
 import org.apache.sshd.server.command.Command;
 
@@ -27,16 +23,15 @@
  * <p>Cannot be combined with {@link PluginCommandModule}.
  */
 public abstract class SingleCommandPluginModule extends CommandModule {
-  private CommandName command;
+  private final CommandName command;
 
-  @Inject
-  void setPluginName(@PluginName String name) {
-    this.command = Commands.named(name);
+  public SingleCommandPluginModule(String pluginName) {
+    super(/* slaveMode= */ false);
+    this.command = Commands.named(pluginName);
   }
 
   @Override
   protected final void configure() {
-    checkState(command != null, "@PluginName must be provided");
     configure(bind(Commands.key(command)));
   }
 
diff --git a/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java b/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
index 7adcd24..8c37ca3 100644
--- a/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
+++ b/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
@@ -31,6 +31,7 @@
 import java.util.Map;
 import org.apache.sshd.server.command.Command;
 
+@SuppressWarnings("MutableGuiceModule")
 class SshAutoRegisterModuleGenerator extends AbstractModule implements ModuleGenerator {
   private final Map<String, Class<Command>> commands = new HashMap<>();
   private final ListMultimap<TypeLiteral<?>, Class<?>> listeners = LinkedListMultimap.create();
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index 58e331b..ff452a6 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -154,7 +154,7 @@
         } else {
           markInvalid(k);
         }
-      } catch (Throwable e) {
+      } catch (Exception e) {
         markInvalid(k);
       }
     }
diff --git a/java/com/google/gerrit/sshd/SshModule.java b/java/com/google/gerrit/sshd/SshModule.java
index ca452c1..ca15ba7 100644
--- a/java/com/google/gerrit/sshd/SshModule.java
+++ b/java/com/google/gerrit/sshd/SshModule.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -39,7 +40,6 @@
 import com.google.inject.internal.UniqueAnnotations;
 import com.google.inject.servlet.RequestScoped;
 import java.net.SocketAddress;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
@@ -50,14 +50,15 @@
 
 /** Configures standard dependencies for {@link SshDaemon}. */
 public class SshModule extends LifecycleModule {
-  private final Map<String, String> aliases;
+  private final ImmutableMap<String, String> aliases;
 
   @Inject
   SshModule(@GerritServerConfig Config cfg) {
-    aliases = new HashMap<>();
+    ImmutableMap.Builder<String, String> aliasesBuilder = ImmutableMap.builder();
     for (String name : cfg.getNames("ssh-alias", true)) {
-      aliases.put(name, cfg.getString("ssh-alias", null, name));
+      aliasesBuilder.put(name, cfg.getString("ssh-alias", null, name));
     }
+    this.aliases = aliasesBuilder.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/sshd/SshScope.java b/java/com/google/gerrit/sshd/SshScope.java
index f19c395..9ab63a4 100644
--- a/java/com/google/gerrit/sshd/SshScope.java
+++ b/java/com/google/gerrit/sshd/SshScope.java
@@ -214,7 +214,10 @@
   Context set(Context ctx) {
     Context old = current.get();
     current.set(ctx);
-    local.setContext(ctx);
+
+    @SuppressWarnings("unused")
+    var unused = local.setContext(ctx);
+
     return old;
   }
 
diff --git a/java/com/google/gerrit/sshd/commands/CheckProjectAccessCommand.java b/java/com/google/gerrit/sshd/commands/CheckProjectAccessCommand.java
index 39f9ef2..76406ef 100644
--- a/java/com/google/gerrit/sshd/commands/CheckProjectAccessCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CheckProjectAccessCommand.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.sshd.commands;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -31,8 +33,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.util.Set;
-import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.kohsuke.args4j.Option;
 
@@ -96,14 +96,14 @@
     }
   }
 
-  private Set<IdentifiedUser> getUserList(String userName)
+  private ImmutableSet<IdentifiedUser> getUserList(String userName)
       throws ConfigInvalidException, IOException, ResourceNotFoundException {
-    return getIdList(userName).stream().map(userFactory::create).collect(Collectors.toSet());
+    return getIdList(userName).stream().map(userFactory::create).collect(toImmutableSet());
   }
 
-  private Set<Account.Id> getIdList(String userName)
+  private ImmutableSet<Account.Id> getIdList(String userName)
       throws ConfigInvalidException, IOException, ResourceNotFoundException {
-    Set<Account.Id> idList = accountResolver.resolve(userName).asIdSet();
+    ImmutableSet<Account.Id> idList = accountResolver.resolve(userName).asIdSet();
     if (idList.isEmpty()) {
       throw new ResourceNotFoundException(
           "No accounts found for your query: \""
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index 2e29203..660b0df 100644
--- a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -82,7 +82,9 @@
     input.httpPassword = httpPassword;
     input.groups = Lists.transform(groups, AccountGroup.Id::toString);
     try {
-      createAccount.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(username), input);
+      @SuppressWarnings("unused")
+      var unused =
+          createAccount.apply(TopLevelResource.INSTANCE, IdString.fromDecoded(username), input);
     } catch (RestApiException e) {
       throw die(e.getMessage());
     }
diff --git a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 5fd2297..1f65c56 100644
--- a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -142,7 +142,9 @@
     AddMembers.Input input =
         AddMembers.Input.fromMembers(
             initialMembers.stream().map(Object::toString).collect(toList()));
-    addMembers.apply(rsrc, input);
+
+    @SuppressWarnings("unused")
+    var unused = addMembers.apply(rsrc, input);
   }
 
   private void addSubgroups(GroupResource rsrc)
@@ -150,6 +152,8 @@
     AddSubgroups.Input input =
         AddSubgroups.Input.fromGroups(
             initialGroups.stream().map(AccountGroup.UUID::get).collect(toList()));
-    addSubgroups.apply(rsrc, input);
+
+    @SuppressWarnings("unused")
+    var unused = addSubgroups.apply(rsrc, input);
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index 42e7c0f..ce8c265 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -29,8 +29,8 @@
   private final LfsPluginAuthCommandModule lfsPluginAuthModule;
 
   public DefaultCommandModule(
-      boolean slave, DownloadConfig downloadCfg, LfsPluginAuthCommandModule module) {
-    slaveMode = slave;
+      boolean slaveMode, DownloadConfig downloadCfg, LfsPluginAuthCommandModule module) {
+    super(slaveMode);
     downloadConfig = downloadCfg;
     lfsPluginAuthModule = module;
   }
diff --git a/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java b/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java
index 8b025d3..b0ecdfc 100644
--- a/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java
+++ b/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java
@@ -24,6 +24,9 @@
 import java.util.concurrent.ExecutorService;
 
 public class ExternalIdCommandsModule extends CommandModule {
+  public ExternalIdCommandsModule() {
+    super(/* slaveMode= */ false);
+  }
 
   @Override
   protected void configure() {
diff --git a/java/com/google/gerrit/sshd/commands/FlushCaches.java b/java/com/google/gerrit/sshd/commands/FlushCaches.java
index fe2a897..bfd64f6 100644
--- a/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -75,9 +75,11 @@
       }
 
       if (all) {
-        postCaches.apply(new ConfigResource(), new PostCaches.Input(FLUSH_ALL));
+        @SuppressWarnings("unused")
+        var unused = postCaches.apply(new ConfigResource(), new PostCaches.Input(FLUSH_ALL));
       } else {
-        postCaches.apply(new ConfigResource(), new PostCaches.Input(FLUSH, caches));
+        @SuppressWarnings("unused")
+        var unused = postCaches.apply(new ConfigResource(), new PostCaches.Input(FLUSH, caches));
       }
     } catch (RestApiException e) {
       throw die(e.getMessage());
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index 1fb0e13..40d8af3 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -56,7 +56,8 @@
     boolean ok = true;
     for (ChangeResource rsrc : changes.values()) {
       try {
-        index.apply(rsrc, new Input());
+        @SuppressWarnings("unused")
+        var unused = index.apply(rsrc, new Input());
       } catch (Exception e) {
         ok = false;
         writeError(
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
index 168dc19..c7a03c4 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
@@ -52,7 +52,8 @@
 
   private void index(ProjectState projectState) {
     try {
-      index.apply(new ProjectResource(projectState, user), null);
+      @SuppressWarnings("unused")
+      var unused = index.apply(new ProjectResource(projectState, user), null);
     } catch (Exception e) {
       writeError(
           "error", String.format("Unable to index %s: %s", projectState.getName(), e.getMessage()));
diff --git a/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java b/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
index 332ed69..b3268c5 100644
--- a/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
+++ b/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
@@ -27,6 +27,7 @@
   private final Injector injector;
 
   public IndexCommandsModule(Injector injector) {
+    super(/* slaveMode= */ false);
     this.injector = injector;
   }
 
diff --git a/java/com/google/gerrit/sshd/commands/KillCommand.java b/java/com/google/gerrit/sshd/commands/KillCommand.java
index a633a8a..ec63d9d 100644
--- a/java/com/google/gerrit/sshd/commands/KillCommand.java
+++ b/java/com/google/gerrit/sshd/commands/KillCommand.java
@@ -52,7 +52,9 @@
     for (String id : taskIds) {
       try {
         TaskResource taskRsrc = tasksCollection.parse(cfgRsrc, IdString.fromDecoded(id));
-        deleteTask.apply(taskRsrc, null);
+
+        @SuppressWarnings("unused")
+        var unused = deleteTask.apply(taskRsrc, null);
       } catch (AuthException
           | ResourceNotFoundException
           | ResourceConflictException
diff --git a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
index 742536c..3111c28 100644
--- a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
@@ -16,11 +16,12 @@
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterators;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import java.util.Collections;
 import java.util.Map;
 import java.util.TreeMap;
 import org.apache.log4j.LogManager;
@@ -52,7 +53,7 @@
   }
 
   @SuppressWarnings({"unchecked", "JdkObsolete"})
-  private static Iterable<Logger> getCurrentLoggers() {
-    return Collections.list(LogManager.getCurrentLoggers());
+  private static ImmutableList<Logger> getCurrentLoggers() {
+    return ImmutableList.copyOf(Iterators.forEnumeration(LogManager.getCurrentLoggers()));
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ReloadConfig.java b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
index eeb48bb..73b262b 100644
--- a/java/com/google/gerrit/sshd/commands/ReloadConfig.java
+++ b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
-import com.google.common.collect.Multimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.config.ConfigUpdatedEvent.ConfigUpdateEntry;
@@ -39,7 +39,8 @@
   @Override
   protected void run() throws Failure {
     enableGracefulStop();
-    Multimap<UpdateResult, ConfigUpdateEntry> updates = gerritServerConfigReloader.reloadConfig();
+    ListMultimap<UpdateResult, ConfigUpdateEntry> updates =
+        gerritServerConfigReloader.reloadConfig();
     if (updates.isEmpty()) {
       stdout.println("No config entries updated!");
       return;
diff --git a/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java b/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
index 976e7bd..23bec1d 100644
--- a/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
@@ -51,7 +51,9 @@
       GroupResource rsrc = groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName));
       NameInput input = new NameInput();
       input.name = newGroupName;
-      putName.apply(rsrc, input);
+
+      @SuppressWarnings("unused")
+      var unused = putName.apply(rsrc, input);
     } catch (RestApiException | IOException | ConfigInvalidException e) {
       throw die(e);
     }
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index e004940..95f771c 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -29,11 +29,13 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.DynamicOptions;
@@ -56,11 +58,13 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
+import java.util.stream.Collectors;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.Option;
@@ -249,15 +253,40 @@
   }
 
   private void applyReview(PatchSet patchSet, ReviewInput review) throws Exception {
-    retryHelper
-        .action(
-            ActionType.CHANGE_UPDATE,
-            "applyReview",
-            () -> {
-              getRevisionApi(patchSet).review(review);
-              return null;
-            })
-        .call();
+    Changes changesApi = gApi.changes();
+    int changeNumber = patchSet.id().changeId().get();
+    String projectName;
+    if (projectState == null) {
+      logger.atWarning().log(
+          "Deprecated usage of review command: missing project for change number %d, patchset %d",
+          changeNumber, patchSet.number());
+      List<ChangeInfo> changeInfos = changesApi.query("change: " + changeNumber).get();
+      if (changeInfos.size() > 1) {
+        throw die(
+            String.format(
+                "Multiple changes (%d) found for change number %d in projects: %s",
+                changeInfos.size(),
+                changeNumber,
+                changeInfos.stream().map(ci -> ci.project).collect(Collectors.joining(", "))));
+      }
+      projectName = changeInfos.get(0).project;
+    } else {
+      projectName = projectState.getProject().getName();
+    }
+    @SuppressWarnings("unused")
+    var unused =
+        retryHelper
+            .action(
+                ActionType.CHANGE_UPDATE,
+                "applyReview",
+                () -> {
+                  changesApi
+                      .id(projectName, changeNumber)
+                      .revision(patchSet.number())
+                      .review(review);
+                  return null;
+                })
+            .call();
   }
 
   private ReviewInput reviewFromJson() throws UnloggedFailure {
diff --git a/java/com/google/gerrit/sshd/commands/SequenceCommandsModule.java b/java/com/google/gerrit/sshd/commands/SequenceCommandsModule.java
index e716240..48147a5 100644
--- a/java/com/google/gerrit/sshd/commands/SequenceCommandsModule.java
+++ b/java/com/google/gerrit/sshd/commands/SequenceCommandsModule.java
@@ -20,6 +20,9 @@
 import com.google.gerrit.sshd.DispatchCommandProvider;
 
 public class SequenceCommandsModule extends CommandModule {
+  public SequenceCommandsModule() {
+    super(/* slaveMode= */ false);
+  }
 
   @Override
   protected void configure() {
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 0c286ca..4b8b8d1 100644
--- a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -247,7 +247,8 @@
       if (fullName != null) {
         NameInput in = new NameInput();
         in.name = fullName;
-        putName.apply(rsrc, in);
+        @SuppressWarnings("unused")
+        var unused = putName.apply(rsrc, in);
       }
 
       if (httpPassword != null || clearHttpPassword || generateHttpPassword) {
@@ -263,10 +264,12 @@
       }
 
       if (active) {
-        putActive.apply(rsrc, null);
+        @SuppressWarnings("unused")
+        var unused = putActive.apply(rsrc, null);
       } else if (inactive) {
         try {
-          deleteActive.apply(rsrc, null);
+          @SuppressWarnings("unused")
+          var unused = deleteActive.apply(rsrc, null);
         } catch (ResourceNotFoundException e) {
           // user is already inactive
         }
@@ -297,7 +300,9 @@
     for (String sshKey : sshKeys) {
       SshKeyInput in = new SshKeyInput();
       in.raw = RawInputUtil.create(sshKey.getBytes(UTF_8), "text/plain");
-      addSshKey.apply(rsrc, in);
+
+      @SuppressWarnings("unused")
+      var unused = addSshKey.apply(rsrc, in);
     }
   }
 
@@ -322,7 +327,10 @@
       throws AuthException, RepositoryNotFoundException, IOException, ConfigInvalidException,
           PermissionBackendException {
     AccountSshKey sshKey = AccountSshKey.create(user.getAccountId(), i.seq, i.sshPublicKey);
-    deleteSshKey.apply(new AccountResource.SshKey(user.asIdentifiedUser(), sshKey), null);
+
+    @SuppressWarnings("unused")
+    var unused =
+        deleteSshKey.apply(new AccountResource.SshKey(user.asIdentifiedUser(), sshKey), null);
   }
 
   private void addEmail(String email)
@@ -332,7 +340,8 @@
     in.email = email;
     in.noConfirmation = true;
     try {
-      createEmail.apply(rsrc, IdString.fromDecoded(email), in);
+      @SuppressWarnings("unused")
+      var unused = createEmail.apply(rsrc, IdString.fromDecoded(email), in);
     } catch (EmailException e) {
       throw die(e.getMessage());
     }
@@ -342,17 +351,24 @@
     if (email.equals("ALL")) {
       List<EmailInfo> emails = getEmails.apply(rsrc).value();
       for (EmailInfo e : emails) {
-        deleteEmail.apply(new AccountResource.Email(user.asIdentifiedUser(), e.email), new Input());
+        @SuppressWarnings("unused")
+        var unused =
+            deleteEmail.apply(
+                new AccountResource.Email(user.asIdentifiedUser(), e.email), new Input());
       }
     } else {
-      deleteEmail.apply(new AccountResource.Email(user.asIdentifiedUser(), email), new Input());
+      @SuppressWarnings("unused")
+      var unused =
+          deleteEmail.apply(new AccountResource.Email(user.asIdentifiedUser(), email), new Input());
     }
   }
 
   private void putPreferred(String email) throws Exception {
     for (EmailInfo e : getEmails.apply(rsrc).value()) {
       if (e.email.equals(email)) {
-        putPreferred.apply(new AccountResource.Email(user.asIdentifiedUser(), email), null);
+        @SuppressWarnings("unused")
+        var unused =
+            putPreferred.apply(new AccountResource.Email(user.asIdentifiedUser(), email), null);
         return;
       }
     }
@@ -390,6 +406,8 @@
     } else {
       ids = Collections.singletonList(externalId);
     }
-    deleteExternalIds.apply(rsrc, ids);
+
+    @SuppressWarnings("unused")
+    var unused = deleteExternalIds.apply(rsrc, ids);
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/SetHeadCommand.java b/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
index b6d283e..5624252 100644
--- a/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
@@ -47,7 +47,8 @@
     HeadInput input = new HeadInput();
     input.ref = newHead;
     try {
-      setHead.apply(new ProjectResource(project, user), input);
+      @SuppressWarnings("unused")
+      var unused = setHead.apply(new ProjectResource(project, user), input);
     } catch (UnprocessableEntityException e) {
       throw die(e);
     }
diff --git a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
index 4d16da6..8395772 100644
--- a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
@@ -17,13 +17,14 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterators;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import java.net.MalformedURLException;
 import java.net.URL;
-import java.util.Collections;
 import org.apache.log4j.Level;
 import org.apache.log4j.LogManager;
 import org.apache.log4j.Logger;
@@ -86,7 +87,7 @@
   }
 
   @SuppressWarnings({"unchecked", "JdkObsolete"})
-  private static Iterable<Logger> getCurrentLoggers() {
-    return Collections.list(LogManager.getCurrentLoggers());
+  private static ImmutableList<Logger> getCurrentLoggers() {
+    return ImmutableList.copyOf(Iterators.forEnumeration(LogManager.getCurrentLoggers()));
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index f788f14..a8894eb 100644
--- a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -108,19 +108,27 @@
         GroupResource resource =
             groupsCollection.parse(TopLevelResource.INSTANCE, IdString.fromUrl(groupUuid.get()));
         if (!accountsToRemove.isEmpty()) {
-          deleteMembers.apply(resource, fromMembers(accountsToRemove));
+          @SuppressWarnings("unused")
+          var unused = deleteMembers.apply(resource, fromMembers(accountsToRemove));
+
           reportMembersAction("removed from", resource, accountsToRemove);
         }
         if (!groupsToRemove.isEmpty()) {
-          deleteSubgroups.apply(resource, fromGroups(groupsToRemove));
+          @SuppressWarnings("unused")
+          var unused = deleteSubgroups.apply(resource, fromGroups(groupsToRemove));
+
           reportGroupsAction("excluded from", resource, groupsToRemove);
         }
         if (!accountsToAdd.isEmpty()) {
-          addMembers.apply(resource, fromMembers(accountsToAdd));
+          @SuppressWarnings("unused")
+          var unused = addMembers.apply(resource, fromMembers(accountsToAdd));
+
           reportMembersAction("added to", resource, accountsToAdd);
         }
         if (!groupsToInclude.isEmpty()) {
-          addSubgroups.apply(resource, fromGroups(groupsToInclude));
+          @SuppressWarnings("unused")
+          var unused = addSubgroups.apply(resource, fromGroups(groupsToInclude));
+
           reportGroupsAction("included to", resource, groupsToInclude);
         }
       }
diff --git a/java/com/google/gerrit/sshd/commands/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
index d23f7fa..712715e 100644
--- a/java/com/google/gerrit/sshd/commands/SetParentCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -122,7 +122,9 @@
       final String name = nameKey.get();
       ProjectState project = projectCache.get(nameKey).orElseThrow(illegalState(nameKey));
       try {
-        setParent.apply(new ProjectResource(project, user), parentInput(newParentKey.get()));
+        @SuppressWarnings("unused")
+        var unused =
+            setParent.apply(new ProjectResource(project, user), parentInput(newParentKey.get()));
       } catch (AuthException e) {
         err.append("error: insuffient access rights to change parent of '")
             .append(name)
diff --git a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
index 9866c4e..188d2cd 100644
--- a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -150,7 +150,8 @@
     }
 
     try {
-      putConfig.apply(new ProjectResource(projectState, user), configInput);
+      @SuppressWarnings("unused")
+      var unused = putConfig.apply(new ProjectResource(projectState, user), configInput);
     } catch (RestApiException | PermissionBackendException e) {
       throw die(e);
     }
diff --git a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index f42eb5c..b1745b4 100644
--- a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -125,15 +125,18 @@
       ReviewerResource rsrc = reviewerFactory.create(changeRsrc, reviewer);
       String error = null;
       try {
-        retryHelper
-            .action(
-                RetryableAction.ActionType.CHANGE_UPDATE,
-                "removeReviewers",
-                () -> {
-                  deleteReviewer.apply(rsrc, new DeleteReviewerInput());
-                  return null;
-                })
-            .call();
+        @SuppressWarnings("unused")
+        var unused =
+            retryHelper
+                .action(
+                    RetryableAction.ActionType.CHANGE_UPDATE,
+                    "removeReviewers",
+                    () -> {
+                      @SuppressWarnings("unused")
+                      var unused2 = deleteReviewer.apply(rsrc, new DeleteReviewerInput());
+                      return null;
+                    })
+                .call();
       } catch (ResourceNotFoundException e) {
         error = String.format("could not remove %s: not found", reviewer);
       } catch (Exception e) {
@@ -156,15 +159,17 @@
             String value;
           };
       try {
-        retryHelper
-            .action(
-                RetryableAction.ActionType.CHANGE_UPDATE,
-                "applyReview",
-                () -> {
-                  error.value = postReviewers.apply(changeRsrc, input).value().error;
-                  return null;
-                })
-            .call();
+        @SuppressWarnings("unused")
+        var unused =
+            retryHelper
+                .action(
+                    RetryableAction.ActionType.CHANGE_UPDATE,
+                    "applyReview",
+                    () -> {
+                      error.value = postReviewers.apply(changeRsrc, input).value().error;
+                      return null;
+                    })
+                .call();
       } catch (Exception e) {
         error.value = String.format("could not add %s: %s", reviewer, e.getMessage());
       }
diff --git a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
index b590740..6ff5686 100644
--- a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
@@ -82,7 +82,8 @@
       TopicInput input = new TopicInput();
       input.topic = topic;
       try {
-        putTopic.apply(r, input);
+        @SuppressWarnings("unused")
+        var unused = putTopic.apply(r, input);
       } catch (ResourceNotFoundException e) {
         ok = false;
         writeError(
diff --git a/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java b/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
index 8ba673a..197354c 100644
--- a/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
+++ b/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
@@ -41,6 +41,7 @@
 
     @Inject
     LfsPluginAuthCommandModule(@GerritServerConfig Config cfg) {
+      super(/* slaveMode= */ false);
       pluginProvided = cfg.getString("lfs", null, "plugin") != null;
     }
 
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/sshd/plugin/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/sshd/plugin/package-info.java
index 0709b86..189effe 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/sshd/plugin/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.sshd.plugin;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 95c9b13..33f8668 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -67,6 +67,9 @@
     name = "gerrit-junit",
     srcs = ["GerritJUnit.java"],
     visibility = ["//visibility:public"],
+    deps = [
+        "//lib/errorprone:annotations",
+    ],
 )
 
 java_library(
diff --git a/java/com/google/gerrit/testing/FakeAccountCache.java b/java/com/google/gerrit/testing/FakeAccountCache.java
index 2c01548..330b602 100644
--- a/java/com/google/gerrit/testing/FakeAccountCache.java
+++ b/java/com/google/gerrit/testing/FakeAccountCache.java
@@ -43,6 +43,7 @@
     return newState(
         Account.builder(accountId, TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
+            .setUniqueTag("1234567812345678123456781234567812345678")
             .build());
   }
 
diff --git a/java/com/google/gerrit/testing/FakeEmailSender.java b/java/com/google/gerrit/testing/FakeEmailSender.java
index a2ebc88..42ffec2 100644
--- a/java/com/google/gerrit/testing/FakeEmailSender.java
+++ b/java/com/google/gerrit/testing/FakeEmailSender.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.testing;
 
+import static com.google.gerrit.common.UsedAt.Project.GOOGLE;
 import static java.util.stream.Collectors.toList;
 
 import com.google.auto.value.AutoValue;
@@ -21,6 +22,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.exceptions.EmailException;
@@ -59,7 +61,8 @@
 
   @AutoValue
   public abstract static class Message {
-    private static Message create(
+    @UsedAt(GOOGLE)
+    public static Message create(
         Address from,
         Collection<Address> rcpt,
         Map<String, EmailHeader> headers,
@@ -183,7 +186,8 @@
     for (WorkQueue.Task<?> task : workQueue.getTasks()) {
       if (task.toString().contains("send-email")) {
         try {
-          task.get();
+          @SuppressWarnings("unused")
+          var unused = task.get();
         } catch (ExecutionException | InterruptedException e) {
           logger.atWarning().withCause(e).log("error finishing email task");
         }
diff --git a/java/com/google/gerrit/testing/GerritJUnit.java b/java/com/google/gerrit/testing/GerritJUnit.java
index e80afa9..87a736c 100644
--- a/java/com/google/gerrit/testing/GerritJUnit.java
+++ b/java/com/google/gerrit/testing/GerritJUnit.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.testing;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+
 /** Static JUnit utility methods. */
 public class GerritJUnit {
   /**
@@ -36,6 +38,7 @@
    * @param runnable runnable containing arbitrary code.
    * @return exception that was thrown.
    */
+  @CanIgnoreReturnValue
   public static <T extends Throwable> T assertThrows(
       Class<T> throwableClass, ThrowingRunnable runnable) {
     try {
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 3f0f8b7..b0045e3 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -16,11 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
-import static com.google.gerrit.server.Sequence.LightweightGroups;
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperationsImpl;
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.PluginUser;
 import com.google.gerrit.server.Sequence;
+import com.google.gerrit.server.Sequence.LightweightGroups;
 import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.storage.notedb.AccountNoteDbReadStorageModule;
@@ -101,6 +102,8 @@
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
 import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
+import com.google.gerrit.server.notedb.NoteDbDraftCommentsModule;
+import com.google.gerrit.server.notedb.NoteDbStarredChangesModule;
 import com.google.gerrit.server.notedb.RepoSequence.DisabledGitRefUpdatedRepoGroupsSequenceProvider;
 import com.google.gerrit.server.notedb.RepoSequence.RepoSequenceModule;
 import com.google.gerrit.server.patch.DiffExecutor;
@@ -132,7 +135,6 @@
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
@@ -199,10 +201,14 @@
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
 
     install(cfgInjector.getInstance(AccountCacheImpl.AccountCacheModule.class));
+    install(cfgInjector.getInstance(AccountCacheImpl.AccountCacheBindingModule.class));
+
     install(cfgInjector.getInstance(GerritGlobalModule.class));
     install(new AccountNoteDbWriteStorageModule());
     install(new AccountNoteDbReadStorageModule());
     install(new RepoSequenceModule());
+    install(new NoteDbDraftCommentsModule());
+    install(new NoteDbStarredChangesModule());
 
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
     install(new AuthModule(authConfig));
@@ -223,7 +229,7 @@
 
     // It would be nice to use Jimfs for the SitePath, but the biggest blocker is that JGit does not
     // support Path-based Configs, only FileBasedConfig.
-    bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
+    bind(Path.class).annotatedWith(SitePath.class).toInstance(Path.of("."));
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
     bind(GerritOptions.class).toInstance(new GerritOptions(false, false));
     bind(AllProjectsConfigProvider.class).to(FileBasedAllProjectsConfigProvider.class);
@@ -381,7 +387,8 @@
     try {
       Class<?> clazz = Class.forName(moduleClassName);
       Method m =
-          clazz.getMethod("singleVersionWithExplicitVersions", Map.class, int.class, boolean.class);
+          clazz.getMethod(
+              "singleVersionWithExplicitVersions", ImmutableMap.class, int.class, boolean.class);
       return (Module) m.invoke(null, getSingleSchemaVersions(), 0, ReplicaUtil.isReplica(cfg));
     } catch (ClassNotFoundException
         | SecurityException
@@ -394,13 +401,13 @@
     }
   }
 
-  private Map<String, Integer> getSingleSchemaVersions() {
+  private ImmutableMap<String, Integer> getSingleSchemaVersions() {
     Map<String, Integer> singleVersions = new HashMap<>();
     putSchemaVersion(singleVersions, AccountSchemaDefinitions.INSTANCE);
     putSchemaVersion(singleVersions, ChangeSchemaDefinitions.INSTANCE);
     putSchemaVersion(singleVersions, GroupSchemaDefinitions.INSTANCE);
     putSchemaVersion(singleVersions, ProjectSchemaDefinitions.INSTANCE);
-    return singleVersions;
+    return ImmutableMap.copyOf(singleVersions);
   }
 
   private void putSchemaVersion(
diff --git a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
index 8c87405..2c00acd 100644
--- a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
+++ b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
@@ -107,7 +107,7 @@
               .addSpecialRef(RefNames::isSequenceRef, REPO_SEQ)
               .addSpecialRef(RefNames.HEAD::equals, HEAD_MODIFICATION)
               .addSpecialRef(RefNames::isRefsChanges, CHANGE_MODIFICATION, MERGE_CHANGE)
-              .addSpecialRef(RefNames::isAutoMergeRef, CHANGE_MODIFICATION)
+              .addSpecialRef(RefNames::isAutoMergeRef, CHANGE_MODIFICATION, MERGE_CHANGE)
               .addSpecialRef(RefNames::isRefsEdit, CHANGE_MODIFICATION, MERGE_CHANGE)
               .addSpecialRef(RefNames::isTagRef, TAG_MODIFICATION)
               .addSpecialRef(RefNames::isRejectCommitsRef, BAN_COMMIT)
@@ -253,7 +253,8 @@
   @Override
   public synchronized Status getRepositoryStatus(NameKey name) {
     try {
-      get(name);
+      @SuppressWarnings("unused")
+      var unused = get(name);
       return Status.ACTIVE;
     } catch (RepositoryNotFoundException e) {
       return Status.NON_EXISTENT;
diff --git a/java/com/google/gerrit/testing/InMemoryTestEnvironment.java b/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
index 77df46c..ef65b61 100644
--- a/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
+++ b/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
@@ -84,7 +84,9 @@
 
   public void setApiUser(Account.Id id) {
     IdentifiedUser user = userFactory.create(id);
-    requestContext.setContext(() -> user);
+
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(() -> user);
   }
 
   private void setUp(Object target) throws Exception {
@@ -113,7 +115,8 @@
       lifecycle.stop();
     }
     if (requestContext != null) {
-      requestContext.setContext(null);
+      @SuppressWarnings("unused")
+      var unused = requestContext.setContext(null);
     }
   }
 }
diff --git a/java/com/google/gerrit/testing/TestCommentHelper.java b/java/com/google/gerrit/testing/TestCommentHelper.java
index 400b559..7db26e3 100644
--- a/java/com/google/gerrit/testing/TestCommentHelper.java
+++ b/java/com/google/gerrit/testing/TestCommentHelper.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Comment.Range;
@@ -143,6 +144,22 @@
     return in;
   }
 
+  public static CommentInput createCommentInputWithMandatoryFields(String path) {
+    CommentInput in = new CommentInput();
+    in.message = "nit: trailing whitespace";
+    in.path = path;
+    return in;
+  }
+
+  public static CommentInput createCommentInput(
+      String path, FixSuggestionInfo... fixSuggestionInfos) {
+    CommentInput in = new CommentInput();
+    in.message = "nit: trailing whitespace";
+    in.path = path;
+    in.fixSuggestions = Arrays.asList(fixSuggestionInfos);
+    return in;
+  }
+
   public void addRobotComment(String targetChangeId, RobotCommentInput robotCommentInput)
       throws Exception {
     addRobotComment(targetChangeId, robotCommentInput, "robot comment test");
@@ -174,4 +191,23 @@
     reviewInput.tag = ChangeMessagesUtil.AUTOGENERATED_TAG_PREFIX;
     return reviewInput;
   }
+
+  public void addComment(String targetChangeId, CommentInput commentInput) throws Exception {
+    addComment(targetChangeId, commentInput, "comment test");
+  }
+
+  public void addComment(String targetChangeId, CommentInput commentInput, String message)
+      throws Exception {
+    ReviewInput reviewInput = createReviewInput(commentInput, message);
+    gApi.changes().id(targetChangeId).current().review(reviewInput);
+  }
+
+  private ReviewInput createReviewInput(CommentInput commentInput, String message) {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.comments =
+        Collections.singletonMap(commentInput.path, ImmutableList.of(commentInput));
+    reviewInput.message = message;
+    reviewInput.tag = ChangeMessagesUtil.AUTOGENERATED_TAG_PREFIX;
+    return reviewInput;
+  }
 }
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/testing/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/testing/package-info.java
index 0709b86..2b83996 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/testing/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.testing;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/truth/BUILD b/java/com/google/gerrit/truth/BUILD
index b0dfef0..2f3411d 100644
--- a/java/com/google/gerrit/truth/BUILD
+++ b/java/com/google/gerrit/truth/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/truth/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/truth/package-info.java
index 0709b86..7afe587 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/truth/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.truth;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/util/cli/BUILD b/java/com/google/gerrit/util/cli/BUILD
index b464f32..f62aea90 100644
--- a/java/com/google/gerrit/util/cli/BUILD
+++ b/java/com/google/gerrit/util/cli/BUILD
@@ -12,6 +12,7 @@
         "//lib:args4j",
         "//lib:guava",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index a37c027..da8d5d9 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -44,6 +44,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -462,6 +463,7 @@
       ensureOptionsInitialized();
     }
 
+    @CanIgnoreReturnValue
     public int addOptionsWithMetRequirements() {
       int count = 0;
       for (Iterator<Map.Entry<String, QueuedOption>> it = queuedOptionsByName.entrySet().iterator();
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/util/cli/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/util/cli/package-info.java
index 0709b86..585ea00 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/util/cli/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.util.cli;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/util/http/BUILD b/java/com/google/gerrit/util/http/BUILD
index afb4e25..1edc46d 100644
--- a/java/com/google/gerrit/util/http/BUILD
+++ b/java/com/google/gerrit/util/http/BUILD
@@ -7,5 +7,6 @@
     deps = [
         "//lib:guava",
         "//lib:servlet-api",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/util/http/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/util/http/package-info.java
index 0709b86..e0b8f62 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/util/http/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.util.http;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/util/logging/BUILD b/java/com/google/gerrit/util/logging/BUILD
index ee598a4..328f0e3 100644
--- a/java/com/google/gerrit/util/logging/BUILD
+++ b/java/com/google/gerrit/util/logging/BUILD
@@ -8,6 +8,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//lib:gson",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/log:log4j",
     ],
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/util/logging/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/util/logging/package-info.java
index 0709b86..d79e8bb 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/util/logging/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.util.logging;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/util/ssl/BUILD b/java/com/google/gerrit/util/ssl/BUILD
index e0641c7..b7d9f85 100644
--- a/java/com/google/gerrit/util/ssl/BUILD
+++ b/java/com/google/gerrit/util/ssl/BUILD
@@ -4,4 +4,7 @@
     name = "ssl",
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
+    deps = [
+        "//lib/errorprone:annotations",
+    ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/util/ssl/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/com/google/gerrit/util/ssl/package-info.java
index 0709b86..161b770 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/util/ssl/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package com.google.gerrit.util.ssl;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index 6923c3d..b6d8325 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -11,6 +11,7 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/rules/prolog",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/prolog:runtime",
         "@guava//jar",
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/gerrit/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/gerrit/package-info.java
index 0709b86..254a10e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/gerrit/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package gerrit;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/org/apache/commons/net/BUILD b/java/org/apache/commons/net/BUILD
index d83d8ec..c5bf47b 100644
--- a/java/org/apache/commons/net/BUILD
+++ b/java/org/apache/commons/net/BUILD
@@ -8,5 +8,6 @@
         "//java/com/google/gerrit/util/ssl",
         "//lib:guava",
         "//lib/commons:net",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/org/apache/commons/net/smtp/package-info.java
similarity index 64%
copy from java/com/google/gerrit/server/StarredChangesUtil.java
copy to java/org/apache/commons/net/smtp/package-info.java
index 0709b86..f3d4db1 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/org/apache/commons/net/smtp/package-info.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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.
@@ -12,8 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+@CheckReturnValue
+package org.apache.commons.net.smtp;
 
-// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
-// used by a plugin.
-public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
+import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index 33e6692..e85a032 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -21,6 +21,7 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
@@ -79,7 +80,7 @@
     Ref matchingRef = createRef("refs/any/test");
 
     try (ProjectResetter resetProject =
-        builder().build(new ProjectResetter.Config().reset(project))) {
+        builder().build(new ProjectResetter.Config.Builder().reset(project).build())) {
       updateRef(matchingRef);
     }
 
@@ -97,8 +98,9 @@
     try (ProjectResetter resetProject =
         builder()
             .build(
-                new ProjectResetter.Config()
-                    .reset(project, "refs/match/*", "refs/another-match/*"))) {
+                new ProjectResetter.Config.Builder()
+                    .reset(project, "refs/match/*", "refs/another-match/*")
+                    .build())) {
       updateRef(matchingRef);
       updateRef(anotherMatchingRef);
       updatedNonMatchingRef = updateRef(nonMatchingRef);
@@ -120,8 +122,9 @@
     try (ProjectResetter resetProject =
         builder()
             .build(
-                new ProjectResetter.Config()
-                    .reset(project, "refs/match/*", "refs/another-match/*"))) {
+                new ProjectResetter.Config.Builder()
+                    .reset(project, "refs/match/*", "refs/another-match/*")
+                    .build())) {
       matchingRef = createRef("refs/match/test");
       anotherMatchingRef = createRef("refs/another-match/test");
       nonMatchingRef = createRef("refs/no-match/test");
@@ -151,9 +154,10 @@
     try (ProjectResetter resetProject =
         builder()
             .build(
-                new ProjectResetter.Config()
+                new ProjectResetter.Config.Builder()
                     .reset(project, "refs/foo/*")
-                    .reset(project2, "refs/bar/*"))) {
+                    .reset(project2, "refs/bar/*")
+                    .build())) {
       updateRef(matchingRefProject1);
       updatedNonMatchingRefProject1 = updateRef(nonMatchingRefProject1);
 
@@ -182,9 +186,10 @@
     try (ProjectResetter resetProject =
         builder()
             .build(
-                new ProjectResetter.Config()
+                new ProjectResetter.Config.Builder()
                     .reset(project, "refs/foo/*")
-                    .reset(project2, "refs/bar/*"))) {
+                    .reset(project2, "refs/bar/*")
+                    .build())) {
       matchingRefProject1 = createRef("refs/foo/test");
       nonMatchingRefProject1 = createRef("refs/bar/test");
 
@@ -207,7 +212,9 @@
     try (ProjectResetter resetProject =
         builder()
             .build(
-                new ProjectResetter.Config().reset(project, "refs/match/*", "refs/match/test"))) {
+                new ProjectResetter.Config.Builder()
+                    .reset(project, "refs/match/*", "refs/match/test")
+                    .build())) {
       // This ref matches 2 ref pattern, ProjectResetter should try to delete it only once.
       matchingRef = createRef("refs/match/test");
     }
@@ -228,7 +235,7 @@
 
     try (ProjectResetter resetProject =
         builder(null, null, null, null, null, null, projectCache)
-            .build(new ProjectResetter.Config().reset(project).reset(project2))) {
+            .build(new ProjectResetter.Config.Builder().reset(project).reset(project2).build())) {
       updateRef(nonMetaConfig);
       updateRef(repo2, metaConfig);
     }
@@ -245,7 +252,7 @@
 
     try (ProjectResetter resetProject =
         builder(null, null, null, null, null, null, projectCache)
-            .build(new ProjectResetter.Config().reset(project).reset(project2))) {
+            .build(new ProjectResetter.Config.Builder().reset(project).reset(project2).build())) {
       createRef("refs/heads/master");
       createRef(repo2, RefNames.REFS_CONFIG);
     }
@@ -269,7 +276,7 @@
     Ref ref2 = createRef(allUsersRepo, RefNames.refsGroups(uuid2));
     try (ProjectResetter resetProject =
         builder(null, null, null, cache, includeCache, indexer, null)
-            .build(new ProjectResetter.Config().reset(project).reset(allUsers))) {
+            .build(new ProjectResetter.Config.Builder().reset(project).reset(allUsers).build())) {
       updateRef(allUsersRepo, ref2);
       createRef(allUsersRepo, RefNames.refsGroups(uuid3));
     }
@@ -283,10 +290,12 @@
     verifyNoMoreInteractions(cache, indexer, includeCache);
   }
 
+  @CanIgnoreReturnValue
   private Ref createRef(String ref) throws IOException {
     return createRef(repo, ref);
   }
 
+  @CanIgnoreReturnValue
   private Ref createRef(Repository repo, String ref) throws IOException {
     try (RefUpdateContext ctx = openTestRefUpdateContext()) {
       try (ObjectInserter oi = repo.newObjectInserter();
@@ -301,10 +310,12 @@
     }
   }
 
+  @CanIgnoreReturnValue
   private Ref updateRef(Ref ref) throws IOException {
     return updateRef(repo, ref);
   }
 
+  @CanIgnoreReturnValue
   private Ref updateRef(Repository repo, Ref ref) throws IOException {
     try (RefUpdateContext ctx = openTestRefUpdateContext()) {
       try (ObjectInserter oi = repo.newObjectInserter();
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index f3bb15b..fdf7457 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.acceptance.api.accounts;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
@@ -36,7 +36,6 @@
 import static com.google.gerrit.server.account.AccountProperties.ACCOUNT_CONFIG;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -54,9 +53,11 @@
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 
+import com.github.rholder.retry.RetryException;
 import com.github.rholder.retry.StopStrategies;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
@@ -145,7 +146,9 @@
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdsNoteDbImpl;
@@ -237,26 +240,26 @@
 
   @Inject protected ProjectOperations projectOperations;
   @Inject protected Emails emails;
+  @Inject protected ExtensionRegistry extensionRegistry;
+  @Inject protected RequestScopeOperations requestScopeOperations;
 
   @Inject protected GroupOperations groupOperations;
 
   @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
   @Inject private AccountIndexer accountIndexer;
   @Inject private ExternalIdNotes.Factory extIdNotesFactory;
-  @Inject private ExternalIdsNoteDbImpl externalIds;
+  @Inject private ExternalIdsNoteDbImpl externalIdsNoteDbImpl;
   @Inject private GitReferenceUpdated gitReferenceUpdated;
   @Inject private Provider<InternalAccountQuery> accountQueryProvider;
   @Inject private Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
   @Inject private Provider<PublicKeyStore> publicKeyStoreProvider;
-  @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private RetryHelper.Metrics retryMetrics;
   @Inject private Sequences seq;
   @Inject private StalenessChecker stalenessChecker;
   @Inject private VersionedAuthorizedKeys.Accessor authorizedKeys;
-  @Inject private ExtensionRegistry extensionRegistry;
   @Inject private PluginSetContext<ExceptionHook> exceptionHooks;
   @Inject private ExternalIdKeyFactory externalIdKeyFactory;
-  @Inject private ExternalIdFactoryNoteDbImpl externalIdFactory;
+  @Inject private ExternalIdFactoryNoteDbImpl externalIdFactoryNoteDbImpl;
   @Inject private AuthConfig authConfig;
   @Inject private AccountControl.Factory accountControlFactory;
   @Inject private AccountOperations accountOperations;
@@ -327,7 +330,7 @@
 
   @Test
   public void createByAccountCreator() throws Exception {
-    RefUpdateCounter refUpdateCounter = new RefUpdateCounter();
+    RefUpdateCounter refUpdateCounter = createRefUpdateCounter();
     try (Registration registration = extensionRegistry.newRegistration().add(refUpdateCounter)) {
       Account.Id accountId = createByAccountCreator(1);
       refUpdateCounter.assertRefUpdateFor(
@@ -353,14 +356,23 @@
   }
 
   @UsedAt(UsedAt.Project.GOOGLE)
+  protected AccountIndexedCounter getAccountIndexedCounter() {
+    return new AccountIndexedCounter();
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
   protected Account.Id createByAccountCreator(int expectedAccountReindexCalls) throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       String name = "foo";
       TestAccount foo = accountCreator.create(name);
       AccountInfo info = gApi.accounts().id(foo.id().get()).get();
-      assertThat(info.username).isEqualTo(name);
+      if (server.isUsernameSupported()) {
+        assertThat(info.username).isEqualTo(name);
+      } else {
+        assertThat(info.email).isEqualTo(foo.email());
+      }
       assertThat(info.name).isEqualTo(name);
       accountIndexedCounter.assertReindexOf(foo, expectedAccountReindexCalls);
       assertUserBranch(foo.id(), name, null);
@@ -370,7 +382,7 @@
 
   @Test
   public void createAnonymousCowardByAccountCreator() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       TestAccount anonymousCoward = accountCreator.create();
@@ -381,7 +393,7 @@
 
   @Test
   public void create() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       AccountInput input = new AccountInput();
@@ -398,10 +410,10 @@
 
       Account.Id accountId = Account.id(accountInfo._accountId);
       accountIndexedCounter.assertReindexOf(accountId, 1);
-      assertThat(externalIds.byAccount(accountId))
+      assertThat(getExternalIdsReader().byAccount(accountId))
           .containsExactly(
-              externalIdFactory.createUsername(input.username, accountId, null),
-              externalIdFactory.createEmail(accountId, input.email));
+              getExternalIdFactory().createUsername(input.username, accountId, null),
+              getExternalIdFactory().createEmail(accountId, input.email));
     }
   }
 
@@ -454,7 +466,7 @@
   public void createAtomically() throws Exception {
     Account.Id accountId = Account.id(seq.nextAccountId());
     String fullName = "Foo";
-    ExternalId extId = externalIdFactory.createEmail(accountId, "foo@example.com");
+    ExternalId extId = getExternalIdFactory().createEmail(accountId, "foo@example.com");
     AccountState accountState =
         accountsUpdateProvider
             .get()
@@ -546,23 +558,25 @@
 
   @Test
   public void get() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      AccountInfo info = gApi.accounts().id("admin").get();
+      AccountInfo info = gApi.accounts().id(admin.id().get()).get();
       assertThat(info.name).isEqualTo("Administrator");
       assertThat(info.email).isEqualTo("admin@example.com");
-      assertThat(info.username).isEqualTo("admin");
+      if (server.isUsernameSupported()) {
+        assertThat(info.username).isEqualTo("admin");
+      }
       accountIndexedCounter.assertNoReindex();
     }
   }
 
   @Test
   public void getByIntId() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      AccountInfo info = gApi.accounts().id("admin").get();
+      AccountInfo info = gApi.accounts().id(admin.id().get()).get();
       AccountInfo infoByIntId = gApi.accounts().id(info._accountId).get();
       assertThat(info.name).isEqualTo(infoByIntId.name);
       accountIndexedCounter.assertNoReindex();
@@ -571,13 +585,13 @@
 
   @Test
   public void self() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       AccountInfo info = gApi.accounts().self().get();
       assertUser(info, admin);
 
-      info = gApi.accounts().id("self").get();
+      info = gApi.accounts().self().get();
       assertUser(info, admin);
       accountIndexedCounter.assertNoReindex();
     }
@@ -585,22 +599,22 @@
 
   @Test
   public void active() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      int id = gApi.accounts().id(user.username()).get()._accountId;
-      assertThat(gApi.accounts().id(user.username()).getActive()).isTrue();
-      gApi.accounts().id(user.username()).setActive(false);
+      int id = gApi.accounts().id(user.id().get()).get()._accountId;
+      assertThat(gApi.accounts().id(user.id().get()).getActive()).isTrue();
+      gApi.accounts().id(user.id().get()).setActive(false);
       accountIndexedCounter.assertReindexOf(user);
 
       // Inactive users may only be resolved by ID.
       ResourceNotFoundException thrown =
-          assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id(user.username()));
+          assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id(user.email()));
       assertThat(thrown)
           .hasMessageThat()
           .isEqualTo(
               "Account '"
-                  + user.username()
+                  + user.email()
                   + "' only matches inactive accounts. To use an inactive account, retry"
                   + " with one of the following exact account IDs:\n"
                   + id
@@ -608,35 +622,41 @@
       assertThat(gApi.accounts().id(id).getActive()).isFalse();
 
       gApi.accounts().id(id).setActive(true);
-      assertThat(gApi.accounts().id(user.username()).getActive()).isTrue();
+      assertThat(gApi.accounts().id(user.id().get()).getActive()).isTrue();
       accountIndexedCounter.assertReindexOf(user);
     }
   }
 
   @Test
   public void shouldAllowQueryByEmailForInactiveUser() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       Account.Id activatableAccountId =
           accountOperations.newAccount().inactive().preferredEmail("foo@activatable.com").create();
-      accountIndexedCounter.assertReindexOf(activatableAccountId, 1);
+      // Implementation of the accountOperations can create an account in several steps,
+      // with more than one reindexing.
+      accountIndexedCounter.assertReindexAtLeastOnceOf(activatableAccountId);
     }
 
-    gApi.changes().query("owner:foo@activatable.com").get();
+    @SuppressWarnings("unused")
+    var unused = gApi.changes().query("owner:foo@activatable.com").get();
   }
 
   @Test
   public void shouldAllowQueryByUserNameForInactiveUser() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       Account.Id activatableAccountId =
           accountOperations.newAccount().inactive().username("foo").create();
-      accountIndexedCounter.assertReindexOf(activatableAccountId, 1);
+      // Implementation of the accountOperations can create an account in several steps,
+      // with more than one reindexing.
+      accountIndexedCounter.assertReindexAtLeastOnceOf(activatableAccountId);
     }
 
-    gApi.changes().query("owner:foo").get();
+    @SuppressWarnings("unused")
+    var unused = gApi.changes().query("owner:foo").get();
   }
 
   @Test
@@ -778,9 +798,9 @@
 
   @Test
   public void deactivateNotActive() throws Exception {
-    int id = gApi.accounts().id(user.username()).get()._accountId;
-    assertThat(gApi.accounts().id(user.username()).getActive()).isTrue();
-    gApi.accounts().id(user.username()).setActive(false);
+    int id = gApi.accounts().id(user.id().get()).get()._accountId;
+    assertThat(gApi.accounts().id(user.id().get()).getActive()).isTrue();
+    gApi.accounts().id(user.id().get()).setActive(false);
     assertThat(gApi.accounts().id(id).getActive()).isFalse();
     ResourceConflictException thrown =
         assertThrows(
@@ -791,8 +811,8 @@
 
   @Test
   public void starUnstarChange() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    RefUpdateCounter refUpdateCounter = new RefUpdateCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
+    RefUpdateCounter refUpdateCounter = createRefUpdateCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter).add(refUpdateCounter)) {
       PushOneCommit.Result r = createChange();
@@ -828,7 +848,7 @@
     reviewerInput.reviewer = user.email();
     input.reviewers.add(reviewerInput);
     gApi.changes().id(r.getChangeId()).current().review(input);
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message message = messages.get(0);
     assertThat(message.rcpt()).containsExactly(user.getNameEmail());
@@ -849,7 +869,7 @@
     input2.reviewers.add(reviewerInput3);
 
     gApi.changes().id(r.getChangeId()).current().review(input2);
-    List<Message> messages2 = sender.getMessages();
+    ImmutableList<Message> messages2 = sender.getMessages();
     assertThat(messages2).hasSize(1);
     Message message2 = messages2.get(0);
     assertThat(message2.rcpt()).containsExactly(user2.getNameEmail());
@@ -869,7 +889,7 @@
     input3.reviewers.add(reviewerInput5);
 
     gApi.changes().id(r.getChangeId()).current().review(input3);
-    List<Message> messages3 = sender.getMessages();
+    ImmutableList<Message> messages3 = sender.getMessages();
     assertThat(messages3).isEmpty();
   }
 
@@ -881,7 +901,7 @@
     ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message message = messages.get(0);
     assertThat(message.rcpt()).containsExactly(user.getNameEmail());
@@ -894,7 +914,7 @@
     ReviewerInput reviewerInput2 = new ReviewerInput();
     reviewerInput2.reviewer = user2.email();
     gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput2);
-    List<Message> messages2 = sender.getMessages();
+    ImmutableList<Message> messages2 = sender.getMessages();
     assertThat(messages2).hasSize(1);
     Message message2 = messages2.get(0);
     assertThat(message2.rcpt()).containsExactly(user2.getNameEmail());
@@ -906,19 +926,22 @@
     ReviewerInput reviewerInput3 = new ReviewerInput();
     reviewerInput3.reviewer = user2.email();
     gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput3);
-    List<Message> messages3 = sender.getMessages();
+    ImmutableList<Message> messages3 = sender.getMessages();
     assertThat(messages3).isEmpty();
   }
 
   @Test
   public void suggestAccounts() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       String adminUsername = "admin";
       List<AccountInfo> result = gApi.accounts().suggestAccounts().withQuery(adminUsername).get();
       assertThat(result).hasSize(1);
-      assertThat(result.get(0).username).isEqualTo(adminUsername);
+      if (server.isUsernameSupported()) {
+        assertThat(result.get(0).username).isEqualTo(adminUsername);
+      }
+      assertThat(result.get(0).email).isEqualTo("admin@example.com");
 
       List<AccountInfo> resultShortcutApi = gApi.accounts().suggestAccounts(adminUsername).get();
       assertThat(resultShortcutApi).hasSize(result.size());
@@ -946,7 +969,9 @@
     AccountDetailInfo detail = gApi.accounts().id(foo.id().get()).detail();
     assertThat(detail._accountId).isEqualTo(foo.id().get());
     assertThat(detail.name).isEqualTo(name);
-    assertThat(detail.username).isEqualTo(username);
+    if (server.isUsernameSupported()) {
+      assertThat(detail.username).isEqualTo(username);
+    }
     assertThat(detail.email).isEqualTo(email);
     assertThat(detail.secondaryEmails).containsExactly(secondaryEmail);
     assertThat(detail.status).isEqualTo(status);
@@ -1028,11 +1053,12 @@
 
   @Test
   public void addEmail() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      List<String> emails = ImmutableList.of("new.email@example.com", "new.email@example.systems");
-      Set<String> currentEmails = getEmails();
+      ImmutableList<String> emails =
+          ImmutableList.of("new.email@example.com", "new.email@example.systems");
+      ImmutableSet<String> currentEmails = getEmails();
       for (String email : emails) {
         assertThat(currentEmails).doesNotContain(email);
         EmailInput input = newEmailInput(email);
@@ -1047,7 +1073,7 @@
 
   @Test
   public void addInvalidEmail() throws Exception {
-    List<String> emails =
+    ImmutableList<String> emails =
         ImmutableList.of(
             // Missing domain part
             "new.email",
@@ -1060,7 +1086,7 @@
 
             // Non-supported TLD  (see tlds-alpha-by-domain.txt)
             "new.email@example.africa");
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       for (String email : emails) {
@@ -1078,7 +1104,7 @@
     TestAccount account = accountCreator.create(name("user"));
     EmailInput input = newEmailInput("test@example.com");
     requestScopeOperations.setApiUser(user.id());
-    assertThrows(AuthException.class, () -> gApi.accounts().id(account.username()).addEmail(input));
+    assertThrows(AuthException.class, () -> gApi.accounts().id(account.id().get()).addEmail(input));
   }
 
   @Test
@@ -1089,7 +1115,7 @@
     ResourceConflictException thrown =
         assertThrows(
             ResourceConflictException.class,
-            () -> gApi.accounts().id(user.username()).addEmail(input));
+            () -> gApi.accounts().id(user.id().get()).addEmail(input));
     assertThat(thrown)
         .hasMessageThat()
         .contains("Identity 'mailto:" + email + "' in use by another account");
@@ -1150,7 +1176,7 @@
 
   @Test
   public void addEmailAndSetPreferred() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       String email = "foo.bar@example.com";
@@ -1171,7 +1197,7 @@
 
   @Test
   public void deleteEmail() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       String email = "foo.bar@example.com";
@@ -1192,7 +1218,7 @@
 
   @Test
   public void deletePreferredEmail() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       ImmutableSet<String> previous = getEmails();
@@ -1229,7 +1255,7 @@
     gApi.accounts().self().addEmail(input);
 
     requestScopeOperations.resetCurrentApiUser();
-    Set<String> allEmails = getEmails();
+    ImmutableSet<String> allEmails = getEmails();
     assertThat(allEmails).hasSize(2);
 
     for (String email : allEmails) {
@@ -1243,7 +1269,7 @@
 
   @Test
   public void deleteEmailFromCustomExternalIdSchemes() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       String email = "foo.bar@example.com";
@@ -1256,11 +1282,13 @@
               admin.id(),
               u ->
                   u.addExternalId(
-                          externalIdFactory.createWithEmail(
-                              externalIdKeyFactory.parse(extId1), admin.id(), email))
+                          getExternalIdFactory()
+                              .createWithEmail(
+                                  externalIdKeyFactory.parse(extId1), admin.id(), email))
                       .addExternalId(
-                          externalIdFactory.createWithEmail(
-                              externalIdKeyFactory.parse(extId2), admin.id(), email)));
+                          getExternalIdFactory()
+                              .createWithEmail(
+                                  externalIdKeyFactory.parse(extId2), admin.id(), email)));
       accountIndexedCounter.assertReindexOf(admin);
       assertThat(
               gApi.accounts().self().getExternalIds().stream()
@@ -1296,8 +1324,9 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                    externalIdFactory.createWithEmail(
-                        externalIdKeyFactory.parse(ldapExternalId), admin.id(), ldapEmail)));
+                    getExternalIdFactory()
+                        .createWithEmail(
+                            externalIdKeyFactory.parse(ldapExternalId), admin.id(), ldapEmail)));
     assertThat(
             gApi.accounts().self().getExternalIds().stream()
                 .map(e -> e.identity)
@@ -1335,13 +1364,17 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                        externalIdFactory.createWithEmail(
-                            externalIdKeyFactory.parse(nonLdapExternalId),
-                            admin.id(),
-                            nonLdapEMail))
+                        getExternalIdFactory()
+                            .createWithEmail(
+                                externalIdKeyFactory.parse(nonLdapExternalId),
+                                admin.id(),
+                                nonLdapEMail))
                     .addExternalId(
-                        externalIdFactory.createWithEmail(
-                            externalIdKeyFactory.parse(ldapExternalId), admin.id(), ldapEmail)));
+                        getExternalIdFactory()
+                            .createWithEmail(
+                                externalIdKeyFactory.parse(ldapExternalId),
+                                admin.id(),
+                                ldapEmail)));
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
         .containsAtLeast(ldapExternalId, nonLdapExternalId);
@@ -1360,7 +1393,7 @@
 
   @Test
   public void deleteEmailOfOtherUser() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       String email = "foo.bar@example.com";
@@ -1404,8 +1437,9 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                    externalIdFactory.createWithEmail(
-                        externalIdKeyFactory.parse("foo:bar"), admin.id(), email)));
+                    getExternalIdFactory()
+                        .createWithEmail(
+                            externalIdKeyFactory.parse("foo:bar"), admin.id(), email)));
     assertEmail(emails.getAccountFor(email), admin);
 
     // wrong case doesn't match
@@ -1451,9 +1485,9 @@
 
   @Test
   public void putStatus() throws Exception {
-    List<String> statuses = ImmutableList.of("OOO", "Busy");
+    ImmutableList<String> statuses = ImmutableList.of("OOO", "Busy");
     AccountInfo info;
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       for (String status : statuses) {
@@ -1478,8 +1512,8 @@
 
   @Test
   public void adminCanSetNameOfOtherUser() throws Exception {
-    gApi.accounts().id(user.username()).setName("User McUserface");
-    assertThat(gApi.accounts().id(user.username()).get().name).isEqualTo("User McUserface");
+    gApi.accounts().id(user.id().get()).setName("User McUserface");
+    assertThat(gApi.accounts().id(user.id().get()).get().name).isEqualTo("User McUserface");
   }
 
   @Test
@@ -1487,7 +1521,7 @@
     requestScopeOperations.setApiUser(user.id());
     assertThrows(
         AuthException.class,
-        () -> gApi.accounts().id(admin.username()).setName("Admin McAdminface"));
+        () -> gApi.accounts().id(admin.id().get()).setName("Admin McAdminface"));
   }
 
   @Test
@@ -1497,13 +1531,13 @@
         .allProjectsForUpdate()
         .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
         .update();
-    gApi.accounts().id(admin.username()).setName("Admin McAdminface");
-    assertThat(gApi.accounts().id(admin.username()).get().name).isEqualTo("Admin McAdminface");
+    gApi.accounts().id(admin.id().get()).setName("Admin McAdminface");
+    assertThat(gApi.accounts().id(admin.id().get()).get().name).isEqualTo("Admin McAdminface");
   }
 
   @Test
   public void fetchUserBranch() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       requestScopeOperations.setApiUser(user.id());
@@ -1710,7 +1744,7 @@
 
   @Test
   public void addOtherUsersGpgKey_Conflict() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       // Both users have a matching external ID for this key.
@@ -1721,7 +1755,7 @@
           .update(
               "Add External ID",
               user.id(),
-              u -> u.addExternalId(externalIdFactory.create("foo", "myId", user.id())));
+              u -> u.addExternalId(getExternalIdFactory().create("foo", "myId", user.id())));
       accountIndexedCounter.assertReindexOf(user);
 
       TestKey key = validKeyWithSecondUserId();
@@ -1739,10 +1773,10 @@
 
   @Test
   public void listGpgKeys() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      List<TestKey> keys = allValidKeys();
+      ImmutableList<TestKey> keys = allValidKeys();
       List<String> toAdd = new ArrayList<>(keys.size());
       for (TestKey key : keys) {
         addExternalIdEmail(
@@ -1758,7 +1792,7 @@
 
   @Test
   public void deleteGpgKey() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       TestKey key = validKeyWithoutExpiration();
@@ -1784,7 +1818,7 @@
 
   @Test
   public void addAndRemoveGpgKeys() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       for (TestKey key : allValidKeys()) {
@@ -1843,7 +1877,7 @@
   @Test
   @UseSsh
   public void sshKeys() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       // The test account should initially have exactly one ssh key
@@ -1916,7 +1950,7 @@
   @Test
   @UseSsh
   public void adminCanAddOrRemoveSshKeyOnOtherAccount() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       // The test account should initially have exactly one ssh key
@@ -1932,8 +1966,8 @@
       // Add a new key
       sender.clear();
       String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), user.email());
-      gApi.accounts().id(user.username()).addSshKey(newKey);
-      info = gApi.accounts().id(user.username()).listSshKeys();
+      gApi.accounts().id(user.id().get()).addSshKey(newKey);
+      info = gApi.accounts().id(user.id().get()).listSshKeys();
       assertThat(info).hasSize(2);
       assertSequenceNumbers(info);
       accountIndexedCounter.assertReindexOf(user);
@@ -1945,8 +1979,8 @@
 
       // Delete key
       sender.clear();
-      gApi.accounts().id(user.username()).deleteSshKey(1);
-      info = gApi.accounts().id(user.username()).listSshKeys();
+      gApi.accounts().id(user.id().get()).deleteSshKey(1);
+      info = gApi.accounts().id(user.id().get()).listSshKeys();
       assertThat(info).hasSize(1);
       accountIndexedCounter.assertReindexOf(user);
 
@@ -1962,7 +1996,7 @@
   public void userCannotAddSshKeyToOtherAccount() throws Exception {
     String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), admin.email());
     requestScopeOperations.setApiUser(user.id());
-    assertThrows(AuthException.class, () -> gApi.accounts().id(admin.username()).addSshKey(newKey));
+    assertThrows(AuthException.class, () -> gApi.accounts().id(admin.id().get()).addSshKey(newKey));
   }
 
   @Test
@@ -1971,18 +2005,18 @@
     requestScopeOperations.setApiUser(user.id());
     assertThrows(
         ResourceNotFoundException.class,
-        () -> gApi.accounts().id(admin.username()).deleteSshKey(0));
+        () -> gApi.accounts().id(admin.id().get()).deleteSshKey(0));
   }
 
   // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
   @Test
   public void reindexPermissions() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       // admin can reindex any account
       requestScopeOperations.setApiUser(admin.id());
-      gApi.accounts().id(user.username()).index();
+      gApi.accounts().id(user.id().get()).index();
       accountIndexedCounter.assertReindexOf(user);
 
       // user can reindex own account
@@ -1992,7 +2026,7 @@
 
       // user cannot reindex any account
       AuthException thrown =
-          assertThrows(AuthException.class, () -> gApi.accounts().id(admin.username()).index());
+          assertThrows(AuthException.class, () -> gApi.accounts().id(admin.id().get()).index());
       assertThat(thrown).hasMessageThat().contains("modify account not permitted");
     }
   }
@@ -2024,7 +2058,7 @@
         .update(
             "Delete External ID",
             account.id(),
-            u -> u.deleteExternalId(externalIdFactory.createEmail(account.id(), email)));
+            u -> u.deleteExternalId(getExternalIdFactory().createEmail(account.id(), email)));
     expectedProblems.add(
         new ConsistencyProblemInfo(
             ConsistencyProblemInfo.Status.ERROR,
@@ -2045,33 +2079,40 @@
     assertThat(accountQueryProvider.get().byDefault(name, true)).isEmpty();
 
     TestAccount foo1 = accountCreator.create(name + "-1");
-    assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo1.id().get()).getActive()).isTrue();
 
     TestAccount foo2 = accountCreator.create(name + "-2");
-    gApi.accounts().id(foo2.username()).setActive(false);
+    gApi.accounts().id(foo2.id().get()).setActive(false);
     assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
 
     assertThat(accountQueryProvider.get().byDefault(name, true)).hasSize(2);
   }
 
   @Test
-  public void checkMetaId() throws Exception {
-    // metaId is set when account is loaded
+  public void checkMetaIdAndUniqueTag() throws Exception {
+    // In open-source Gerrit, the uniqueTag and metaId are always the same. Check them together
+    // in this test.
+    // metaId and uniqueTag are set when account is loaded
     assertThat(accounts.get(admin.id()).get().account().metaId()).isEqualTo(getMetaId(admin.id()));
+    assertThat(accounts.get(admin.id()).get().account().uniqueTag())
+        .isEqualTo(getMetaId(admin.id()));
 
-    // metaId is set when account is created
+    // metaId and uniqueTag are set when account is created
     AccountsUpdate au = accountsUpdateProvider.get();
     Account.Id accountId = Account.id(seq.nextAccountId());
     AccountState accountState = au.insert("Create Test Account", accountId, u -> {});
     assertThat(accountState.account().metaId()).isEqualTo(getMetaId(accountId));
+    assertThat(accountState.account().uniqueTag()).isEqualTo(getMetaId(accountId));
 
-    // metaId is set when account is updated
+    // metaId and uniqueTag are set when account is updated
     Optional<AccountState> updatedAccountState =
         au.update("Set Full Name", accountId, u -> u.setFullName("foo"));
     assertThat(updatedAccountState).isPresent();
     Account updatedAccount = updatedAccountState.get().account();
     assertThat(accountState.account().metaId()).isNotEqualTo(updatedAccount.metaId());
+    assertThat(accountState.account().uniqueTag()).isNotEqualTo(updatedAccount.uniqueTag());
     assertThat(updatedAccount.metaId()).isEqualTo(getMetaId(accountId));
+    assertThat(updatedAccount.uniqueTag()).isEqualTo(getMetaId(accountId));
   }
 
   private EmailInput newEmailInput(String email, boolean noConfirmation) {
@@ -2081,7 +2122,7 @@
     return input;
   }
 
-  private EmailInput newEmailInput(String email) {
+  protected EmailInput newEmailInput(String email) {
     return newEmailInput(email, true);
   }
 
@@ -2097,7 +2138,7 @@
 
   @Test
   public void allGroupsForAnAdminAccountCanBeRetrieved() throws Exception {
-    List<GroupInfo> groups = gApi.accounts().id(admin.username()).getGroups();
+    List<GroupInfo> groups = gApi.accounts().id(admin.id().get()).getGroups();
     assertThat(groups)
         .comparingElementsUsing(getGroupToNameCorrespondence())
         .containsAtLeast("Anonymous Users", "Registered Users", "Administrators");
@@ -2135,13 +2176,13 @@
   @Test
   public void allGroupsForAUserAccountCanBeRetrieved() throws Exception {
     String username = name("user1");
-    accountOperations.newAccount().username(username).create();
+    Account.Id id = accountOperations.newAccount().username(username).create();
     AccountGroup.UUID groupID = groupOperations.newGroup().name("group").create();
     String group = groupOperations.group(groupID).get().name();
 
     gApi.groups().id(group).addMembers(username);
 
-    List<GroupInfo> allGroups = gApi.accounts().id(username).getGroups();
+    List<GroupInfo> allGroups = gApi.accounts().id(id.get()).getGroups();
     assertThat(allGroups)
         .comparingElementsUsing(getGroupToNameCorrespondence())
         .containsExactly("Anonymous Users", "Registered Users", group);
@@ -2161,7 +2202,10 @@
 
     assertLabelPermission(
         allUsers, groupRef(REGISTERED_USERS), userRef, true, "Code-Review", -2, 2);
+  }
 
+  @Test
+  public void defaultPermissionsOnUserDefaultBranches() throws Exception {
     assertPermissions(
         allUsers,
         adminGroupRef(),
@@ -2178,7 +2222,7 @@
     String fullName = "Foo";
     AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
     AccountsUpdate update =
-        getAccountsUpdate(
+        getAccountsUpdateWithRunnables(
             () -> {
               if (!doneBgUpdate.getAndSet(true)) {
                 try {
@@ -2212,11 +2256,11 @@
 
   @Test
   public void failAfterRetryerGivesUp() throws Exception {
-    List<String> status = ImmutableList.of("foo", "bar", "baz");
+    ImmutableList<String> status = ImmutableList.of("foo", "bar", "baz");
     String fullName = "Foo";
     AtomicInteger bgCounter = new AtomicInteger(0);
     AccountsUpdate update =
-        getAccountsUpdate(
+        getAccountsUpdateWithRunnables(
             () -> {
               try {
                 accountsUpdateProvider
@@ -2236,6 +2280,7 @@
                 null,
                 null,
                 null,
+                null,
                 exceptionHooks,
                 r ->
                     r.withStopStrategy(StopStrategies.stopAfterAttempt(status.size()))
@@ -2245,9 +2290,12 @@
     assertThat(accountInfo.status).isNull();
     assertThat(accountInfo.name).isNotEqualTo(fullName);
 
-    assertThrows(
-        LockFailureException.class,
-        () -> update.update("Set Full Name", admin.id(), u -> u.setFullName(fullName)));
+    StorageException exception =
+        assertThrows(
+            StorageException.class,
+            () -> update.update("Set Full Name", admin.id(), u -> u.setFullName(fullName)));
+    assertThat(exception).hasCauseThat().isInstanceOf(RetryException.class);
+    assertThat(exception.getCause()).hasCauseThat().isInstanceOf(LockFailureException.class);
     assertThat(bgCounter.get()).isEqualTo(status.size());
 
     Account updatedAccount = accounts.get(admin.id()).get().account();
@@ -2260,15 +2308,19 @@
   }
 
   @Test
-  public void atomicReadMofifyWrite() throws Exception {
+  public void atomicReadModifyWrite() throws Exception {
     gApi.accounts().id(admin.id().get()).setStatus("A-1");
 
-    AtomicInteger bgCounterA1 = new AtomicInteger(0);
-    AtomicInteger bgCounterA2 = new AtomicInteger(0);
+    AtomicBoolean bgIndicatorA1ToA2 = new AtomicBoolean(false);
     AccountsUpdate update =
-        getAccountsUpdate(
+        getAccountsUpdateWithRunnables(
             Runnables.doNothing(),
             () -> {
+              if (bgIndicatorA1ToA2.get()) {
+                // In the Google architecture, this runnable might be called multiple times. Only
+                // do the replacement once.
+                return;
+              }
               try {
                 accountsUpdateProvider
                     .get()
@@ -2276,29 +2328,31 @@
               } catch (IOException | ConfigInvalidException | StorageException e) {
                 // Ignore, the expected exception is asserted later
               }
+              bgIndicatorA1ToA2.set(true);
             });
-    assertThat(bgCounterA1.get()).isEqualTo(0);
-    assertThat(bgCounterA2.get()).isEqualTo(0);
+
     assertThat(gApi.accounts().id(admin.id().get()).get().status).isEqualTo("A-1");
 
+    AtomicBoolean bgIndicatorA1ToB1 = new AtomicBoolean(false);
+    AtomicBoolean bgIndicatorA2ToB2 = new AtomicBoolean(false);
     Optional<AccountState> updatedAccountState =
         update.update(
             "Set Status",
             admin.id(),
             (a, u) -> {
               if ("A-1".equals(a.account().status())) {
-                bgCounterA1.getAndIncrement();
+                bgIndicatorA1ToB1.set(true);
                 u.setStatus("B-1");
               }
 
               if ("A-2".equals(a.account().status())) {
-                bgCounterA2.getAndIncrement();
+                bgIndicatorA2ToB2.set(true);
                 u.setStatus("B-2");
               }
             });
 
-    assertThat(bgCounterA1.get()).isEqualTo(1);
-    assertThat(bgCounterA2.get()).isEqualTo(1);
+    assertThat(bgIndicatorA1ToB1.get()).isTrue();
+    assertThat(bgIndicatorA2ToB2.get()).isTrue();
 
     assertThat(updatedAccountState).isPresent();
     assertThat(updatedAccountState.get().account().status()).isEqualTo("B-2");
@@ -2307,64 +2361,70 @@
   }
 
   @Test
-  public void atomicReadMofifyWriteExternalIds() throws Exception {
+  public void atomicReadModifyWriteExternalIds() throws Exception {
     projectOperations
         .allProjectsForUpdate()
         .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
         .update();
 
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId extIdA1 = externalIdFactory.create("foo", "A-1", accountId);
+    ExternalId extIdA1 = getExternalIdFactory().create("foo", "A-1", accountId);
     accountsUpdateProvider
         .get()
         .insert("Create Test Account", accountId, u -> u.addExternalId(extIdA1));
 
-    AtomicInteger bgCounterA1 = new AtomicInteger(0);
-    AtomicInteger bgCounterA2 = new AtomicInteger(0);
-    ExternalId extIdA2 = externalIdFactory.create("foo", "A-2", accountId);
+    ExternalId extIdA2 = getExternalIdFactory().create("foo", "A-2", accountId);
+    AtomicBoolean bgIndicatorA1ToA2 = new AtomicBoolean(false);
     AccountsUpdate update =
-        getAccountsUpdate(
+        getAccountsUpdateWithRunnables(
             Runnables.doNothing(),
             () -> {
+              if (bgIndicatorA1ToA2.get()) {
+                // In the Google architecture, this runnable might be called multiple times. Only
+                // do the replacement once.
+                return;
+              }
               try {
                 accountsUpdateProvider
                     .get()
                     .update(
-                        "Update External ID",
+                        "Update External ID A1->A2",
                         accountId,
                         u -> u.replaceExternalId(extIdA1, extIdA2));
               } catch (IOException | ConfigInvalidException | StorageException e) {
                 // Ignore, the expected exception is asserted later
               }
+              bgIndicatorA1ToA2.set(true);
             });
-    assertThat(bgCounterA1.get()).isEqualTo(0);
-    assertThat(bgCounterA2.get()).isEqualTo(0);
+
     assertThat(
             gApi.accounts().id(accountId.get()).getExternalIds().stream()
                 .map(i -> i.identity)
                 .collect(toSet()))
         .containsExactly(extIdA1.key().get());
 
-    ExternalId extIdB1 = externalIdFactory.create("foo", "B-1", accountId);
-    ExternalId extIdB2 = externalIdFactory.create("foo", "B-2", accountId);
+    ExternalId extIdB1 = getExternalIdFactory().create("foo", "B-1", accountId);
+    ExternalId extIdB2 = getExternalIdFactory().create("foo", "B-2", accountId);
+    AtomicBoolean bgIndicatorA1ToB1 = new AtomicBoolean(false);
+    AtomicBoolean bgIndicatorA2ToB2 = new AtomicBoolean(false);
     Optional<AccountState> updatedAccount =
         update.update(
-            "Update External ID",
+            "Conditionally update External IDs: A1->B1, A2->B2",
             accountId,
             (a, u) -> {
               if (a.externalIds().contains(extIdA1)) {
-                bgCounterA1.getAndIncrement();
+                bgIndicatorA1ToB1.set(true);
                 u.replaceExternalId(extIdA1, extIdB1);
               }
 
               if (a.externalIds().contains(extIdA2)) {
-                bgCounterA2.getAndIncrement();
+                bgIndicatorA2ToB2.set(true);
                 u.replaceExternalId(extIdA2, extIdB2);
               }
             });
 
-    assertThat(bgCounterA1.get()).isEqualTo(1);
-    assertThat(bgCounterA2.get()).isEqualTo(1);
+    assertThat(bgIndicatorA1ToB1.get()).isTrue();
+    assertThat(bgIndicatorA2ToB2.get()).isTrue();
 
     assertThat(updatedAccount).isPresent();
     assertThat(updatedAccount.get().externalIds()).containsExactly(extIdB2);
@@ -2414,38 +2474,24 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       testRefAction(
           () -> {
-            ExternalIdNotes extIdNotes =
-                ExternalIdNotes.load(
-                    allUsers,
-                    repo,
-                    externalIdFactory,
-                    authConfig.isUserNameCaseInsensitiveMigrationMode());
+            ExternalIdNotes extIdNotes = getExternalIdNotes(repo);
 
             ExternalId.Key key = externalIdKeyFactory.create("foo", "foo");
-            extIdNotes.insert(externalIdFactory.create(key, accountId));
+            extIdNotes.insert(getExternalIdFactory().create(key, accountId));
             try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
               extIdNotes.commit(update);
             }
             assertStaleAccountAndReindex(accountId);
 
-            extIdNotes =
-                ExternalIdNotes.load(
-                    allUsers,
-                    repo,
-                    externalIdFactory,
-                    authConfig.isUserNameCaseInsensitiveMigrationMode());
-            extIdNotes.upsert(externalIdFactory.createWithEmail(key, accountId, "foo@example.com"));
+            extIdNotes = getExternalIdNotes(repo);
+            extIdNotes.upsert(
+                getExternalIdFactory().createWithEmail(key, accountId, "foo@example.com"));
             try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
               extIdNotes.commit(update);
             }
             assertStaleAccountAndReindex(accountId);
 
-            extIdNotes =
-                ExternalIdNotes.load(
-                    allUsers,
-                    repo,
-                    externalIdFactory,
-                    authConfig.isUserNameCaseInsensitiveMigrationMode());
+            extIdNotes = getExternalIdNotes(repo);
             extIdNotes.delete(accountId, key);
             try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
               extIdNotes.commit(update);
@@ -2629,7 +2675,7 @@
   public void adminCanGenerateNewHttpPasswordForUser() throws Exception {
     requestScopeOperations.setApiUser(admin.id());
     sender.clear();
-    String newPassword = gApi.accounts().id(user.username()).generateHttpPassword();
+    String newPassword = gApi.accounts().id(user.id().get()).generateHttpPassword();
     assertThat(newPassword).isNotNull();
     assertThat(sender.getMessages()).hasSize(1);
     assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
@@ -2639,7 +2685,7 @@
   public void userCannotGenerateNewHttpPasswordForOtherUser() throws Exception {
     requestScopeOperations.setApiUser(user.id());
     assertThrows(
-        AuthException.class, () -> gApi.accounts().id(admin.username()).generateHttpPassword());
+        AuthException.class, () -> gApi.accounts().id(admin.id().get()).generateHttpPassword());
   }
 
   @Test
@@ -2654,7 +2700,7 @@
     requestScopeOperations.setApiUser(user.id());
     assertThrows(
         AuthException.class,
-        () -> gApi.accounts().id(admin.username()).setHttpPassword("my-new-password"));
+        () -> gApi.accounts().id(admin.id().get()).setHttpPassword("my-new-password"));
   }
 
   @Test
@@ -2670,7 +2716,7 @@
   public void userCannotRemoveHttpPasswordForOtherUser() throws Exception {
     requestScopeOperations.setApiUser(user.id());
     assertThrows(
-        AuthException.class, () -> gApi.accounts().id(admin.username()).setHttpPassword(null));
+        AuthException.class, () -> gApi.accounts().id(admin.id().get()).setHttpPassword(null));
   }
 
   @Test
@@ -2678,7 +2724,7 @@
     requestScopeOperations.setApiUser(admin.id());
     String httpPassword = "new-password-for-user";
     sender.clear();
-    assertThat(gApi.accounts().id(user.username()).setHttpPassword(httpPassword))
+    assertThat(gApi.accounts().id(user.id().get()).setHttpPassword(httpPassword))
         .isEqualTo(httpPassword);
     assertThat(sender.getMessages()).hasSize(1);
     assertThat(sender.getMessages().get(0).body()).contains("HTTP password was added or updated");
@@ -2688,7 +2734,7 @@
   public void adminCanRemoveHttpPasswordForUser() throws Exception {
     requestScopeOperations.setApiUser(admin.id());
     sender.clear();
-    assertThat(gApi.accounts().id(user.username()).setHttpPassword(null)).isNull();
+    assertThat(gApi.accounts().id(user.id().get()).setHttpPassword(null)).isNull();
     assertThat(sender.getMessages()).hasSize(1);
     assertThat(sender.getMessages().get(0).body()).contains("HTTP password was deleted");
   }
@@ -2710,17 +2756,13 @@
     String extId1String = "foo:bar";
     String extId2String = "foo:baz";
     ExternalId extId1 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse(extId1String), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse(extId1String), admin.id(), "1@foo.com");
     ExternalId extId2 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse(extId2String), user.id(), "2@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse(extId2String), user.id(), "2@foo.com");
 
-    ObjectId revBefore;
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      revBefore = repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
-    }
-
+    int initialCommits = countExternalIdsCommits();
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
             "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
@@ -2744,21 +2786,28 @@
         .contains(extId2String);
 
     // Ensure that we only applied one single commit.
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit after = rw.parseCommit(repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
-      assertThat(after.getParent(0).toObjectId()).isEqualTo(revBefore);
+    int afterUpdateCommits = countExternalIdsCommits();
+    assertThat(afterUpdateCommits).isEqualTo(initialCommits + 1);
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected int countExternalIdsCommits() throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        Git git = new Git(allUsersRepo)) {
+      ObjectId refsMetaExternalIdsHead =
+          allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
+      return Iterables.size(git.log().add(refsMetaExternalIdsHead).call());
     }
   }
 
   @Test
   public void externalIdBatchUpdates_fail_sameAccount() {
     ExternalId extId1 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
     ExternalId extId2 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
@@ -2777,11 +2826,11 @@
   @Test
   public void externalIdBatchUpdates_fail_duplicateKey() {
     ExternalId extIdAdmin =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
     ExternalId extIdUser =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), user.id(), "2@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), user.id(), "2@foo.com");
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
@@ -2799,11 +2848,11 @@
   @Test
   public void externalIdBatchUpdates_commitMsg_multipleAccounts() throws Exception {
     ExternalId extId1 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
     ExternalId extId2 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
@@ -2825,8 +2874,8 @@
   @Test
   public void externalIdBatchUpdates_commitMsg_singleAccount() throws Exception {
     ExternalId extId =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
 
     accountsUpdateProvider.get().update("foobar", admin.id(), (a, u) -> u.addExternalId(extId));
 
@@ -2848,17 +2897,21 @@
     gApi.accounts().id(foo.id().get()).addEmail(input);
 
     requestScopeOperations.setApiUser(user.id());
-    assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id("secondary"));
+    if (server.isUsernameSupported()) {
+      assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id("secondary"));
+    }
     assertThrows(
         ResourceNotFoundException.class, () -> gApi.accounts().id("secondary@example.com"));
     requestScopeOperations.setApiUser(admin.id());
-    assertThat(gApi.accounts().id("secondary").get()._accountId).isEqualTo(foo.id().get());
+    if (server.isUsernameSupported()) {
+      assertThat(gApi.accounts().id("secondary").get()._accountId).isEqualTo(foo.id().get());
+    }
     assertThat(gApi.accounts().id("secondary@example.com").get()._accountId)
         .isEqualTo(foo.id().get());
   }
 
   @Test
-  public void getAccountFromMetaId() throws RestApiException {
+  public void getAccountFromMetaId() throws Exception {
     AccountState preUpdateState = accountCache.get(admin.id()).get();
     requestScopeOperations.setApiUser(admin.id());
     gApi.accounts().self().setStatus("New status");
@@ -2898,7 +2951,7 @@
   }
 
   @Test
-  public void projectWatchesUpdate_refsUsersUpdated() throws RestApiException {
+  public void projectWatchesUpdate_refsUsersUpdated() throws Exception {
     AccountState preUpdateState = accountCache.get(admin.id()).get();
     requestScopeOperations.setApiUser(admin.id());
 
@@ -2921,16 +2974,16 @@
     AccountState preUpdateState = accountCache.get(admin.id()).get();
     requestScopeOperations.setApiUser(admin.id());
 
-    gApi.accounts().self().addEmail(newEmailInput("secondary@google.com"));
+    gApi.accounts().self().addEmail(newEmailInput("secondary@non.google"));
     assertExternalIds(
         admin.id(),
         ImmutableSet.of(
-            "mailto:admin@example.com", "username:admin", "mailto:secondary@google.com"));
+            "mailto:admin@example.com", "username:admin", "mailto:secondary@non.google"));
 
     AccountState updatedState1 = accountCache.get(admin.id()).get();
     assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState1.account().metaId());
 
-    gApi.accounts().self().deleteExternalIds(ImmutableList.of("mailto:secondary@google.com"));
+    gApi.accounts().self().deleteExternalIds(ImmutableList.of("mailto:secondary@non.google"));
 
     AccountState updatedState2 = accountCache.get(admin.id()).get();
     assertThat(updatedState1.account().metaId()).isNotEqualTo(updatedState2.account().metaId());
@@ -2941,7 +2994,7 @@
     AccountState preUpdateState = accountCache.get(admin.id()).get();
     requestScopeOperations.setApiUser(admin.id());
 
-    ExternalId externalId = externalIdFactory.create("custom", "value", admin.id());
+    ExternalId externalId = getExternalIdFactory().create("custom", "value", admin.id());
     accountsUpdateProvider
         .get()
         .update("Add External ID", admin.id(), (a, u) -> u.addExternalId(externalId));
@@ -2957,7 +3010,7 @@
     AccountState preUpdateState = accountCache.get(admin.id()).get();
     requestScopeOperations.setApiUser(admin.id());
 
-    ExternalId externalId = externalIdFactory.create("mailto", "admin@example.com", admin.id());
+    ExternalId externalId = createEmailExternalId(admin.id(), "admin@example.com");
     accountsUpdateProvider
         .get()
         .update("Remove External ID", admin.id(), (a, u) -> u.deleteExternalId(externalId));
@@ -2973,12 +3026,16 @@
     requestScopeOperations.setApiUser(admin.id());
 
     ExternalId externalId =
-        externalIdFactory.createWithEmail(
-            SCHEME_USERNAME, "admin", admin.id(), "secondary@example.com");
+        getExternalIdFactory()
+            .createWithEmail(
+                SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
     accountsUpdateProvider
         .get()
-        .update("Remove External ID", admin.id(), (a, u) -> u.updateExternalId(externalId));
-    assertExternalIds(admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin"));
+        .update("Update External ID", admin.id(), (a, u) -> u.updateExternalId(externalId));
+    assertExternalIds(
+        admin.id(),
+        ImmutableSet.of(
+            "mailto:admin@example.com", "username:admin", "mailto:secondary@non.google"));
 
     AccountState updatedState = accountCache.get(admin.id()).get();
     assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
@@ -2990,23 +3047,30 @@
     requestScopeOperations.setApiUser(admin.id());
 
     ExternalId externalId =
-        externalIdFactory.createWithEmail(
-            SCHEME_USERNAME, admin.username(), admin.id(), "secondary@example.com");
+        getExternalIdFactory()
+            .createWithEmail(
+                SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
+    ExternalId oldExternalId =
+        getExternalIdsReader().get(createEmailExternalId(admin.id(), admin.email()).key()).get();
     accountsUpdateProvider
         .get()
         .update(
-            "Remove External ID",
+            "Replace External ID",
             admin.id(),
-            (a, u) ->
-                u.replaceExternalId(
-                    externalIds
-                        .get(externalIdKeyFactory.create(SCHEME_USERNAME, admin.username()))
-                        .get(),
-                    externalId));
-    assertExternalIds(admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin"));
+            (a, u) -> {
+              u.replaceExternalId(oldExternalId, externalId);
+            });
+    assertExternalIds(admin.id(), ImmutableSet.of("mailto:secondary@non.google", "username:admin"));
 
     AccountState updatedState = accountCache.get(admin.id()).get();
-    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+    assertThat(accountCache.get(admin.id()).get()).isNotSameInstanceAs(preUpdateState);
+    if (preUpdateState.account().metaId() == null) {
+      // When the test is executed on google infrastructure, metaId should be either always set
+      // or always be null.
+      assertThat(updatedState.account().metaId()).isNull();
+    } else {
+      assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+    }
   }
 
   @Test
@@ -3017,10 +3081,11 @@
 
     requestScopeOperations.setApiUser(admin.id());
     ExternalId extId1 =
-        externalIdFactory.createWithEmail("custom", "admin-id", admin.id(), "admin-id@test.com");
+        getExternalIdFactory()
+            .createWithEmail("custom", "admin-id", admin.id(), "admin-id@test.com");
 
     ExternalId extId2 =
-        externalIdFactory.createWithEmail("custom", "user-id", user.id(), "user-id@test.com");
+        getExternalIdFactory().createWithEmail("custom", "user-id", user.id(), "user-id@test.com");
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
@@ -3028,7 +3093,7 @@
     AccountsUpdate.UpdateArguments ua2 =
         new AccountsUpdate.UpdateArguments(
             "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
@@ -3069,11 +3134,8 @@
         new AccountsUpdate.UpdateArguments(
             "Remove external Id",
             user.id(),
-            (a, u) ->
-                u.deleteExternalId(
-                    externalIdFactory.createWithEmail(
-                        SCHEME_MAILTO, user.email(), user.id(), user.email())));
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+            (a, u) -> u.deleteExternalId(createEmailExternalId(user.id(), user.email())));
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
@@ -3090,6 +3152,10 @@
         .isNotEqualTo(updatedUserState.account().metaId());
   }
 
+  protected ExternalId createEmailExternalId(Account.Id accountId, String email) {
+    return getExternalIdFactory().createWithEmail(SCHEME_MAILTO, email, accountId, email);
+  }
+
   @Test
   @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
   public void accountsCanSeeEachOtherThroughASharedExternalGroupOnlyWhenTheGroupIsMentionedInAcls()
@@ -3212,7 +3278,8 @@
     gApi.accounts().self().delete();
 
     requestScopeOperations.setApiUser(admin.id());
-    assertThat(externalIds.byEmails("deleted@internal.com", "deleted@external.com")).isEmpty();
+    assertThat(getExternalIdsReader().byEmails("deleted@internal.com", "deleted@external.com"))
+        .isEmpty();
 
     // Clean up the test framework
     accountCreator.evict(deleted.id());
@@ -3271,23 +3338,12 @@
     requestScopeOperations.setApiUser(deleted.id());
 
     gApi.accounts().self().starChange(triplet);
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(
-              repo.getRefDatabase()
-                  .getRefsByPrefix(RefNames.refsStarredChangesPrefix(r.getChange().getId())))
-          .hasSize(1);
+    assertThat(getStarredChangesCount(r.getChange().getId())).isEqualTo(1);
 
-      gApi.accounts().self().delete();
-    }
+    gApi.accounts().self().delete();
 
     // Reopen the repo to refresh RefDb
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(
-              repo.getRefDatabase()
-                  .getRefsByPrefix(RefNames.refsStarredChangesPrefix(r.getChange().getId())))
-          .isEmpty();
-    }
-
+    assertThat(getStarredChangesCount(r.getChange().getId())).isEqualTo(0);
     // Clean up the test framework
     accountCreator.evict(deleted.id());
   }
@@ -3329,27 +3385,35 @@
     requestScopeOperations.setApiUser(deleted.id());
 
     createDraft(r, PushOneCommit.FILE_NAME, "draft");
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(
-              repo.getRefDatabase()
-                  .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(r.getChange().getId())))
-          .hasSize(1);
+    assertThat(getUsersWithDraftsCount(r.getChange().getId())).isEqualTo(1);
+    gApi.accounts().self().delete();
 
-      gApi.accounts().self().delete();
-    }
-
-    // Reopen the repo to refresh RefDb
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(
-              repo.getRefDatabase()
-                  .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(r.getChange().getId())))
-          .isEmpty();
-    }
+    assertThat(getUsersWithDraftsCount(r.getChange().getId())).isEqualTo(0);
 
     // Clean up the test framework
     accountCreator.evict(deleted.id());
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected int getUsersWithDraftsCount(Change.Id changeId) throws Exception {
+    // The getStarredChangesCount and getUsersWithDraftsCount should be 2 distinct methods,
+    // because in google they can query data from a different storage (i.e. not from noteDb).
+    return getRefCount(RefNames.refsDraftCommentsPrefix(changeId));
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected int getStarredChangesCount(Change.Id changeId) throws Exception {
+    // The getStarredChangesCount and getDraftsCommentsCount should be 2 distinct methods,
+    // because in google they can query data from a different storage (i.e. not from noteDb).
+    return getRefCount(RefNames.refsStarredChangesPrefix(changeId));
+  }
+
+  private int getRefCount(String refPrefix) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return repo.getRefDatabase().getRefsByPrefix(refPrefix).size();
+    }
+  }
+
   @Test
   @SuppressWarnings("unused")
   public void deleteAccount_deletesReviewedFlags() throws Exception {
@@ -3410,7 +3474,7 @@
           }
 
           @Override
-          public Set<AccountGroup.UUID> getKnownGroups() {
+          public ImmutableSet<AccountGroup.UUID> getKnownGroups() {
             // Typically for external group backends it's too expensive to query all groups that the
             // user is a member of. Instead limit the group membership check to groups that are
             // guessed to be relevant.
@@ -3488,7 +3552,7 @@
   }
 
   private static void assertIteratorSize(int size, Iterator<?> it) {
-    List<?> lst = ImmutableList.copyOf(it);
+    ImmutableList<?> lst = ImmutableList.copyOf(it);
     assertThat(lst).hasSize(size);
   }
 
@@ -3524,11 +3588,11 @@
     }
 
     // Check raw external IDs.
-    Account.Id currAccountId = atrScope.get().getUser().getAccountId();
+    Account.Id currAccountId = localCtx.getContext().getUser().getAccountId();
     Iterable<String> expectedFps =
         expected.transform(k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
-    Iterable<String> actualFps =
-        externalIds.byAccount(currAccountId, SCHEME_GPGKEY).stream()
+    Set<String> actualFps =
+        getExternalIdsReader().byAccount(currAccountId, SCHEME_GPGKEY).stream()
             .map(e -> e.key().id())
             .collect(toSet());
     assertWithMessage("external IDs in database")
@@ -3549,7 +3613,7 @@
     assertWithMessage(id)
         .that(actual.fingerprint)
         .isEqualTo(Fingerprint.toString(expected.getPublicKey().getFingerprint()));
-    List<String> userIds = ImmutableList.copyOf(expected.getPublicKey().getUserIDs());
+    ImmutableList<String> userIds = ImmutableList.copyOf(expected.getPublicKey().getUserIDs());
     assertWithMessage(id).that(actual.userIds).containsExactlyElementsIn(userIds);
     String key = actual.key;
     assertWithMessage(id).that(key).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----\n");
@@ -3559,7 +3623,7 @@
   }
 
   private void addExternalIdEmail(TestAccount account, String email) throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       requireNonNull(email);
@@ -3570,7 +3634,8 @@
               account.id(),
               u ->
                   u.addExternalId(
-                      externalIdFactory.createWithEmail(name("test"), email, account.id(), email)));
+                      getExternalIdFactory()
+                          .createWithEmail(name("test"), email, account.id(), email)));
       accountIndexedCounter.assertReindexOf(account);
       requestScopeOperations.setApiUser(account.id());
     }
@@ -3585,14 +3650,14 @@
   private Map<String, GpgKeyInfo> addGpgKey(TestAccount account, String armored) throws Exception {
     return testRefAction(
         () -> {
-          AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+          AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
           try (Registration registration =
               extensionRegistry.newRegistration().add(accountIndexedCounter)) {
             Map<String, GpgKeyInfo> gpgKeys =
                 gApi.accounts()
-                    .id(account.username())
+                    .id(account.id().get())
                     .putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
-            accountIndexedCounter.assertReindexOf(gApi.accounts().id(account.username()).get());
+            accountIndexedCounter.assertReindexOf(gApi.accounts().id(account.id().get()).get());
             return gpgKeys;
           }
         });
@@ -3610,7 +3675,9 @@
       throws Exception {
     assertThat(info.name).isEqualTo(account.fullName());
     assertThat(info.email).isEqualTo(account.email());
-    assertThat(info.username).isEqualTo(account.username());
+    if (server.isUsernameSupported()) {
+      assertThat(info.username).isEqualTo(account.username());
+    }
     assertThat(info.status).isEqualTo(expectedStatus);
   }
 
@@ -3647,8 +3714,9 @@
         "login?account_id=" + accountId, HttpServletResponse.SC_MOVED_TEMPORARILY);
   }
 
-  private AccountsUpdate getAccountsUpdate(Runnable afterReadRevision, Runnable beforeCommit) {
-    return getAccountsUpdate(
+  private AccountsUpdate getAccountsUpdateWithRunnables(
+      Runnable afterReadRevision, Runnable beforeCommit) {
+    return getAccountsUpdateWithRunnables(
         afterReadRevision,
         beforeCommit,
         new RetryHelper(
@@ -3657,18 +3725,45 @@
             null,
             null,
             null,
+            null,
             exceptionHooks,
             r -> r.withBlockStrategy(noSleepBlockStrategy)));
   }
 
-  private AccountsUpdate getAccountsUpdate(
+  private ExternalIdNotes getExternalIdNotes(Repository allUsersRepo)
+      throws ConfigInvalidException, IOException {
+    return ExternalIdNotes.load(
+        allUsers,
+        allUsersRepo,
+        externalIdFactoryNoteDbImpl,
+        authConfig.isUserNameCaseInsensitiveMigrationMode());
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected ExternalIdFactory getExternalIdFactory() {
+    return externalIdFactoryNoteDbImpl;
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected ExternalIds getExternalIdsReader() {
+    return externalIdsNoteDbImpl;
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected AccountsUpdate getAccountsUpdateWithRunnables(
+      Runnable afterReadRevision, Runnable beforeCommit, RetryHelper retryHelper) {
+    return getAccountsUpdateNoteDbImplWithRunnables(afterReadRevision, beforeCommit, retryHelper);
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected final AccountsUpdateNoteDbImpl getAccountsUpdateNoteDbImplWithRunnables(
       Runnable afterReadRevision, Runnable beforeCommit, RetryHelper retryHelper) {
     return new AccountsUpdateNoteDbImpl(
         repoManager,
         gitReferenceUpdated,
         Optional.empty(),
         allUsers,
-        externalIds,
+        externalIdsNoteDbImpl,
         extIdNotesFactory,
         metaDataUpdateInternalFactory,
         retryHelper,
@@ -3685,6 +3780,11 @@
   }
 
   @UsedAt(UsedAt.Project.GOOGLE)
+  protected RefUpdateCounter createRefUpdateCounter() {
+    return new RefUpdateCounter();
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static class RefUpdateCounter implements GitReferenceUpdatedListener {
     private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
 
@@ -3715,10 +3815,18 @@
       assertRefUpdateFor(expectedRefUpdateCounts);
     }
 
-    void assertRefUpdateFor(Map<String, Long> expectedProjectRefUpdateCounts) {
-      assertThat(countsByProjectRefs.asMap())
-          .containsExactlyEntriesIn(expectedProjectRefUpdateCounts);
+    protected void assertRefUpdateFor(Map<String, Long> expectedProjectRefUpdateCounts) {
+      ImmutableMap<String, Long> exprectedFiltered =
+          expectedProjectRefUpdateCounts.entrySet().stream()
+              .filter(entry -> isRefSupported(entry.getKey()))
+              .collect(toImmutableMap(Map.Entry::getKey, Map.Entry::getValue));
+      assertThat(countsByProjectRefs.asMap()).containsExactlyEntriesIn(exprectedFiltered);
       clear();
     }
+
+    @UsedAt(UsedAt.Project.GOOGLE)
+    protected boolean isRefSupported(String expectedRefEntryKey) {
+      return true;
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
index 1693411..c56d907 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
@@ -134,7 +134,8 @@
   }
 
   private void loadAccountToCache(Account.Id accountId) {
-    accountCache.get(accountId);
+    @SuppressWarnings("unused")
+    var unused = accountCache.get(accountId);
   }
 
   private static AccountDelta.Builder newAccountDelta() {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
index 3ead608..b4c4595 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -49,16 +50,17 @@
     @Override
     protected void configure() {
       DynamicSet.bind(binder(), AccountActivationValidationListener.class).to(Validator.class);
+      bind(AccountListenersITValidator.class).to(Validator.class);
       DynamicSet.bind(binder(), AccountActivationListener.class).to(Listener.class);
     }
   }
 
-  Validator validator;
+  AccountListenersITValidator validator;
   Listener listener;
 
   @Before
   public void setUp() {
-    validator = plugin.getSysInjector().getInstance(Validator.class);
+    validator = plugin.getSysInjector().getInstance(AccountListenersITValidator.class);
 
     listener = plugin.getSysInjector().getInstance(Listener.class);
   }
@@ -128,8 +130,21 @@
     listener.assertNoMoreEvents();
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected interface AccountListenersITValidator extends AccountActivationValidationListener {
+    void failActivationValidations();
+
+    void failDeactivationValidations();
+
+    void assertNoMoreEvents();
+
+    void assertActivationValidation(int id);
+
+    void assertDeactivationValidation(int id);
+  }
+
   @Singleton
-  public static class Validator implements AccountActivationValidationListener {
+  public static final class Validator implements AccountListenersITValidator {
     private Integer lastIdActivationValidation;
     private Integer lastIdDeactivationValidation;
     private boolean failActivationValidations;
@@ -153,25 +168,30 @@
       }
     }
 
+    @Override
     public void failActivationValidations() {
       failActivationValidations = true;
     }
 
+    @Override
     public void failDeactivationValidations() {
       failDeactivationValidations = true;
     }
 
-    private void assertNoMoreEvents() {
+    @Override
+    public void assertNoMoreEvents() {
       assertThat(lastIdActivationValidation).isNull();
       assertThat(lastIdDeactivationValidation).isNull();
     }
 
-    private void assertActivationValidation(int id) {
+    @Override
+    public void assertActivationValidation(int id) {
       assertThat(lastIdActivationValidation).isEqualTo(id);
       lastIdActivationValidation = null;
     }
 
-    private void assertDeactivationValidation(int id) {
+    @Override
+    public void assertDeactivationValidation(int id) {
       assertThat(lastIdDeactivationValidation).isEqualTo(id);
       lastIdDeactivationValidation = null;
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index 39fa918..efc7e0f 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.OptionalSubject.optionals;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.entities.RefNames.REFS_EXTERNAL_IDS;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GOOGLE_OAUTH;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -29,6 +28,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
@@ -41,6 +41,7 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.SetInactiveFlag;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
@@ -52,11 +53,9 @@
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.util.Providers;
-import java.io.IOException;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
@@ -594,7 +593,7 @@
 
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
-    assertThat(thrown).hasMessageThat().contains("Cannot assign external ID \"username:foo\" to");
+    assertThat(thrown).hasCauseThat().isInstanceOf(DuplicateExternalIdKeyException.class);
   }
 
   @Test
@@ -648,9 +647,7 @@
 
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("Cannot assign external ID \"username:foo\" to account");
+    assertThat(thrown).hasCauseThat().isInstanceOf(DuplicateExternalIdKeyException.class);
   }
 
   @Test
@@ -799,25 +796,22 @@
         "Create Test Account",
         accountId,
         u -> u.addExternalId(externalIdFactory.create(mailExtIdKey, accountId)));
-
     accountManager.link(accountId, authRequestFactory.createForEmail(email1));
+    int initialCommits = countExternalIdsCommits();
 
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        Git git = new Git(allUsersRepo)) {
-      int initialCommits = getCommitsInExternalIds(git, allUsersRepo);
+    accountManager.updateLink(accountId, authRequestFactory.createForEmail(email2));
 
-      accountManager.updateLink(accountId, authRequestFactory.createForEmail(email2));
-
-      int afterUpdateCommits = getCommitsInExternalIds(git, allUsersRepo);
-
-      assertThat(afterUpdateCommits).isEqualTo(initialCommits + 1);
-    }
+    int afterUpdateCommits = countExternalIdsCommits();
+    assertThat(afterUpdateCommits).isEqualTo(initialCommits + 1);
   }
 
-  private static int getCommitsInExternalIds(Git git, Repository allUsersRepo)
-      throws GitAPIException, IOException {
-    ObjectId refsMetaExternalIdsHead = allUsersRepo.exactRef(REFS_EXTERNAL_IDS).getObjectId();
-    return Iterables.size(git.log().add(refsMetaExternalIdsHead).call());
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected int countExternalIdsCommits() throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        Git git = new Git(allUsersRepo)) {
+      ObjectId refsMetaExternalIdsHead = allUsersRepo.exactRef(REFS_EXTERNAL_IDS).getObjectId();
+      return Iterables.size(git.log().add(refsMetaExternalIdsHead).call());
+    }
   }
 
   private void assertNoSuchExternalIds(ExternalId.Key... extIdKeys) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 9b77b01..ead4c40 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -127,7 +127,7 @@
       assertThat(info.auth.useContributorAgreements).isTrue();
       assertThat(info.auth.contributorAgreements).hasSize(2);
       // Sort to get a stable assertion as the API does not guarantee ordering.
-      List<AgreementInfo> agreements =
+      ImmutableList<AgreementInfo> agreements =
           ImmutableList.sortedCopyOf(comparing(a -> a.name), info.auth.contributorAgreements);
       assertAgreement(agreements.get(0), caAutoVerify);
       assertAgreement(agreements.get(1), caNoAutoVerify);
@@ -187,8 +187,11 @@
   public void listAgreementPermission() throws Exception {
     assume().that(isContributorAgreementsEnabled()).isTrue();
     requestScopeOperations.setApiUser(admin.id());
+
     // Allowed.
-    gApi.accounts().id(user.id().get()).listAgreements();
+    @SuppressWarnings("unused")
+    var unused = gApi.accounts().id(user.id().get()).listAgreements();
+
     requestScopeOperations.setApiUser(user.id());
 
     // Not allowed.
@@ -202,7 +205,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.accounts().id("admin").signAgreement(caAutoVerify.getName()));
+            () -> gApi.accounts().id(admin.id().get()).signAgreement(caAutoVerify.getName()));
     assertThat(thrown).hasMessageThat().contains("not allowed to enter contributor agreement");
   }
 
@@ -391,7 +394,9 @@
   @GerritConfig(name = "auth.contributorAgreements", value = "true")
   public void anonymousAccessServerInfoEvenWithCLAs() throws Exception {
     requestScopeOperations.setApiUserAnonymous();
-    gApi.config().server().getInfo();
+
+    @SuppressWarnings("unused")
+    var unused = gApi.config().server().getInfo();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
index a442ddd..e3380c0 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
@@ -28,7 +28,7 @@
   @Test
   public void getDiffPreferences() throws Exception {
     DiffPreferencesInfo d = DiffPreferencesInfo.defaults();
-    DiffPreferencesInfo o = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
+    DiffPreferencesInfo o = gApi.accounts().id(admin.id().get()).getDiffPreferences();
     assertPrefs(o, d);
   }
 
@@ -62,13 +62,13 @@
     i.matchBrackets ^= true;
     i.lineWrapping ^= true;
 
-    DiffPreferencesInfo o = gApi.accounts().id(admin.id().toString()).setDiffPreferences(i);
+    DiffPreferencesInfo o = gApi.accounts().id(admin.id().get()).setDiffPreferences(i);
     assertPrefs(o, i);
 
     // Partially fill input record
     i = new DiffPreferencesInfo();
     i.tabSize = 42;
-    DiffPreferencesInfo a = gApi.accounts().id(admin.id().toString()).setDiffPreferences(i);
+    DiffPreferencesInfo a = gApi.accounts().id(admin.id().get()).setDiffPreferences(i);
     assertPrefs(a, o, "tabSize");
     assertThat(a.tabSize).isEqualTo(42);
   }
@@ -85,7 +85,7 @@
     update.fontSize = newFontSize;
     gApi.config().server().setDefaultDiffPreferences(update);
 
-    DiffPreferencesInfo o = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
+    DiffPreferencesInfo o = gApi.accounts().id(admin.id().get()).getDiffPreferences();
 
     // assert configured defaults
     assertThat(o.lineLength).isEqualTo(newLineLength);
@@ -104,29 +104,29 @@
     update.lineLength = configuredDefaultLineLength;
     gApi.config().server().setDefaultDiffPreferences(update);
 
-    DiffPreferencesInfo o = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
+    DiffPreferencesInfo o = gApi.accounts().id(admin.id().get()).getDiffPreferences();
     assertThat(o.lineLength).isEqualTo(configuredDefaultLineLength);
     assertPrefs(o, d, "lineLength");
 
     int newLineLength = configuredDefaultLineLength + 10;
     DiffPreferencesInfo i = new DiffPreferencesInfo();
     i.lineLength = newLineLength;
-    DiffPreferencesInfo a = gApi.accounts().id(admin.id().toString()).setDiffPreferences(i);
+    DiffPreferencesInfo a = gApi.accounts().id(admin.id().get()).setDiffPreferences(i);
     assertThat(a.lineLength).isEqualTo(newLineLength);
     assertPrefs(a, d, "lineLength");
 
-    a = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
+    a = gApi.accounts().id(admin.id().get()).getDiffPreferences();
     assertThat(a.lineLength).isEqualTo(newLineLength);
     assertPrefs(a, d, "lineLength");
 
     // overwrite the configured default with original hard-coded default
     i = new DiffPreferencesInfo();
     i.lineLength = d.lineLength;
-    a = gApi.accounts().id(admin.id().toString()).setDiffPreferences(i);
+    a = gApi.accounts().id(admin.id().get()).setDiffPreferences(i);
     assertThat(a.lineLength).isEqualTo(d.lineLength);
     assertPrefs(a, d, "lineLength");
 
-    a = gApi.accounts().id(admin.id().toString()).getDiffPreferences();
+    a = gApi.accounts().id(admin.id().get()).getDiffPreferences();
     assertThat(a.lineLength).isEqualTo(d.lineLength);
     assertPrefs(a, d, "lineLength");
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
index f142c08..6802333 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
@@ -25,7 +25,7 @@
 public class EditPreferencesIT extends AbstractDaemonTest {
   @Test
   public void getSetEditPreferences() throws Exception {
-    EditPreferencesInfo out = gApi.accounts().id(admin.id().toString()).getEditPreferences();
+    EditPreferencesInfo out = gApi.accounts().id(admin.id().get()).getEditPreferences();
 
     assertThat(out.lineLength).isEqualTo(100);
     assertThat(out.indentUnit).isEqualTo(2);
@@ -58,7 +58,7 @@
     out.autoCloseBrackets = true;
     out.showBase = true;
 
-    EditPreferencesInfo info = gApi.accounts().id(admin.id().toString()).setEditPreferences(out);
+    EditPreferencesInfo info = gApi.accounts().id(admin.id().get()).setEditPreferences(out);
 
     assertEditPreferences(info, out);
 
@@ -66,7 +66,7 @@
     EditPreferencesInfo in = new EditPreferencesInfo();
     in.tabSize = 42;
 
-    info = gApi.accounts().id(admin.id().toString()).setEditPreferences(in);
+    info = gApi.accounts().id(admin.id().get()).setEditPreferences(in);
 
     out.tabSize = in.tabSize;
     assertEditPreferences(info, out);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index eed9de8..0b28f6f 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -53,7 +53,7 @@
 
   @Test
   public void getAndSetPreferences() throws Exception {
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).getPreferences();
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().get()).getPreferences();
     assertPrefs(o, GeneralPreferencesInfo.defaults(), "my", "changeTable");
     assertThat(o.my)
         .containsExactly(
@@ -84,6 +84,7 @@
     i.muteCommonPathPrefixes ^= true;
     i.signedOffBy ^= true;
     i.allowBrowserNotifications ^= false;
+    i.allowSuggestCodeWhileCommenting ^= false;
     i.diffPageSidebar = "plugin-insight";
     i.diffView = DiffView.UNIFIED_DIFF;
     i.my = new ArrayList<>();
@@ -91,12 +92,13 @@
     i.changeTable = new ArrayList<>();
     i.changeTable.add("Status");
 
-    o = gApi.accounts().id(user42.id().toString()).setPreferences(i);
+    o = gApi.accounts().id(user42.id().get()).setPreferences(i);
     assertPrefs(o, i, "my");
     assertThat(o.my).containsExactlyElementsIn(i.my);
     assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
     assertThat(o.theme).isEqualTo(i.theme);
     assertThat(o.allowBrowserNotifications).isEqualTo(i.allowBrowserNotifications);
+    assertThat(o.allowSuggestCodeWhileCommenting).isEqualTo(i.allowSuggestCodeWhileCommenting);
     assertThat(o.diffPageSidebar).isEqualTo(i.diffPageSidebar);
     assertThat(o.disableKeyboardShortcuts).isEqualTo(i.disableKeyboardShortcuts);
   }
@@ -109,7 +111,7 @@
     update.changesPerPage = newChangesPerPage;
     gApi.config().server().setDefaultPreferences(update);
 
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).getPreferences();
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().get()).getPreferences();
 
     // assert configured defaults
     assertThat(o.changesPerPage).isEqualTo(newChangesPerPage);
@@ -126,29 +128,29 @@
     update.changesPerPage = configuredChangesPerPage;
     gApi.config().server().setDefaultPreferences(update);
 
-    GeneralPreferencesInfo o = gApi.accounts().id(admin.id().toString()).getPreferences();
+    GeneralPreferencesInfo o = gApi.accounts().id(admin.id().get()).getPreferences();
     assertThat(o.changesPerPage).isEqualTo(configuredChangesPerPage);
     assertPrefs(o, d, "my", "changeTable", "changesPerPage");
 
     int newChangesPerPage = configuredChangesPerPage * 2;
     GeneralPreferencesInfo i = new GeneralPreferencesInfo();
     i.changesPerPage = newChangesPerPage;
-    GeneralPreferencesInfo a = gApi.accounts().id(admin.id().toString()).setPreferences(i);
+    GeneralPreferencesInfo a = gApi.accounts().id(admin.id().get()).setPreferences(i);
     assertThat(a.changesPerPage).isEqualTo(newChangesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
 
-    a = gApi.accounts().id(admin.id().toString()).getPreferences();
+    a = gApi.accounts().id(admin.id().get()).getPreferences();
     assertThat(a.changesPerPage).isEqualTo(newChangesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
 
     // overwrite the configured default with original hard-coded default
     i = new GeneralPreferencesInfo();
     i.changesPerPage = d.changesPerPage;
-    a = gApi.accounts().id(admin.id().toString()).setPreferences(i);
+    a = gApi.accounts().id(admin.id().get()).setPreferences(i);
     assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
 
-    a = gApi.accounts().id(admin.id().toString()).getPreferences();
+    a = gApi.accounts().id(admin.id().get()).getPreferences();
     assertThat(a.changesPerPage).isEqualTo(d.changesPerPage);
     assertPrefs(a, d, "my", "changeTable", "changesPerPage");
   }
@@ -162,7 +164,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+            () -> gApi.accounts().id(user42.id().get()).setPreferences(i));
     assertThat(thrown).hasMessageThat().contains("name for menu item is required");
   }
 
@@ -175,7 +177,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+            () -> gApi.accounts().id(user42.id().get()).setPreferences(i));
     assertThat(thrown).hasMessageThat().contains("URL for menu item is required");
   }
 
@@ -185,7 +187,7 @@
     i.my = new ArrayList<>();
     i.my.add(new MenuItem(" name\t", " url\t", " _blank\t", " id\t"));
 
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).setPreferences(i);
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().get()).setPreferences(i);
     assertThat(o.my).containsExactly(new MenuItem("name", "url", "_blank", "id"));
   }
 
@@ -197,7 +199,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.accounts().id(user42.id().toString()).setPreferences(i));
+            () -> gApi.accounts().id(user42.id().get()).setPreferences(i));
     assertThat(thrown)
         .hasMessageThat()
         .contains("Unsupported download scheme: " + i.downloadScheme);
@@ -211,10 +213,10 @@
       GeneralPreferencesInfo i = GeneralPreferencesInfo.defaults();
       i.downloadScheme = schemeName;
 
-      GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).setPreferences(i);
+      GeneralPreferencesInfo o = gApi.accounts().id(user42.id().get()).setPreferences(i);
       assertThat(o.downloadScheme).isEqualTo(schemeName);
 
-      o = gApi.accounts().id(user42.id().toString()).getPreferences();
+      o = gApi.accounts().id(user42.id().get()).getPreferences();
       assertThat(o.downloadScheme).isEqualTo(schemeName);
     }
   }
@@ -225,7 +227,7 @@
     // becomes unsupported.
     setDownloadScheme();
 
-    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().toString()).getPreferences();
+    GeneralPreferencesInfo o = gApi.accounts().id(user42.id().get()).getPreferences();
     assertThat(o.downloadScheme).isNull();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
index 0309646..984b32d 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
@@ -18,6 +18,8 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
@@ -34,12 +36,8 @@
 
   @Test
   public void fromAccountUpdate() throws Exception {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      String messageId = messageIdGenerator.fromAccountUpdate(admin.id()).id();
-      String sha1 =
-          repo.getRefDatabase().findRef(RefNames.refsUsers(admin.id())).getObjectId().getName();
-      assertThat(sha1).isEqualTo(messageId);
-    }
+    String messageId = messageIdGenerator.fromAccountUpdate(admin.id()).id();
+    validateAccountUpdateMessageId(messageId, admin.id());
   }
 
   @Test
@@ -78,4 +76,13 @@
             messageIdGenerator.fromReasonAccountIdAndTimestamp(reason, admin.id(), timestamp).id())
         .isEqualTo(reason + "-" + admin.id().toString() + "-" + timestamp.toString());
   }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected void validateAccountUpdateMessageId(String messageId, Account.Id id) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String sha1 =
+          repo.getRefDatabase().findRef(RefNames.refsUsers(admin.id())).getObjectId().getName();
+      assertThat(sha1).isEqualTo(messageId);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index b80ff9b..f0f262f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -76,10 +76,10 @@
 
   @Test
   public void batchAbandon() throws Exception {
-    CurrentUser user = atrScope.get().getUser();
+    CurrentUser user = localCtx.getContext().getUser();
     PushOneCommit.Result a = createChange();
     PushOneCommit.Result b = createChange();
-    List<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
+    ImmutableList<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
     batchAbandon.batchAbandon(batchUpdateFactory, a.getChange().project(), user, list, "deadbeef");
 
     ChangeInfo info = get(a.getChangeId(), MESSAGES);
@@ -106,10 +106,10 @@
     TestRepository<InMemoryRepository> project1 = cloneProject(Project.nameKey(project1Name));
     TestRepository<InMemoryRepository> project2 = cloneProject(Project.nameKey(project2Name));
 
-    CurrentUser user = atrScope.get().getUser();
+    CurrentUser user = localCtx.getContext().getUser();
     PushOneCommit.Result a = createChange(project1, "master", "x", "x", "x", "");
     PushOneCommit.Result b = createChange(project2, "master", "x", "x", "x", "");
-    List<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
+    ImmutableList<ChangeData> list = ImmutableList.of(a.getChange(), b.getChange());
     ResourceConflictException thrown =
         assertThrows(
             ResourceConflictException.class,
@@ -177,8 +177,8 @@
     assertThat(query("is:abandoned")).isEmpty();
 
     // submit one of the conflicting changes
-    gApi.changes().id(id3).current().review(ReviewInput.approve());
-    gApi.changes().id(id3).current().submit();
+    gApi.changes().id(project.get(), id3).current().review(ReviewInput.approve());
+    gApi.changes().id(project.get(), id3).current().submit();
     assertThat(toChangeNumbers(query("is:merged"))).containsExactly(id3);
     assertThat(toChangeNumbers(query("-is:mergeable"))).containsExactly(id4);
 
@@ -221,8 +221,8 @@
     assertThat(query("is:abandoned")).isEmpty();
 
     // submit one of the conflicting changes
-    gApi.changes().id(id3).current().review(ReviewInput.approve());
-    gApi.changes().id(id3).current().submit();
+    gApi.changes().id(project.get(), id3).current().review(ReviewInput.approve());
+    gApi.changes().id(project.get(), id3).current().submit();
     assertThat(toChangeNumbers(query("is:merged"))).containsExactly(id3);
 
     BadRequestException thrown =
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
index e9fac73..78361a1 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -28,7 +28,6 @@
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.hash.Hashing;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
@@ -57,6 +56,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.restapi.change.ApplyPatchUtil;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -220,6 +220,25 @@
   }
 
   @Test
+  public void applyDecodedPatchConsistsOfBase64CharsOnly_success() throws Exception {
+    final String deletedFileName = "file_to_be_deleted";
+    final String deletedFileOriginalContent =
+        "The deletion patch of this file only contain valid Base64 chars.\n"
+            + "However, the patch is not Base64-encoded.\n";
+    final String deletedFileDiff =
+        "diff --git a/file_to_be_deleted b/file_to_be_deleted\n"
+            + "--- a/file_to_be_deleted\n"
+            + "+++ /dev/null\n";
+    initBaseWithFile(deletedFileName, deletedFileOriginalContent);
+    ApplyPatchPatchSetInput in = buildInput(deletedFileDiff);
+
+    ChangeInfo result = applyPatch(in);
+
+    DiffInfo diff = fetchDiffForFile(result, deletedFileName);
+    assertDiffForDeletedFile(diff, deletedFileName, deletedFileOriginalContent);
+  }
+
+  @Test
   public void applyGerritBasedPatchWithSingleFile_success() throws Exception {
     String head = getHead(repo(), HEAD).name();
     createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
@@ -363,7 +382,7 @@
                 + "\n[[[Original patch trimmed due to size. Decoded string size: "
                 + removePatchHeader(patch).length()
                 + ". Decoded string SHA1: "
-                + Hashing.sha1().hashString(removePatchHeader(patch), UTF_8)
+                + ApplyPatchUtil.sha1(removePatchHeader(patch))
                 + ".]]]"
                 + "\n\nChange-Id: "
                 + result.changeId
@@ -591,6 +610,24 @@
   }
 
   @Test
+  public void longCommitMessage_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 =
+        "Looooooooooooooooooong 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 =
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index cab92aa..4758254 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -17,7 +17,6 @@
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
@@ -96,6 +95,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -149,6 +149,7 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.CommitMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
@@ -188,6 +189,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.AbstractModule;
@@ -215,6 +217,7 @@
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
@@ -266,6 +269,7 @@
     assertThat(c.changeId).isEqualTo(r.getChangeId());
     assertThat(c.created).isEqualTo(c.updated);
     assertThat(c._number).isEqualTo(r.getChange().getId().get());
+    assertThat(c.currentRevisionNumber).isEqualTo(r.getPatchSetId().get());
 
     assertThat(c.owner._accountId).isEqualTo(admin.id().get());
     assertThat(c.owner.name).isNull();
@@ -306,7 +310,9 @@
     String triplet = project.get() + "~master~" + result.getChangeId();
     CacheStats startIntra = cloneStats(intraCache.stats());
     CacheStats startSummary = cloneStats(diffSummaryCache.stats());
-    gApi.changes().id(triplet).get(ImmutableList.of(ListChangesOption.SKIP_DIFFSTAT));
+
+    @SuppressWarnings("unused")
+    var unused = gApi.changes().id(triplet).get(ImmutableList.of(ListChangesOption.SKIP_DIFFSTAT));
 
     assertThat(intraCache.stats()).since(startIntra).hasMissCount(0);
     assertThat(intraCache.stats()).since(startIntra).hasHitCount(0);
@@ -509,7 +515,7 @@
             .reviewer("byemail3@example.com", CC, false)
             .reviewer("byemail4@example.com", CC, false);
     ReviewResult result = gApi.changes().id(changeId).current().review(in);
-    assertThat(result.changeInfo).isNull();
+    assertThat(result.changeInfo).isNotNull();
     assertThat(result.reviewers).isNotEmpty();
     ChangeInfo info = gApi.changes().id(changeId).get();
     Function<Collection<AccountInfo>, Collection<String>> toEmails =
@@ -821,7 +827,9 @@
   public void getAmbiguous() throws Exception {
     PushOneCommit.Result r1 = createChange();
     String changeId = r1.getChangeId();
-    gApi.changes().id(changeId).get();
+
+    @SuppressWarnings("unused")
+    var unused = gApi.changes().id(changeId).get();
 
     BranchInput b = new BranchInput();
     b.revision = repo().exactRef("HEAD").getObjectId().name();
@@ -1119,17 +1127,20 @@
     gApi.changes().id(r.getChangeId()).current().createDraft(dri);
     Change.Id num = r.getChange().getId();
 
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftComments(num, user.id())))
-          .isNotEmpty();
-    }
+    assertThat(getDraftsCountForChange(num, user.id())).isGreaterThan(0);
 
     requestScopeOperations.setApiUser(admin.id());
 
     gApi.changes().id(r.getChangeId()).delete();
+    assertThat(getDraftsCountForChange(num, user.id())).isEqualTo(0);
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected int getDraftsCountForChange(Change.Id changeId, Account.Id accountId) throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      assertThat(repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftComments(num, user.id())))
-          .isEmpty();
+      return repo.getRefDatabase()
+          .getRefsByPrefix(RefNames.refsDraftComments(changeId, accountId))
+          .size();
     }
   }
 
@@ -1146,11 +1157,13 @@
         .modifyFile(FILE_NAME, RawInputUtil.create("foo".getBytes(UTF_8)));
 
     requestScopeOperations.setApiUser(admin.id());
+    String expected = RefNames.refsUsers(user.id()) + "/edit-" + result.getChange().getId() + "/1";
     try (Repository repo = repoManager.openRepository(project)) {
-      String expected =
-          RefNames.refsUsers(user.id()) + "/edit-" + result.getChange().getId() + "/1";
       assertThat(repo.getRefDatabase().getRefsByPrefix(expected)).isNotEmpty();
       gApi.changes().id(changeId).delete();
+    }
+    // On google infra, repo should be reopened for getting updated refs.
+    try (Repository repo = repoManager.openRepository(project)) {
       assertThat(repo.getRefDatabase().getRefsByPrefix(expected)).isEmpty();
     }
   }
@@ -1177,7 +1190,6 @@
   @Test
   public void attentionSetListener_firesOnChange() throws Exception {
     PushOneCommit.Result r1 = createChange();
-    AttentionSetInput addUser = new AttentionSetInput(user.email(), "some reason");
     TestAttentionSetListener attentionSetListener = new TestAttentionSetListener();
 
     try (Registration registration =
@@ -1191,12 +1203,24 @@
           .usersAdded()
           .forEach(u -> assertThat(u).isEqualTo(user.id().get()));
 
+      // Adding the user with the same reason doesn't fire an event.
+      AttentionSetInput addUser = new AttentionSetInput(user.email(), "Reviewer was added");
       gApi.changes().id(r1.getChangeId()).addToAttentionSet(addUser);
       assertThat(attentionSetListener.firedCount).isEqualTo(1);
 
-      gApi.changes().id(r1.getChangeId()).attention(user.username()).remove(addUser);
-
+      // Adding the user with a different reason fires an event.
+      addUser = new AttentionSetInput(user.email(), "some reason");
+      gApi.changes().id(r1.getChangeId()).addToAttentionSet(addUser);
       assertThat(attentionSetListener.firedCount).isEqualTo(2);
+      assertThat(attentionSetListener.lastEvent.usersAdded().size()).isEqualTo(1);
+      attentionSetListener
+          .lastEvent
+          .usersAdded()
+          .forEach(u -> assertThat(u).isEqualTo(user.id().get()));
+
+      // Removing the user fires an event.
+      gApi.changes().id(r1.getChangeId()).attention(user.username()).remove(addUser);
+      assertThat(attentionSetListener.firedCount).isEqualTo(3);
       assertThat(attentionSetListener.lastEvent.usersAdded()).isEmpty();
       assertThat(attentionSetListener.lastEvent.usersRemoved().size()).isEqualTo(1);
       attentionSetListener
@@ -1271,7 +1295,7 @@
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
     assertThat(change.reviewers.get(REVIEWER)).isNull();
 
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.from().name()).isEqualTo("Administrator (Code Review)");
@@ -1341,7 +1365,7 @@
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
     assertThat(change.reviewers.get(CC)).isNull();
 
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
@@ -1440,7 +1464,9 @@
     assertThat(r.reviewers).hasSize(1);
     ReviewerInfo reviewer = r.reviewers.get(0);
     assertThat(reviewer._accountId).isEqualTo(id.get());
-    assertThat(reviewer.username).isEqualTo(username);
+    if (server.isUsernameSupported()) {
+      assertThat(reviewer.username).isEqualTo(username);
+    }
   }
 
   @Test
@@ -1459,7 +1485,9 @@
     assertThat(r.reviewers).hasSize(1);
     ReviewerInfo reviewer = r.reviewers.get(0);
     assertThat(reviewer._accountId).isEqualTo(id.get());
-    assertThat(reviewer.username).isEqualTo(username);
+    if (server.isUsernameSupported()) {
+      assertThat(reviewer.username).isEqualTo(username);
+    }
   }
 
   @Test
@@ -1468,20 +1496,20 @@
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
     PushOneCommit.Result result = createChange();
-    String username = "user@domain.com";
-    Account.Id id = accountOperations.newAccount().username(username).inactive().create();
+    String email = "user@domain.com";
+    Account.Id id = accountOperations.newAccount().preferredEmail(email).inactive().create();
 
     ReviewerInput in = new ReviewerInput();
-    in.reviewer = username;
+    in.reviewer = email;
     in.state = ReviewerState.CC;
     ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
-    assertThat(r.input).isEqualTo(username);
+    assertThat(r.input).isEqualTo(email);
     assertThat(r.error).isNull();
     assertThat(r.ccs).hasSize(1);
     AccountInfo reviewer = r.ccs.get(0);
     assertThat(reviewer._accountId).isEqualTo(id.get());
-    assertThat(reviewer.username).isEqualTo(username);
+    assertThat(reviewer.email).isEqualTo(email);
   }
 
   @Test
@@ -1516,7 +1544,7 @@
 
     addReviewer.call(r.getChangeId(), user.email());
 
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
@@ -1570,17 +1598,22 @@
 
     String username1 = name("user1");
     String email1 = username1 + "@example.com";
-    accountOperations
-        .newAccount()
-        .username(username1)
-        .preferredEmail(email1)
-        .fullname("User1")
-        .create();
+    Account.Id user1Id =
+        accountOperations
+            .newAccount()
+            .username(username1)
+            .preferredEmail(email1)
+            .fullname("User1")
+            .create();
     in.reviewer = email1;
     in.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(in);
-    assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a.username))
-        .containsExactly(user.username(), username1);
+    if (server.isUsernameSupported()) {
+      assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a.username))
+          .containsExactly(user.username(), username1);
+    }
+    assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a._accountId))
+        .containsExactly(user.id().get(), user1Id.get());
   }
 
   @Test
@@ -1644,7 +1677,7 @@
     in.reviewer = "abc";
     gApi.changes().id(r.getChangeId()).addReviewer(in.reviewer);
 
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(Address.create(fullname, email));
@@ -1705,7 +1738,7 @@
     in.reviewer = testGroup;
     gApi.changes().id(r.getChangeId()).addReviewer(in.reviewer);
 
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(Address.create(myGroupUserFullname, myGroupUserEmail));
@@ -1804,7 +1837,9 @@
   @Test
   public void implicitlyCcOnNonVotingReviewForUserWithoutUserNamePgStyle() throws Exception {
     com.google.gerrit.acceptance.TestAccount accountWithoutUsername = accountCreator.create();
-    assertThat(accountWithoutUsername.username()).isNull();
+    if (server.isUsernameSupported()) {
+      assertThat(accountWithoutUsername.username()).isNull();
+    }
     testImplicitlyCcOnNonVotingReviewPgStyle(accountWithoutUsername);
   }
 
@@ -2355,7 +2390,7 @@
         .reviewer(user.id().toString())
         .deleteVote(LabelId.CODE_REVIEW);
 
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message msg = messages.get(0);
     assertThat(msg.rcpt()).containsExactly(user.getNameEmail());
@@ -2969,7 +3004,8 @@
     c = gApi.changes().id(r.getChangeId()).info();
     assertThat(c.submitted).isNotNull();
     assertThat(c.submitter).isNotNull();
-    assertThat(c.submitter._accountId).isEqualTo(atrScope.get().getUser().getAccountId().get());
+    assertThat(c.submitter._accountId)
+        .isEqualTo(localCtx.getContext().getUser().getAccountId().get());
   }
 
   @Test
@@ -3452,7 +3488,9 @@
     // permittedVotingRange is not served if DETAILED_LABELS is not requested.
     assertThat(codeReviewApproval.permittedVotingRange).isNull();
     assertThat(codeReviewApproval.value).isEqualTo(1);
-    assertThat(codeReviewApproval.username).isEqualTo(admin.username());
+    if (server.isUsernameSupported()) {
+      assertThat(codeReviewApproval.username).isEqualTo(admin.username());
+    }
 
     // Add another +1 vote as user
     requestScopeOperations.setApiUser(user.id());
@@ -3468,8 +3506,12 @@
         .containsExactly(null, null);
     assertThat(codeReviewApprovals.stream().map(a -> a.value).collect(toList()))
         .containsExactly(1, 1);
-    assertThat(codeReviewApprovals.stream().map(a -> a.username).collect(toList()))
-        .containsExactly(admin.username(), user.username());
+    if (server.isUsernameSupported()) {
+      assertThat(codeReviewApprovals.stream().map(a -> a.username).collect(toList()))
+          .containsExactly(admin.username(), user.username());
+    }
+    assertThat(codeReviewApprovals.stream().map(a -> a._accountId).collect(toList()))
+        .containsExactly(admin.id().get(), user.id().get());
   }
 
   @Test
@@ -3584,12 +3626,16 @@
 
     assertThat(codeReviewApprovals).hasSize(1);
     assertThat(codeReviewApprovals.get(0).value).isEqualTo(2);
-    assertThat(codeReviewApprovals.get(0).username).isEqualTo(admin.username());
+    if (server.isUsernameSupported()) {
+      assertThat(codeReviewApprovals.get(0).username).isEqualTo(admin.username());
+    }
     assertThat(codeReviewApprovals.get(0).permittedVotingRange).isNull();
 
     assertThat(verifiedApprovals).hasSize(1);
     assertThat(verifiedApprovals.get(0).value).isEqualTo(1);
-    assertThat(verifiedApprovals.get(0).username).isEqualTo(admin.username());
+    if (server.isUsernameSupported()) {
+      assertThat(verifiedApprovals.get(0).username).isEqualTo(admin.username());
+    }
     assertThat(codeReviewApprovals.get(0).permittedVotingRange).isNull();
   }
 
@@ -3738,7 +3784,6 @@
 
   @Test
   public void uploadingRulesPlIsNotAllowed() throws Exception {
-    projectOperations.project(project).getHead("master");
     GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
     testRepo.reset("config");
     PushOneCommit.Result pushResult =
@@ -3974,6 +4019,22 @@
   }
 
   @Test
+  public void getCommitMessage() throws Exception {
+    String subject = "Change Subject";
+    String changeId = "I" + ObjectId.toString(CommitMessageUtil.generateChangeId());
+    String commitMessage =
+        String.format(
+            "%s\n\nFirst Paragraph.\n\nSecond Paragraph\n\nFoo: Bar\nChange-Id: %s\n",
+            subject, changeId);
+    changeOperations.newChange().project(project).commitMessage(commitMessage).create();
+
+    CommitMessageInfo commitMessageInfo = gApi.changes().id(changeId).getMessage();
+    assertThat(commitMessageInfo.subject).isEqualTo(subject);
+    assertThat(commitMessageInfo.fullMessage).isEqualTo(commitMessage);
+    assertThat(commitMessageInfo.footers).containsExactly("Foo", "Bar", "Change-Id", changeId);
+  }
+
+  @Test
   public void changeCommitMessage() throws Exception {
     // Tests mutating the commit message as both the owner of the change and a regular user with
     // addPatchSet permission. Asserts that both cases succeed.
@@ -4121,6 +4182,8 @@
     PushOneCommit push = pushFactory.create(user.newIdent(), userTestRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
+    assertThat(gApi.changes().id(r.getChangeId()).info().owner._accountId)
+        .isEqualTo(user.id().get());
     // Try to change the commit message
     AuthException thrown =
         assertThrows(
@@ -4373,7 +4436,7 @@
   private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
     try (RefUpdateContext ctx = openTestRefUpdateContext()) {
       try (BatchUpdate batchUpdate =
-          batchUpdateFactory.create(project, atrScope.get().getUser(), TimeUtil.now())) {
+          batchUpdateFactory.create(project, localCtx.getContext().getUser(), TimeUtil.now())) {
         batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
         batchUpdate.execute();
       }
@@ -4513,7 +4576,7 @@
   @Test
   public void changeDetailsDoesNotRequireIndex() throws Exception {
     // This set of options must be kept in sync with gr-rest-api-interface.js
-    Set<ListChangesOption> options =
+    ImmutableSet<ListChangesOption> options =
         ImmutableSet.of(
             ListChangesOption.ALL_COMMITS,
             ListChangesOption.ALL_REVISIONS,
@@ -4734,7 +4797,7 @@
     }
     sender.clear();
     gApi.changes().id(change).addReviewer(user.email());
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     assertThat(((StringEmailHeader) messages.get(0).headers().get("Subject")).getString())
         .contains("[" + expectedSizeBucket + "]");
@@ -4746,7 +4809,8 @@
 
   private ThrowableSubject assertThatQueryException(String query) throws Exception {
     try {
-      query(query);
+      @SuppressWarnings("unused")
+      var unused = query(query);
     } catch (BadRequestException e) {
       return assertThat(e);
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
index 2b1bef0..bc210f0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
index a055201..b2aa4fe 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 1b06b7b..4d8566d 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -24,6 +24,7 @@
 import static java.util.stream.Collectors.toList;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -59,10 +60,12 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -92,6 +95,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -152,6 +156,10 @@
       @Override
       public void configure() {
         CommentValidator mockCommentValidator = mock(CommentValidator.class);
+
+        // by default return no validation errors
+        when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of());
+
         bind(CommentValidator.class)
             .annotatedWith(Exports.named(mockCommentValidator.getClass()))
             .toInstance(mockCommentValidator);
@@ -757,6 +765,26 @@
   }
 
   @Test
+  public void currentRevisionNumberIsSetOnReturnedChangeInfo() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeInfo changeInfo =
+        gApi.changes().id(r.getChangeId()).current().review(ReviewInput.dislike()).changeInfo;
+    assertThat(changeInfo.currentRevisionNumber).isEqualTo(1);
+    amendChange(r.getChangeId());
+    changeInfo =
+        gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend()).changeInfo;
+    assertThat(changeInfo.currentRevisionNumber).isEqualTo(2);
+
+    // Check that the current revision number is also returned when list changes options are
+    // requested.
+    ReviewInput reviewInput = ReviewInput.approve();
+    reviewInput.responseFormatOptions =
+        ImmutableList.copyOf(EnumSet.allOf(ListChangesOption.class));
+    changeInfo = gApi.changes().id(r.getChangeId()).current().review(reviewInput).changeInfo;
+    assertThat(changeInfo.currentRevisionNumber).isEqualTo(2);
+  }
+
+  @Test
   public void submitRulesAreInvokedOnlyOnce() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -770,6 +798,20 @@
   }
 
   @Test
+  public void submitRulesAreInvokedOnlyOnce_allOptionsSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestSubmitRule testSubmitRule = new TestSubmitRule();
+    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      input.responseFormatOptions = ImmutableList.copyOf(EnumSet.allOf(ListChangesOption.class));
+      gApi.changes().id(r.getChangeId()).current().review(input);
+    }
+
+    assertThat(testSubmitRule.count).isEqualTo(1);
+  }
+
+  @Test
   public void addingReviewers() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -825,6 +867,41 @@
   }
 
   @Test
+  public void rejectAddingReviewerIfReviewerUserIdentifierIsMissing() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add reviewer with ReviewerInput where the 'reviewer' field is not set.
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = ReviewerState.REVIEWER;
+
+    ReviewInput reviewInput = ReviewInput.create();
+    reviewInput.reviewers = ImmutableList.of(reviewerInput);
+
+    ReviewResult reviewResult = gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    assertThat(reviewResult.error).isEqualTo("error adding reviewer");
+    assertThat(reviewResult.reviewers.keySet()).containsExactly(reviewerInput.reviewer);
+    assertThat(reviewResult.reviewers.get(reviewerInput.reviewer).error)
+        .isEqualTo("reviewer user identifier is required");
+
+    // Add reviewer with ReviewerInput where the 'reviewer' field is set to an empty string.
+    reviewerInput.reviewer = "";
+    reviewResult = gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    assertThat(reviewResult.error).isEqualTo("error adding reviewer");
+    assertThat(reviewResult.reviewers.keySet()).containsExactly(reviewerInput.reviewer);
+    assertThat(reviewResult.reviewers.get(reviewerInput.reviewer).error)
+        .isEqualTo("reviewer user identifier is required");
+
+    // Add reviewer with ReviewerInput where the 'reviewer' field is set to an empty string after
+    // trimming it.
+    reviewerInput.reviewer = "   ";
+    reviewResult = gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    assertThat(reviewResult.error).isEqualTo("error adding reviewer");
+    assertThat(reviewResult.reviewers.keySet()).containsExactly(reviewerInput.reviewer);
+    assertThat(reviewResult.reviewers.get(reviewerInput.reviewer).error)
+        .isEqualTo("reviewer user identifier is required");
+  }
+
+  @Test
   public void deletingReviewers() throws Exception {
     PushOneCommit.Result r = createChange();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 465a19a..c36c9f1 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -86,7 +86,7 @@
     assertThat(result.get(0)).hasSize(2);
     assertThat(result.get(1)).hasSize(1);
 
-    List<Integer> firstResultIds =
+    ImmutableList<Integer> firstResultIds =
         ImmutableList.of(result.get(0).get(0)._number, result.get(0).get(1)._number);
     assertThat(firstResultIds).containsExactly(numericId1, numericId2);
     assertThat(result.get(1).get(0)._number).isEqualTo(numericId2);
@@ -97,7 +97,7 @@
   public void moreChangesIndicatorDoesNotWronglyCopyToUnrelatedChanges() throws Exception {
     String queryWithMoreChanges = "is:wip limit:1 repo:" + project.get();
     String queryWithNoMoreChanges = "is:open limit:10 repo:" + project.get();
-    createChange().getChangeId();
+    createChange();
     String cId2 = createChange().getChangeId();
     String cId3 = createChange().getChangeId();
     gApi.changes().id(cId2).setWorkInProgress();
@@ -160,8 +160,8 @@
   @SuppressWarnings("unchecked")
   public void withPagedResults() throws Exception {
     // Create 4 visible changes.
-    createChange(testRepo).getChange().getId().get();
-    createChange(testRepo).getChange().getId().get();
+    createChange(testRepo);
+    createChange(testRepo);
     int changeId3 = createChange(testRepo).getChange().getId().get();
     int changeId4 = createChange(testRepo).getChange().getId().get();
 
@@ -291,7 +291,7 @@
     assertThat(result.get(0)).hasSize(2);
     assertThat(result.get(1)).hasSize(1);
 
-    List<Integer> firstResultIds =
+    ImmutableList<Integer> firstResultIds =
         ImmutableList.of(result.get(0).get(0)._number, result.get(0).get(1)._number);
     assertThat(firstResultIds).containsExactly(numericId1, numericId2);
     assertThat(result.get(1).get(0)._number).isEqualTo(numericId2);
@@ -311,7 +311,7 @@
   @Test
   @SuppressWarnings("unchecked")
   public void skipVisibility_noReadPermission() throws Exception {
-    createChange().getChangeId();
+    createChange();
     requestScopeOperations.setApiUser(admin.id());
     QueryChanges queryChanges = queryChangesProvider.get();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
index 297579c..c32c04b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index c637916..7f21eb6 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -18,12 +18,15 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.client.ChangeKind.MERGE_FIRST_PARENT_UPDATE;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.HEAD;
@@ -44,8 +47,10 @@
 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.PatchSet;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
@@ -65,6 +70,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -75,6 +81,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
 import java.util.stream.Collectors;
@@ -145,6 +152,474 @@
     }
 
     @Test
+    public void rebaseMerge() throws Exception {
+      // Create a new project for this test so that we can configure a copy condition without
+      // affecting any other tests. Copy Code-Review approvals if change kind is
+      // MERGE_FIRST_PARENT_UPDATE. MERGE_FIRST_PARENT_UPDATE is the change kind when a merge commit
+      // is rebased without conflicts.
+      Project.NameKey project = projectOperations.newProject().create();
+      try (ProjectConfigUpdate u = updateProject(project)) {
+        LabelType.Builder codeReview =
+            labelBuilder(
+                    LabelId.CODE_REVIEW,
+                    value(2, "Looks good to me, approved"),
+                    value(1, "Looks good to me, but someone else must approve"),
+                    value(0, "No score"),
+                    value(-1, "I would prefer this is not submitted as is"),
+                    value(-2, "This shall not be submitted"))
+                .setCopyCondition("changekind:" + MERGE_FIRST_PARENT_UPDATE.name());
+        u.getConfig().upsertLabelType(codeReview.build());
+        u.save();
+      }
+
+      String file1 = "foo/a.txt";
+      String file2 = "bar/b.txt";
+      String file3 = "baz/c.txt";
+
+      // Create an initial change that adds file1, so that we can modify it later.
+      Change.Id initialChange =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file1)
+              .content("base content")
+              .create();
+      approveAndSubmit(initialChange);
+
+      // Create another branch
+      String branchName = "foo";
+      BranchInput branchInput = new BranchInput();
+      branchInput.ref = branchName;
+      branchInput.revision = projectOperations.project(project).getHead("master").name();
+      gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+      // Create a change in master that touches file1.
+      Change.Id baseChangeInMaster =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file1)
+              .content("master content")
+              .create();
+      approveAndSubmit(baseChangeInMaster);
+
+      // Create a change in the other branch and that touches file1 and creates file2.
+      Change.Id changeInOtherBranch =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch(branchName)
+              .file(file1)
+              .content("other content")
+              .file(file2)
+              .content("content")
+              .create();
+      approveAndSubmit(changeInOtherBranch);
+
+      // Create a merge change with a conflict resolution for file1. file2 has the same content as
+      // in the other branch (no conflict on file2).
+      Change.Id mergeChangeId =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .mergeOfButBaseOnFirst()
+              .tipOfBranch("master")
+              .and()
+              .tipOfBranch(branchName)
+              .file(file1)
+              .content("merged content")
+              .file(file2)
+              .content("content")
+              .create();
+
+      // Create a change in master onto which the merge change can be rebased. This change touches
+      // an unrelated file (file3) so that there is no conflict on rebase.
+      Change.Id newBaseChangeInMaster =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file3)
+              .content("other content")
+              .create();
+      approveAndSubmit(newBaseChangeInMaster);
+
+      // Add an approval whose score should be copied on rebase.
+      gApi.changes().id(mergeChangeId.get()).current().review(ReviewInput.recommend());
+
+      // Rebase the merge change
+      rebaseCall.call(mergeChangeId.toString());
+
+      verifyRebaseForChange(
+          mergeChangeId,
+          ImmutableList.of(
+              getCurrentRevision(newBaseChangeInMaster), getCurrentRevision(changeInOtherBranch)),
+          /* shouldHaveApproval= */ true,
+          /* expectedNumRevisions= */ 2);
+
+      // Verify the file contents.
+      assertThat(getFileContent(mergeChangeId, file1)).isEqualTo("merged content");
+      assertThat(getFileContent(mergeChangeId, file2)).isEqualTo("content");
+      assertThat(getFileContent(mergeChangeId, file3)).isEqualTo("other content");
+
+      // Rebasing the merge change again should fail
+      verifyChangeIsUpToDate(mergeChangeId.toString());
+    }
+
+    @Test
+    public void rebaseMergeWithConflict_fails() throws Exception {
+      String file1 = "foo/a.txt";
+      String file2 = "bar/b.txt";
+
+      // Create an initial change that adds file1, so that we can modify it later.
+      Change.Id initialChange =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file1)
+              .content("base content")
+              .create();
+      approveAndSubmit(initialChange);
+
+      // Create another branch
+      String branchName = "foo";
+      BranchInput branchInput = new BranchInput();
+      branchInput.ref = branchName;
+      branchInput.revision = projectOperations.project(project).getHead("master").name();
+      gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+      // Create a change in master that touches file1.
+      Change.Id baseChangeInMaster =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file1)
+              .content("master content")
+              .create();
+      approveAndSubmit(baseChangeInMaster);
+
+      // Create a change in the other branch and that touches file1 and creates file2.
+      Change.Id changeInOtherBranch =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch(branchName)
+              .file(file1)
+              .content("other content")
+              .file(file2)
+              .content("content")
+              .create();
+      approveAndSubmit(changeInOtherBranch);
+
+      // Create a merge change with a conflict resolution for file1. file2 has the same content as
+      // in the other branch (no conflict on file2).
+      Change.Id mergeChangeId =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .mergeOfButBaseOnFirst()
+              .tipOfBranch("master")
+              .and()
+              .tipOfBranch(branchName)
+              .file(file1)
+              .content("merged content")
+              .file(file2)
+              .content("content")
+              .create();
+
+      // Create a change in master onto which the merge change can be rebased. This change touches
+      // file1 again so that there is a conflict on rebase.
+      Change.Id newBaseChangeInMaster =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file1)
+              .content("conflicting content")
+              .create();
+      approveAndSubmit(newBaseChangeInMaster);
+
+      // Try to rebase the merge change
+      MergeConflictException mergeConflictException =
+          assertThrows(
+              MergeConflictException.class, () -> rebaseCall.call(mergeChangeId.toString()));
+      assertThat(mergeConflictException)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Change %s could not be rebased due to a conflict during merge.\n"
+                      + "\n"
+                      + "merge conflict(s):\n"
+                      + "%s",
+                  mergeChangeId, file1));
+    }
+
+    @Test
+    public void rebaseMergeWithConflict_conflictsAllowed() throws Exception {
+      // Create a new project for this test so that we can configure a copy condition without
+      // affecting any other tests. Copy Code-Review approvals if change kind is
+      // MERGE_FIRST_PARENT_UPDATE. MERGE_FIRST_PARENT_UPDATE is the change kind when a merge commit
+      // is rebased without conflicts.
+      Project.NameKey project = projectOperations.newProject().create();
+      try (ProjectConfigUpdate u = updateProject(project)) {
+        LabelType.Builder codeReview =
+            labelBuilder(
+                    LabelId.CODE_REVIEW,
+                    value(2, "Looks good to me, approved"),
+                    value(1, "Looks good to me, but someone else must approve"),
+                    value(0, "No score"),
+                    value(-1, "I would prefer this is not submitted as is"),
+                    value(-2, "This shall not be submitted"))
+                .setCopyCondition("changekind:" + MERGE_FIRST_PARENT_UPDATE.name());
+        u.getConfig().upsertLabelType(codeReview.build());
+        u.save();
+      }
+
+      String file = "foo/a.txt";
+
+      // Create an initial change that adds a file, so that we can modify it later.
+      Change.Id initialChange =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file)
+              .content("base content")
+              .create();
+      approveAndSubmit(initialChange);
+
+      // Create another branch
+      String branchName = "foo";
+      BranchInput branchInput = new BranchInput();
+      branchInput.ref = branchName;
+      branchInput.revision = projectOperations.project(project).getHead("master").name();
+      gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+      // Create a change in master that touches the file.
+      Change.Id baseChangeInMaster =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file)
+              .content("master content")
+              .create();
+      approveAndSubmit(baseChangeInMaster);
+
+      // Create a change in the other branch and that also touches the file.
+      Change.Id changeInOtherBranch =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch(branchName)
+              .file(file)
+              .content("other content")
+              .create();
+      approveAndSubmit(changeInOtherBranch);
+
+      // Create a merge change with a conflict resolution.
+      String mergeCommitMessage = "Merge";
+      String mergeContent = "merged content";
+      Change.Id mergeChangeId =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .commitMessage(mergeCommitMessage)
+              .mergeOfButBaseOnFirst()
+              .tipOfBranch("master")
+              .and()
+              .tipOfBranch(branchName)
+              .file(file)
+              .content(mergeContent)
+              .create();
+      String mergeSha1 = abbreviateName(ObjectId.fromString(getCurrentRevision(mergeChangeId)), 6);
+
+      // Create a change in master onto which the merge change can be rebased. This change touches
+      // the file again so that there is a conflict on rebase.
+      String newBaseCommitMessage = "Foo";
+      String newBaseContent = "conflicting content";
+      Change.Id newBaseChangeInMaster =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .commitMessage(newBaseCommitMessage)
+              .file(file)
+              .content(newBaseContent)
+              .create();
+      approveAndSubmit(newBaseChangeInMaster);
+
+      // Add an approval whose score should NOT be copied on rebase (since there is a conflict the
+      // change kind should be REWORK).
+      gApi.changes().id(mergeChangeId.get()).current().review(ReviewInput.recommend());
+
+      // Rebase the merge change with conflicts allowed.
+      TestWorkInProgressStateChangedListener wipStateChangedListener =
+          new TestWorkInProgressStateChangedListener();
+      try (ExtensionRegistry.Registration registration =
+          extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+        RebaseInput rebaseInput = new RebaseInput();
+        rebaseInput.allowConflicts = true;
+        rebaseCallWithInput.call(mergeChangeId.toString(), rebaseInput);
+      }
+      assertThat(wipStateChangedListener.invoked).isTrue();
+      assertThat(wipStateChangedListener.wip).isTrue();
+
+      String baseCommit = getCurrentRevision(newBaseChangeInMaster);
+      verifyRebaseForChange(
+          mergeChangeId,
+          ImmutableList.of(baseCommit, getCurrentRevision(changeInOtherBranch)),
+          /* shouldHaveApproval= */ false,
+          /* expectedNumRevisions= */ 2);
+
+      // Verify the file contents.
+      String baseSha1 = abbreviateName(ObjectId.fromString(baseCommit), 6);
+      assertThat(getFileContent(mergeChangeId, file))
+          .isEqualTo(
+              "<<<<<<< PATCH SET ("
+                  + mergeSha1
+                  + " "
+                  + mergeCommitMessage
+                  + ")\n"
+                  + mergeContent
+                  + "\n"
+                  + "=======\n"
+                  + newBaseContent
+                  + "\n"
+                  + ">>>>>>> BASE      ("
+                  + baseSha1
+                  + " "
+                  + newBaseCommitMessage
+                  + ")\n");
+
+      // Verify that a change message has been posted on the change that informs about the conflict
+      // and the outdated vote.
+      List<ChangeMessageInfo> messages = gApi.changes().id(mergeChangeId.get()).messages();
+      assertThat(messages).hasSize(3);
+      assertThat(Iterables.getLast(messages).message)
+          .isEqualTo(
+              "Patch Set 2: Patch Set 1 was rebased\n\n"
+                  + "The following files contain Git conflicts:\n"
+                  + "* "
+                  + file
+                  + "\n\n"
+                  + "Outdated Votes:\n"
+                  + "* Code-Review+1"
+                  + " (copy condition: \"changekind:MERGE_FIRST_PARENT_UPDATE\")\n");
+
+      // Rebasing the merge change again should fail
+      verifyChangeIsUpToDate(mergeChangeId.toString());
+    }
+
+    @Test
+    public void rebaseMergeWithConflict_strategyAcceptTheirs() throws Exception {
+      rebaseMergeWithConflict_strategy("theirs");
+    }
+
+    @Test
+    public void rebaseMergeWithConflict_strategyAcceptOurs() throws Exception {
+      rebaseMergeWithConflict_strategy("ours");
+    }
+
+    private void rebaseMergeWithConflict_strategy(String strategy) throws Exception {
+      String file = "foo/a.txt";
+
+      // Create an initial change that adds a file, so that we can modify it later.
+      Change.Id initialChange =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file)
+              .content("base content")
+              .create();
+      approveAndSubmit(initialChange);
+
+      // Create another branch
+      String branchName = "foo";
+      BranchInput branchInput = new BranchInput();
+      branchInput.ref = branchName;
+      branchInput.revision = projectOperations.project(project).getHead("master").name();
+      gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+      // Create a change in master that touches the file.
+      Change.Id baseChangeInMaster =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file)
+              .content("master content")
+              .create();
+      approveAndSubmit(baseChangeInMaster);
+
+      // Create a change in the other branch and that also touches the file.
+      Change.Id changeInOtherBranch =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch(branchName)
+              .file(file)
+              .content("other content")
+              .create();
+      approveAndSubmit(changeInOtherBranch);
+
+      // Create a merge change with a conflict resolution for the file.
+      String mergeContent = "merged content";
+      Change.Id mergeChangeId =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .mergeOfButBaseOnFirst()
+              .tipOfBranch("master")
+              .and()
+              .tipOfBranch(branchName)
+              .file(file)
+              .content(mergeContent)
+              .create();
+
+      // Create a change in master onto which the merge change can be rebased.  This change touches
+      // the file again so that there is a conflict on rebase.
+      String newBaseContent = "conflicting content";
+      Change.Id newBaseChangeInMaster =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file)
+              .content(newBaseContent)
+              .create();
+      approveAndSubmit(newBaseChangeInMaster);
+
+      // Rebase the merge change with setting a merge strategy
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.strategy = strategy;
+      rebaseCallWithInput.call(mergeChangeId.toString(), rebaseInput);
+
+      verifyRebaseForChange(
+          mergeChangeId,
+          ImmutableList.of(
+              getCurrentRevision(newBaseChangeInMaster), getCurrentRevision(changeInOtherBranch)),
+          /* shouldHaveApproval= */ false,
+          /* expectedNumRevisions= */ 2);
+
+      // Verify the file contents.
+      assertThat(getFileContent(mergeChangeId, file))
+          .isEqualTo(strategy.equals("theirs") ? newBaseContent : mergeContent);
+
+      // Rebasing the merge change again should fail
+      verifyChangeIsUpToDate(mergeChangeId.toString());
+    }
+
+    @Test
     public void rebaseWithCommitterEmail() throws Exception {
       // Create three changes with the same parent
       PushOneCommit.Result r1 = createChange();
@@ -365,6 +840,51 @@
     }
 
     @Test
+    public void rebaseChangeWithValidBaseCommit() throws Exception {
+      RevCommit desiredBase =
+          createNewCommitWithoutChangeId(/*branch=*/ "refs/heads/master", "file", "content");
+      PushOneCommit.Result child = createChange();
+      RebaseInput ri = new RebaseInput();
+
+      // rebase child onto desiredBase (referenced by commit)
+      ri.base = desiredBase.getName();
+      rebaseCallWithInput.call(child.getChangeId(), ri);
+
+      PatchSet ps2 = child.getPatchSet();
+      assertThat(ps2.id().get()).isEqualTo(2);
+      RevisionInfo childInfo =
+          get(child.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+      assertThat(childInfo.commit.parents.get(0).commit).isEqualTo(desiredBase.name());
+    }
+
+    @Test
+    public void cannotRebaseChangeWithInvalidBaseCommit() throws Exception {
+      // Create another branch and push the desired parent commit to it.
+      String branchName = "foo";
+      BranchInput branchInput = new BranchInput();
+      branchInput.ref = branchName;
+      branchInput.revision = projectOperations.project(project).getHead("master").name();
+      gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+      RevCommit desiredBase =
+          createNewCommitWithoutChangeId(/*branch=*/ "refs/heads/foo", "file", "content");
+      // Create the child commit on "master".
+      PushOneCommit.Result child = createChange();
+      RebaseInput ri = new RebaseInput();
+
+      // Try to rebase child onto desiredBase (referenced by commit)
+      ri.base = desiredBase.getName();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(child.getChangeId(), ri));
+
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format("base revision is missing from the destination branch: %s", ri.base));
+    }
+
+    @Test
     public void rebaseUpToDateChange() throws Exception {
       PushOneCommit.Result r = createChange();
       verifyChangeIsUpToDate(r);
@@ -660,6 +1180,23 @@
           /* expectedNumRevisions= */ 2);
     }
 
+    protected void approveAndSubmit(Change.Id changeId) throws Exception {
+      approve(Integer.toString(changeId.get()));
+      gApi.changes().id(changeId.get()).current().submit();
+    }
+
+    protected String getCurrentRevision(Change.Id changeId) throws RestApiException {
+      return gApi.changes().id(changeId.get()).get(CURRENT_REVISION).currentRevision;
+    }
+
+    protected String getFileContent(Change.Id changeId, String file)
+        throws RestApiException, IOException {
+      BinaryResult bin = gApi.changes().id(changeId.get()).current().file(file).content();
+      ByteArrayOutputStream os = new ByteArrayOutputStream();
+      bin.writeTo(os);
+      return new String(os.toByteArray(), UTF_8);
+    }
+
     protected void verifyRebaseForChange(
         Change.Id changeId, Change.Id baseChangeId, boolean shouldHaveApproval)
         throws RestApiException {
@@ -672,14 +1209,26 @@
         boolean shouldHaveApproval,
         int expectedNumRevisions)
         throws RestApiException {
-      ChangeInfo baseInfo = gApi.changes().id(baseChangeId.get()).get(CURRENT_REVISION);
       verifyRebaseForChange(
-          changeId, baseInfo.currentRevision, shouldHaveApproval, expectedNumRevisions);
+          changeId,
+          ImmutableList.of(getCurrentRevision(baseChangeId)),
+          shouldHaveApproval,
+          expectedNumRevisions);
     }
 
     protected void verifyRebaseForChange(
         Change.Id changeId, String baseCommit, boolean shouldHaveApproval, int expectedNumRevisions)
         throws RestApiException {
+      verifyRebaseForChange(
+          changeId, ImmutableList.of(baseCommit), shouldHaveApproval, expectedNumRevisions);
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId,
+        List<String> baseCommits,
+        boolean shouldHaveApproval,
+        int expectedNumRevisions)
+        throws RestApiException {
       ChangeInfo info =
           gApi.changes().id(changeId.get()).get(CURRENT_REVISION, CURRENT_COMMIT, DETAILED_LABELS);
 
@@ -688,10 +1237,12 @@
       assertThat(r.realUploader).isNull();
 
       // ...and the base should be correct
-      assertThat(r.commit.parents).hasSize(1);
-      assertWithMessage("base commit for change " + changeId)
-          .that(r.commit.parents.get(0).commit)
-          .isEqualTo(baseCommit);
+      assertThat(r.commit.parents).hasSize(baseCommits.size());
+      for (int baseNum = 0; baseNum < baseCommits.size(); baseNum++) {
+        assertWithMessage("base commit " + baseNum + " for change " + changeId)
+            .that(r.commit.parents.get(baseNum).commit)
+            .isEqualTo(baseCommits.get(baseNum));
+      }
 
       // ...and the committer and description should be correct
       GitPerson committer = r.commit.committer;
@@ -711,8 +1262,12 @@
     }
 
     protected void verifyChangeIsUpToDate(PushOneCommit.Result r) {
+      verifyChangeIsUpToDate(r.getChangeId());
+    }
+
+    protected void verifyChangeIsUpToDate(String changeId) {
       ResourceConflictException thrown =
-          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r.getChangeId()));
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(changeId));
       assertThat(thrown).hasMessageThat().contains("Change is already up to date");
     }
 
@@ -1167,9 +1722,9 @@
     }
 
     @Override
-    protected void verifyChangeIsUpToDate(PushOneCommit.Result r) {
+    protected void verifyChangeIsUpToDate(String changeId) {
       ResourceConflictException thrown =
-          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r.getChangeId()));
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(changeId));
       assertThat(thrown).hasMessageThat().contains("The whole chain is already up to date.");
     }
 
@@ -1294,6 +1849,152 @@
     }
 
     @Test
+    public void rebaseChainWithMerges() throws Exception {
+      String file1 = "foo/a.txt";
+      String file2 = "bar/b.txt";
+
+      // Create an initial change that adds file1, so that we can modify it later.
+      Change.Id initialChange =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file1)
+              .content("base content")
+              .create();
+      approveAndSubmit(initialChange);
+
+      // Create another branch
+      String branchName = "foo";
+      BranchInput branchInput = new BranchInput();
+      branchInput.ref = branchName;
+      branchInput.revision = projectOperations.project(project).getHead("master").name();
+      gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput);
+
+      // Create a change in master that touches file1.
+      Change.Id baseChangeInMaster =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file1)
+              .content("master content")
+              .create();
+      approveAndSubmit(baseChangeInMaster);
+
+      // Create a change in the other branch and that also touches file1.
+      Change.Id changeInOtherBranch =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch(branchName)
+              .file(file1)
+              .content("other content")
+              .create();
+      approveAndSubmit(changeInOtherBranch);
+
+      // Create a merge change with a conflict resolution.
+      Change.Id mergeChangeId =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .mergeOfButBaseOnFirst()
+              .tipOfBranch("master")
+              .and()
+              .tipOfBranch(branchName)
+              .file(file1)
+              .content("merged content")
+              .create();
+
+      // Create a follow up change.
+      Change.Id followUpChangeId =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .childOf()
+              .change(mergeChangeId)
+              .file(file1)
+              .content("modified content")
+              .create();
+
+      // Create another change in the other branch so that we can create another merge
+      Change.Id anotherChangeInOtherBranch =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch(branchName)
+              .file(file1)
+              .content("yet another content")
+              .create();
+      approveAndSubmit(anotherChangeInOtherBranch);
+
+      // Create a second merge change with a conflict resolution.
+      Change.Id followUpMergeChangeId =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .childOf()
+              .change(followUpChangeId)
+              .mergeOfButBaseOnFirst()
+              .change(followUpChangeId)
+              .and()
+              .tipOfBranch(branchName)
+              .file(file1)
+              .content("another merged content")
+              .create();
+
+      // Create a change in master onto which the chain can be rebased. This change touches an
+      // unrelated file (file2) so that there is no conflict on rebase.
+      Change.Id newBaseChangeInMaster =
+          changeOperations
+              .newChange()
+              .project(project)
+              .branch("master")
+              .file(file2)
+              .content("other content")
+              .create();
+      approveAndSubmit(newBaseChangeInMaster);
+
+      // Rebase the chain
+      RebaseChainInfo rebaseChainInfo =
+          gApi.changes().id(followUpMergeChangeId.get()).rebaseChain().value();
+      assertThat(rebaseChainInfo.rebasedChanges).hasSize(3);
+      assertThat(rebaseChainInfo.containsGitConflicts).isNull();
+
+      verifyRebaseForChange(
+          mergeChangeId,
+          ImmutableList.of(
+              getCurrentRevision(newBaseChangeInMaster), getCurrentRevision(changeInOtherBranch)),
+          /* shouldHaveApproval= */ false,
+          /* expectedNumRevisions= */ 2);
+      verifyRebaseForChange(
+          followUpChangeId,
+          ImmutableList.of(getCurrentRevision(mergeChangeId)),
+          /* shouldHaveApproval= */ false,
+          /* expectedNumRevisions= */ 2);
+      verifyRebaseForChange(
+          followUpMergeChangeId,
+          ImmutableList.of(
+              getCurrentRevision(followUpChangeId), getCurrentRevision(anotherChangeInOtherBranch)),
+          /* shouldHaveApproval= */ false,
+          /* expectedNumRevisions= */ 2);
+
+      // Verify the file contents.
+      assertThat(getFileContent(mergeChangeId, file1)).isEqualTo("merged content");
+      assertThat(getFileContent(mergeChangeId, file2)).isEqualTo("other content");
+      assertThat(getFileContent(followUpChangeId, file1)).isEqualTo("modified content");
+      assertThat(getFileContent(followUpChangeId, file2)).isEqualTo("other content");
+      assertThat(getFileContent(followUpMergeChangeId, file1)).isEqualTo("another merged content");
+      assertThat(getFileContent(followUpMergeChangeId, file2)).isEqualTo("other content");
+
+      // Rebasing the chain again should fail
+      verifyChangeIsUpToDate(followUpChangeId.toString());
+    }
+
+    @Test
     public void rebasePartlyOutdatedChain() throws Exception {
       final String file = "modified_file.txt";
       final String oldContent = "old content";
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
index 319c0cd..99a299f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 7c50e93..cd3e76d 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -318,7 +318,7 @@
     sender.clear();
     ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert().get();
 
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(2);
     assertThat(sender.getMessages(revertChange.changeId, "newchange")).hasSize(1);
     assertThat(sender.getMessages(r.getChangeId(), "revert")).hasSize(1);
@@ -335,7 +335,7 @@
     // If notify input not specified, the endpoint overrides it to NONE
     RevertInput revertInput = createWipRevertInput();
     revertInput.notify = null;
-    gApi.changes().id(r.getChangeId()).revert(revertInput).get();
+    gApi.changes().id(r.getChangeId()).revert(revertInput);
     assertThat(sender.getMessages()).isEmpty();
   }
 
@@ -350,7 +350,7 @@
     revertInput.notify = NotifyHandling.NONE;
 
     sender.clear();
-    gApi.changes().id(r.getChangeId()).revert(revertInput).get();
+    gApi.changes().id(r.getChangeId()).revert(revertInput);
     assertThat(sender.getMessages()).isEmpty();
   }
 
@@ -751,7 +751,7 @@
     RevertSubmissionInfo revertChanges =
         gApi.changes().id(secondResult).revertSubmission(revertInput);
 
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
 
     assertThat(messages).hasSize(4);
     assertThat(sender.getMessages(revertChanges.revertChanges.get(0).changeId, "newchange"))
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 6ba1498..2cc2798 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -33,6 +33,7 @@
 import static java.util.Comparator.comparing;
 
 import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -471,7 +472,7 @@
 
     // The code-review approval is copied for the second change between PS1 and PS2 since the only
     // modified file is due to rebase.
-    List<PatchSetApproval> patchSetApprovals =
+    ImmutableList<PatchSetApproval> patchSetApprovals =
         r2.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
@@ -1024,7 +1025,7 @@
       amendChange(r.getChangeId());
     }
 
-    List<PatchSetApproval> patchSetApprovals =
+    ImmutableList<PatchSetApproval> patchSetApprovals =
         r.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
@@ -1065,7 +1066,7 @@
     // Rebase the second change
     gApi.changes().id(r2.getChangeId()).rebase();
 
-    List<PatchSetApproval> patchSetApprovals =
+    ImmutableList<PatchSetApproval> patchSetApprovals =
         r2.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
@@ -1102,7 +1103,7 @@
     // Make a new patchset, keeping the Code-Review +1 vote.
     amendChange(r.getChangeId());
 
-    List<PatchSetApproval> patchSetApprovals =
+    ImmutableList<PatchSetApproval> patchSetApprovals =
         r.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
@@ -1152,7 +1153,7 @@
     // Make a new patchset, keeping the Code-Review +1 vote.
     amendChange(r.getChangeId());
 
-    List<PatchSetApproval> patchSetApprovals =
+    ImmutableList<PatchSetApproval> patchSetApprovals =
         r.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
@@ -1289,7 +1290,7 @@
           .create();
       vote(admin, changeId, 2, 1);
 
-      List<PatchSetApproval> patchSetApprovals =
+      ImmutableList<PatchSetApproval> patchSetApprovals =
           notesFactory.create(project, r.getChange().getId()).getApprovals().all().values().stream()
               .sorted(comparing(a -> a.patchSetId().get()))
               .collect(toImmutableList());
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java
index 5584c2b..2c376fc 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java
@@ -178,9 +178,12 @@
     String changeId = r.getChangeId();
 
     rule.numberOfEvaluations.set(0);
-    gApi.changes()
-        .id(changeId)
-        .get(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS);
+
+    @SuppressWarnings("unused")
+    var unused =
+        gApi.changes()
+            .id(changeId)
+            .get(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS);
 
     // Submit rules are computed freshly, but only once.
     assertThat(rule.numberOfEvaluations.get()).isEqualTo(1);
@@ -192,10 +195,13 @@
     String changeId = r.getChangeId();
 
     rule.numberOfEvaluations.set(0);
-    gApi.changes()
-        .query(changeId)
-        .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS)
-        .get();
+
+    @SuppressWarnings("unused")
+    var unused =
+        gApi.changes()
+            .query(changeId)
+            .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS)
+            .get();
 
     // Submit rule evaluation results from the change index are reused
     assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index c8f361e..42af666 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -1134,7 +1134,7 @@
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.function = "NoOp";
     input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
-    gApi.projects().name(project.get()).label("Code-Review-Override").create(input).get();
+    gApi.projects().name(project.get()).label("Code-Review-Override").create(input);
 
     // Allow to vote on the Code-Review-Override label.
     projectOperations
@@ -1300,7 +1300,7 @@
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.function = "NoOp";
     input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
-    gApi.projects().name(project.get()).label("build-cop-override").create(input).get();
+    gApi.projects().name(project.get()).label("build-cop-override").create(input);
 
     // Allow to vote on the build-cop-override label.
     projectOperations
@@ -1325,7 +1325,7 @@
             .build());
 
     // Create Code-Review-Override label
-    gApi.projects().name(project.get()).label("Code-Review-Override").create(input).get();
+    gApi.projects().name(project.get()).label("Code-Review-Override").create(input);
 
     // Allow to vote on the Code-Review-Override label.
     projectOperations
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
index b5416aa..8643489 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -92,6 +92,37 @@
   }
 
   @Test
+  public void labelVote_greaterThan_withManyMaxVotes() throws Exception {
+    TestRepository<InMemoryRepository> clonedRepo = cloneProject(project, admin);
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(user.newIdent(), clonedRepo, "Subject", "file.txt", "text")
+            .to("refs/for/master");
+
+    Account.Id user11 = accountCreator.create("user11").id();
+    Account.Id user12 = accountCreator.create("user12").id();
+    Account.Id user13 = accountCreator.create("user13").id();
+    Account.Id user14 = accountCreator.create("user14").id();
+    Account.Id user15 = accountCreator.create("user15").id();
+    Account.Id user16 = accountCreator.create("user16").id();
+    Account.Id user17 = accountCreator.create("user17").id();
+    ImmutableList<Account.Id> allUsers =
+        ImmutableList.of(user11, user12, user13, user14, user15, user16, user17);
+
+    // Give voting permissions to all users
+    requestScopeOperations.setApiUser(admin.id());
+    allowLabelPermission(
+        codeReview().getName(), RefNames.REFS_HEADS + "*", REGISTERED_USERS, -2, +2);
+
+    // The predicate uses the MAX_COUNT_INTERNAL in label predicate, and the SR expression matches
+    // even if the change has more than 5 votes.
+    for (Account.Id aId : allUsers) {
+      approveAsUser(r1.getChangeId(), aId);
+      assertMatching("label:Code-Review=+2,count>=1", r1.getChange().getId());
+    }
+  }
+
+  @Test
   public void distinctVoters_sameUserVotesOnDifferentLabels_fails() throws Exception {
     Change.Id c1 = changeOperations.newChange().project(project).create();
     requestScopeOperations.setApiUser(admin.id());
@@ -436,6 +467,11 @@
     assertMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
   }
 
+  private void approveAsUser(String changeId, Account.Id userId) throws Exception {
+    requestScopeOperations.setApiUser(userId);
+    approve(changeId);
+  }
+
   private static void assertUploader(ChangeInfo changeInfo, String email) {
     assertThat(changeInfo.revisions.get(changeInfo.currentRevision).uploader.email)
         .isEqualTo(email);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
index 308e4e0..83de9824 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.entities.Change;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -36,15 +36,12 @@
 import com.google.gerrit.extensions.common.TestSubmitRuleInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.testing.ConfigSuite;
 import java.io.IOException;
-import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -60,7 +57,7 @@
     return submitWholeTopicEnabledConfig();
   }
 
-  private class RulesPl extends VersionedMetaData {
+  private static class RulesPl extends VersionedMetaData {
     private String rule;
 
     @Override
@@ -69,33 +66,23 @@
     }
 
     @Override
-    protected void onLoad() throws IOException, ConfigInvalidException {
+    protected void onLoad() throws IOException {
       rule = readUTF8(RULES_PL_FILE);
     }
 
     @Override
-    protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
-      TestSubmitRuleInput in = new TestSubmitRuleInput();
-      in.rule = rule;
-      try {
-        gApi.changes().id(testChangeId.get()).current().testSubmitType(in);
-      } catch (RestApiException e) {
-        throw new ConfigInvalidException("Invalid submit type rule", e);
-      }
-
+    protected boolean onSave(CommitBuilder commit) throws IOException {
       saveUTF8(RULES_PL_FILE, rule);
       return true;
     }
   }
 
   private AtomicInteger fileCounter;
-  private Change.Id testChangeId;
 
   @Before
   public void setUp() throws Exception {
     fileCounter = new AtomicInteger();
     gApi.projects().name(project.get()).branch("test").create(new BranchInput());
-    testChangeId = createChange("test", "test change").getChange().getId();
   }
 
   private void setRulesPl(String rule) throws Exception {
@@ -186,6 +173,21 @@
   }
 
   @Test
+  @GerritConfig(name = "rules.enable", value = "false")
+  public void submitType_rulesTakeNoEffectWhenDisabled() throws Exception {
+    PushOneCommit.Result r1 = createChange("master", "Default 1");
+    PushOneCommit.Result r2 = createChange("master", "FAST_FORWARD_ONLY 2");
+    PushOneCommit.Result r3 = createChange("master", "MERGE_IF_NECESSARY 3");
+
+    setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
+
+    // Rules take no effect
+    assertSubmitType(MERGE_IF_NECESSARY, r1.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r2.getChangeId());
+    assertSubmitType(MERGE_IF_NECESSARY, r3.getChangeId());
+  }
+
+  @Test
   public void submitTypeIsUsedForSubmit() throws Exception {
     setRulesPl(SUBMIT_TYPE_FROM_SUBJECT);
 
@@ -194,7 +196,7 @@
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
 
-    List<RevCommit> log = log("master", 1);
+    ImmutableList<RevCommit> log = log("master", 1);
     assertThat(log.get(0).getShortMessage()).isEqualTo("CHERRY_PICK 1");
     assertThat(log.get(0).name()).isNotEqualTo(r.getCommit().name());
     assertThat(log.get(0).getFullMessage()).contains("Change-Id: " + r.getChangeId());
@@ -223,7 +225,7 @@
 
     assertThat(log("master", 1).get(0).name()).isEqualTo(r1.getCommit().name());
 
-    List<RevCommit> branchLog = log("branch", 1);
+    ImmutableList<RevCommit> branchLog = log("branch", 1);
     assertThat(branchLog.get(0).getParents()).hasLength(2);
     assertThat(branchLog.get(0).getParent(1).name()).isEqualTo(r2.getCommit().name());
   }
@@ -285,7 +287,7 @@
     return info;
   }
 
-  private List<RevCommit> log(String commitish, int n) throws Exception {
+  private ImmutableList<RevCommit> log(String commitish, int n) throws Exception {
     try (Repository repo = repoManager.openRepository(project);
         Git git = new Git(repo)) {
       ObjectId id = repo.resolve(commitish);
diff --git a/javatests/com/google/gerrit/acceptance/api/config/GetExperimentIT.java b/javatests/com/google/gerrit/acceptance/api/config/GetExperimentIT.java
new file mode 100644
index 0000000..0609b5c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/config/GetExperimentIT.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.common.ExperimentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class GetExperimentIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void cannotGetAsNonAdmin() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+
+    AuthException exception =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.config()
+                    .server()
+                    .experiment(ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS)
+                    .get());
+    assertThat(exception).hasMessageThat().isEqualTo("administrate server not permitted");
+  }
+
+  @Test
+  public void cannotGetNonExistingExperiment() throws Exception {
+    ResourceNotFoundException exception =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.config().server().experiment("non-existing").get());
+    assertThat(exception).hasMessageThat().isEqualTo("non-existing");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"GerritBackendFeature__attach_nonce_to_documentation"})
+  public void getEnabled() throws Exception {
+    ExperimentInfo experimentInfo =
+        gApi.config()
+            .server()
+            .experiment(
+                ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION)
+            .get();
+    assertThat(experimentInfo.enabled).isTrue();
+  }
+
+  @Test
+  public void getDisabled() throws Exception {
+    ExperimentInfo experimentInfo =
+        gApi.config()
+            .server()
+            .experiment(
+                ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION)
+            .get();
+    assertThat(experimentInfo.enabled).isFalse();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
new file mode 100644
index 0000000..fe3cb00
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.common.ExperimentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@NoHttpd
+public class ListExperimentsIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void cannotListAsNonAdmin() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+
+    AuthException exception =
+        assertThrows(AuthException.class, () -> gApi.config().server().listExperiments().get());
+    assertThat(exception).hasMessageThat().isEqualTo("administrate server not permitted");
+  }
+
+  @Test
+  public void listAll() throws Exception {
+    ImmutableMap<String, ExperimentInfo> experiments =
+        gApi.config().server().listExperiments().get();
+    assertThat(experiments.keySet())
+        .containsExactly(
+            ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS,
+            ExperimentFeaturesConstants
+                .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE,
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION,
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE,
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
+        .inOrder();
+
+    // "GerritBackendFeature__check_implicit_merges_on_merge",
+    // "GerritBackendFeature__reject_implicit_merges_on_merge" and
+    // "GerritBackendFeature__always_reject_implicit_merges_on_merge" are enabled via
+    // AbstractDaemonTest#beforeTest
+    assertThat(
+            experiments.get(
+                    ExperimentFeaturesConstants
+                        .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE)
+                .enabled)
+        .isTrue();
+    assertThat(
+            experiments.get(
+                    ExperimentFeaturesConstants
+                        .GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE)
+                .enabled)
+        .isTrue();
+    assertThat(
+            experiments.get(
+                    ExperimentFeaturesConstants
+                        .GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
+                .enabled)
+        .isTrue();
+
+    assertThat(
+            experiments.get(ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS).enabled)
+        .isFalse();
+    assertThat(
+            experiments.get(
+                    ExperimentFeaturesConstants
+                        .GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION)
+                .enabled)
+        .isFalse();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"GerritBackendFeature__attach_nonce_to_documentation"})
+  // "GerritBackendFeature__check_implicit_merges_on_merge",
+  // "GerritBackendFeature__reject_implicit_merges_on_merge" and
+  // "GerritBackendFeature__always_reject_implicit_merges_on_merge" are enabled via
+  // AbstractDaemonTest#beforeTest
+  public void listEnabled_noneEnabled() throws Exception {
+    ImmutableMap<String, ExperimentInfo> experiments =
+        gApi.config().server().listExperiments().enabledOnly().get();
+    assertThat(experiments.keySet())
+        .containsExactly(
+            ExperimentFeaturesConstants
+                .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE,
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION,
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE,
+            ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE)
+        .inOrder();
+    for (ExperimentInfo experimentInfo : experiments.values()) {
+      assertThat(experimentInfo.enabled).isTrue();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
index 98ed56c..2f3ef24 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -41,7 +41,6 @@
 import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.Optional;
-import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.junit.Rule;
 import org.junit.Test;
@@ -68,7 +67,7 @@
 
     groupIndexer.index(groupUuid);
 
-    Set<AccountGroup.UUID> parentGroups =
+    ImmutableSet<AccountGroup.UUID> parentGroups =
         groupQueryProvider.get().bySubgroups(ImmutableSet.of(subgroupUuid)).get(subgroupUuid);
     assertThat(parentGroups).hasSize(1);
     assertThat(parentGroups).containsExactly(groupUuid);
@@ -87,7 +86,7 @@
 
     groupIndexer.index(groupUuid);
 
-    Set<AccountGroup.UUID> parentGroups =
+    ImmutableSet<AccountGroup.UUID> parentGroups =
         groupQueryProvider.get().bySubgroups(ImmutableSet.of(subgroupUuid)).get(subgroupUuid);
     assertThat(parentGroups).hasSize(1);
     assertThat(parentGroups).containsExactly(groupUuid);
@@ -117,7 +116,7 @@
 
     groupIndexer.reindexIfStale(groupUuid);
 
-    Set<AccountGroup.UUID> parentGroups =
+    ImmutableSet<AccountGroup.UUID> parentGroups =
         groupQueryProvider.get().bySubgroups(ImmutableSet.of(subgroupUuid)).get(subgroupUuid);
     assertThat(parentGroups).hasSize(1);
     assertThat(parentGroups).containsExactly(groupUuid);
@@ -170,7 +169,8 @@
   }
 
   private void loadGroupToCache(AccountGroup.UUID groupUuid) {
-    groupCache.get(groupUuid);
+    @SuppressWarnings("unused")
+    var unused = groupCache.get(groupUuid);
   }
 
   private static GroupDelta.Builder newGroupDelta() {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index db12e85..289e642 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -17,7 +17,6 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.api.group.GroupAssert.assertGroupInfo;
@@ -90,6 +89,8 @@
 import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.account.GroupsSnapshotReader;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.group.db.GroupDelta;
@@ -116,6 +117,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Stream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -153,6 +155,8 @@
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private GroupsSnapshotReader groupsSnapshotReader;
 
+  @Inject private ProjectResetter.Builder.Factory projectResetterFactory;
+
   @Override
   public Module createModule() {
     return new AbstractModule() {
@@ -166,17 +170,18 @@
 
   @After
   public void consistencyCheck() throws Exception {
-    if (description.getAnnotation(IgnoreGroupInconsistencies.class) == null) {
+    if (configRule.description().getAnnotation(IgnoreGroupInconsistencies.class) == null) {
       assertThat(consistencyChecker.check()).isEmpty();
     }
   }
 
   @Override
-  protected ProjectResetter.Config resetProjects() {
+  protected ProjectResetter.Config resetProjects(
+      AllProjectsName allProjects, AllUsersName allUsers) {
     // Don't reset All-Users since deleting users makes groups inconsistent (e.g. groups would
     // contain members that no longer exist) and as result of this the group consistency checker
     // that is executed after each test would fail.
-    return new ProjectResetter.Config().reset(allProjects, RefNames.REFS_CONFIG);
+    return new ProjectResetter.Config.Builder().reset(allProjects, RefNames.REFS_CONFIG).build();
   }
 
   @Test
@@ -292,7 +297,9 @@
     Account.Id accountId = accountOperations.newAccount().username(username).create();
 
     // Fill the cache for the observed account.
-    groupIncludeCache.getGroupsWithMember(accountId);
+    @SuppressWarnings("unused")
+    var unused = groupIncludeCache.getGroupsWithMember(accountId);
+
     AccountGroup.UUID groupUuid = groupOperations.newGroup().create();
 
     gApi.groups().id(groupUuid.get()).addMembers(username);
@@ -578,7 +585,8 @@
     Account.Id accountId = accountOperations.newAccount().create();
 
     // Fill the cache for the observed account.
-    groupIncludeCache.getGroupsWithMember(accountId);
+    @SuppressWarnings("unused")
+    var unused = groupIncludeCache.getGroupsWithMember(accountId);
 
     GroupInput groupInput = new GroupInput();
     groupInput.name = name("Users");
@@ -654,7 +662,7 @@
   @Test
   public void groupCannotBeCreatedWithNameOfAnotherGroup() throws Exception {
     String name = name("Users");
-    gApi.groups().create(name).get();
+    gApi.groups().create(name);
 
     assertThrows(ResourceConflictException.class, () -> gApi.groups().create(name));
   }
@@ -937,7 +945,7 @@
 
   @Test
   public void defaultGroupsCreated() throws Exception {
-    Iterable<String> names = gApi.groups().list().getAsMap().keySet();
+    Set<String> names = gApi.groups().list().getAsMap().keySet();
     assertThat(names)
         .containsAtLeast("Administrators", ServiceUserClassifier.SERVICE_USERS)
         .inOrder();
@@ -1348,9 +1356,12 @@
   public void cannotCreateGroupNamesBranch() throws Exception {
     // Use ProjectResetter to restore the group names ref
     try (ProjectResetter resetter =
-        projectResetter
+        projectResetterFactory
             .builder()
-            .build(new ProjectResetter.Config().reset(allUsers, RefNames.REFS_GROUPNAMES))) {
+            .build(
+                new ProjectResetter.Config.Builder()
+                    .reset(allUsers, RefNames.REFS_GROUPNAMES)
+                    .build())) {
       // Manually delete group names ref
       try (Repository repo = repoManager.openRepository(allUsers);
           RevWalk rw = new RevWalk(repo)) {
@@ -1482,7 +1493,7 @@
     GroupInput groupInput = new GroupInput();
     groupInput.name = name("contributors");
     groupInput.members = ImmutableList.of(user.username());
-    gApi.groups().create(groupInput).get();
+    gApi.groups().create(groupInput);
     restartAsSlave();
 
     requestScopeOperations.setApiUser(user.id());
@@ -1690,7 +1701,7 @@
   }
 
   private static void assertIncludes(List<GroupInfo> includes, String... expectedNames) {
-    List<String> names = includes.stream().map(i -> i.name).collect(toImmutableList());
+    ImmutableList<String> names = includes.stream().map(i -> i.name).collect(toImmutableList());
     assertThat(names).containsExactlyElementsIn(Arrays.asList(expectedNames));
     assertThat(names).isInOrder();
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
index 2796488..6eecda9 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.group;
 
-import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index 462d0a5..dbd1fb8 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -145,7 +145,9 @@
         new com.google.gerrit.extensions.common.InstallPluginInput();
     input.raw = JS_PLUGIN_CONTENT;
     gApi.plugins().install("legacy.js", input);
-    gApi.plugins().name("legacy").get();
+
+    @SuppressWarnings("unused")
+    var unused = gApi.plugins().name("legacy").get();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
index a2f1f46..035b567 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
@@ -244,7 +243,8 @@
       testRefAction(() -> assertThat(u.delete()).isEqualTo(Result.FORCED));
 
       // This should not crash.
-      pApi().access();
+      @SuppressWarnings("unused")
+      var unused = pApi().access();
     }
   }
 
@@ -465,9 +465,11 @@
         .forUpdate()
         .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
         .update();
+
     // User can see the branch
     requestScopeOperations.setApiUser(user.id());
-    pApi().branch("refs/heads/master").get();
+    @SuppressWarnings("unused")
+    var unused = pApi().branch("refs/heads/master").get();
 
     ProjectAccessInput accessInput = newProjectAccessInput();
 
@@ -516,7 +518,7 @@
 
     // Now it works again.
     requestScopeOperations.setApiUser(user.id());
-    pApi().branch("refs/heads/master").get();
+    unused = pApi().branch("refs/heads/master").get();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 27193dd..a5fa55a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -227,7 +227,7 @@
 
   @Test
   public void accessible() throws Exception {
-    List<TestCase> inputs =
+    ImmutableList<TestCase> inputs =
         ImmutableList.of(
             // Test 1
             TestCase.projectRefPerm(
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
index 4163e17..02f6784 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckProjectIT.java
@@ -253,7 +253,9 @@
   @Test
   public void branchPrefixCanBeOmitted() throws Exception {
     CheckProjectInput input = checkProjectInputForAutoCloseableCheck("master");
-    gApi.projects().name(project.get()).check(input);
+
+    @SuppressWarnings("unused")
+    var unused = gApi.projects().name(project.get()).check(input);
   }
 
   @Test
@@ -261,7 +263,9 @@
     CheckProjectInput input = checkProjectInputForAutoCloseableCheck("refs/heads/master");
     input.autoCloseableChangesCheck.maxCommits =
         ProjectsConsistencyChecker.AUTO_CLOSE_MAX_COMMITS_LIMIT;
-    gApi.projects().name(project.get()).check(input);
+
+    @SuppressWarnings("unused")
+    var unused = gApi.projects().name(project.get()).check(input);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index 84a4a40..bf3c80f 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -47,6 +47,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -121,7 +122,10 @@
 
     createBranch(BranchNameKey.create(project, "test-branch-1"));
     createBranch(BranchNameKey.create(project, "test-branch-2"));
-    createAndSubmitChange("refs/for/test-branch-1");
+    RevCommit changeCommit = createAndSubmitChange("refs/for/test-branch-1").getCommit();
+    // Reset repo back to the original state - otherwise all changes in tests have testChange as a
+    // parent.
+    testRepo.reset(changeCommit.getParent(0));
     createAndSubmitChange("refs/for/test-branch-2");
 
     assertThat(getIncludedIn(baseChange.getCommit().getId()).branches)
@@ -550,6 +554,51 @@
         .containsExactly(baseChangeNumber, cherryPickChange._number);
   }
 
+  @Test
+  public void editMessageWithSecondaryEmail() throws Exception {
+    // Create new user with a secondary email
+    Account.Id testUser =
+        accountOperations
+            .newAccount()
+            .preferredEmail("preferred@example.com")
+            .addSecondaryEmail("secondary@example.com")
+            .create();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Create a change and edit its message using secondary email
+    PushOneCommit.Result r = createChange();
+    RevCommit commit = r.getCommit();
+    String message = commit.getFullMessage();
+    CommitMessageInput in = new CommitMessageInput();
+    in.message = "new message" + message;
+    in.committerEmail = "secondary@example.com";
+    gApi.changes().id(r.getChangeId()).setMessage(in);
+    CommitInfo newCommit = gApi.changes().id(r.getChangeId()).current().commit(false);
+    assertThat(newCommit.message).contains("new message");
+    assertThat(newCommit.committer.email).isEqualTo("secondary@example.com");
+  }
+
+  @Test
+  public void cannotEditMessageWithUnregisteredEmail() throws Exception {
+    PushOneCommit.Result r = createChange();
+    RevCommit commit = r.getCommit();
+    String message = commit.getFullMessage();
+    CommitMessageInput in = new CommitMessageInput();
+    in.message = "new message" + message;
+    in.committerEmail = "unregistered@example.com";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(r.getChangeId()).setMessage(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Cannot set commit message using committer email %s,"
+                    + " as it is not among the registered emails of account %s",
+                in.committerEmail, admin.id()));
+  }
+
   private IncludedInInfo getIncludedIn(ObjectId id) throws Exception {
     return gApi.projects().name(project.get()).commit(id.name()).includedIn();
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
index a22b558..b9cbbcd 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -38,7 +38,6 @@
 import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -219,8 +218,7 @@
         }
       }
       cb.add(info.path, content.toString());
-      RevCommit c = cb.create();
-      project().commit(c.name());
+      cb.create();
     }
     return info;
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
index 28a0196..9a926ea 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
@@ -20,6 +20,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
@@ -29,15 +31,22 @@
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.ConfigValue;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.git.validators.ValidationMessage;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.GroupList;
 import com.google.gerrit.server.project.LabelConfigValidator;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -51,6 +60,7 @@
           + "  copyAllScoresOnTrivialRebase = true";
 
   @Inject private ProjectOperations projectOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Test
   public void noLabelValidationForNonRefsMetaConfigChange() throws Exception {
@@ -515,6 +525,119 @@
   }
 
   @Test
+  public void pluginConfigs_neverWriteDefaultValueToConfigFile() throws Exception {
+    String projectConfig = projectOperations.project(project).getConfig().toText();
+    assertThat(projectConfig).doesNotContain("myPlugin");
+
+    ProjectConfigEntry entry = new ProjectConfigEntry("enabled", "true");
+    try (Registration ignored =
+        extensionRegistry.newRegistration().add(entry, "test-config-entry")) {
+      // Default value is populated in API response
+      ConfigInfo configInfo = gApi.projects().name(project.get()).config();
+      assertThat(configInfo.pluginConfig.get("myPlugin").get("test-config-entry").value)
+          .isEqualTo("true");
+
+      // Set an unrelated parameter
+      ConfigInput input = new ConfigInput();
+      input.description = "New description";
+
+      gApi.projects().name(project.get()).config(input);
+
+      // The project config does not contain a section for the plugin
+      projectConfig = projectOperations.project(project).getConfig().toText();
+      assertThat(projectConfig).doesNotContain("myPlugin");
+      assertThat(projectConfig).contains("New description");
+
+      // Set the plugin config to the default value. Set an unrelated setting on the side.
+      Map<String, ConfigValue> val = new HashMap<>();
+      input.pluginConfigValues = new HashMap<>();
+      input.pluginConfigValues.put("myPlugin", val);
+      val.put("test-config-entry", new ConfigValue("true"));
+      input.description = "New description2";
+      gApi.projects().name(project.get()).config(input);
+
+      // The project config does not contain a section for the plugin
+      projectConfig = projectOperations.project(project).getConfig().toText();
+      assertThat(projectConfig).doesNotContain("myPlugin");
+      assertThat(projectConfig).contains("New description2");
+    }
+  }
+
+  @Test
+  public void pluginConfigs_persistNonDefaultConfig() throws Exception {
+    String projectConfig = projectOperations.project(project).getConfig().toText();
+    assertThat(projectConfig).doesNotContain("myPlugin");
+
+    ProjectConfigEntry entry = new ProjectConfigEntry("enabled", "true");
+    try (Registration ignored =
+        extensionRegistry.newRegistration().add(entry, "test-config-entry")) {
+      // Default value is populated in API response
+      ConfigInfo configInfo = gApi.projects().name(project.get()).config();
+      assertThat(configInfo.pluginConfig.get("myPlugin").get("test-config-entry").value)
+          .isEqualTo("true");
+
+      // Change value to non-default
+      ConfigInput input = new ConfigInput();
+      input.pluginConfigValues = new HashMap<>();
+      Map<String, ConfigValue> val = new HashMap<>();
+      input.pluginConfigValues.put("myPlugin", val);
+      val.put("test-config-entry", new ConfigValue("false"));
+      gApi.projects().name(project.get()).config(input);
+
+      // API response serves new setting
+      configInfo = gApi.projects().name(project.get()).config();
+      assertThat(configInfo.pluginConfig.get("myPlugin").get("test-config-entry").value)
+          .isEqualTo("false");
+
+      // The project config contains a section for the plugin
+      projectConfig = projectOperations.project(project).getConfig().toText();
+      assertThat(projectConfig).contains("myPlugin");
+    }
+  }
+
+  @Test
+  public void pluginConfigs_canUnsetPluginSetting() throws Exception {
+    String projectConfig = projectOperations.project(project).getConfig().toText();
+    assertThat(projectConfig).doesNotContain("myPlugin");
+
+    ProjectConfigEntry entry = new ProjectConfigEntry("enabled", "true");
+    try (Registration ignored =
+        extensionRegistry.newRegistration().add(entry, "test-config-entry")) {
+      // Default value is populated in API response
+      ConfigInfo configInfo = gApi.projects().name(project.get()).config();
+      assertThat(configInfo.pluginConfig.get("myPlugin").get("test-config-entry").value)
+          .isEqualTo("true");
+
+      // Change value to non-default
+      ConfigInput input = new ConfigInput();
+      input.pluginConfigValues = new HashMap<>();
+      Map<String, ConfigValue> val = new HashMap<>();
+      input.pluginConfigValues.put("myPlugin", val);
+      val.put("test-config-entry", new ConfigValue("false"));
+      gApi.projects().name(project.get()).config(input);
+
+      // API response serves new setting
+      configInfo = gApi.projects().name(project.get()).config();
+      assertThat(configInfo.pluginConfig.get("myPlugin").get("test-config-entry").value)
+          .isEqualTo("false");
+
+      // The project config contains a section for the plugin
+      projectConfig = projectOperations.project(project).getConfig().toText();
+      assertThat(projectConfig).contains("myPlugin");
+
+      // Reset value to default
+      val.put("test-config-entry", new ConfigValue("true"));
+      gApi.projects().name(project.get()).config(input);
+
+      projectConfig = projectOperations.project(project).getConfig().toText();
+      assertThat(projectConfig).doesNotContain("myPlugin");
+      configInfo = gApi.projects().name(project.get()).config();
+      assertThat(configInfo.pluginConfig.get("myPlugin").get("test-config-entry").value)
+          .isEqualTo("true");
+    }
+  }
+
+  @Test
   public void rejectSubmitRequirement_duplicateApplicableIfKeys() throws Exception {
     fetchRefsMetaConfig();
     PushOneCommit push =
@@ -1059,7 +1182,100 @@
   }
 
   @Test
-  public void setCopyCondition() throws Exception {
+  public void testSettingCopyCondition() throws Exception {
+    testChangingCopyCondition(/* initialCopyCondition= */ null, /* newCopyCondition= */ "is:ANY");
+  }
+
+  @Test
+  public void testRejectNonParseableCopyCondition_badSyntax() throws Exception {
+    testChangingCopyConditionExpectError(
+        /* initialCopyCondition= */ "is:ANY",
+        /* newCopyCondition= */ ":",
+        /* errorMessage= */ "Cannot parse copy condition ':' of label Foo (parameter"
+            + " 'label.Foo.copyCondition'): line 1:0 no viable alternative at input ':'");
+  }
+
+  @Test
+  public void testRejectNonParseableCopyCondition_unsupportedOperator() throws Exception {
+    testChangingCopyConditionExpectError(
+        /* initialCopyCondition= */ "is:ANY",
+        /* newCopyCondition= */ "foo:bar",
+        /* errorMessage= */ "Cannot parse copy condition 'foo:bar' of label Foo (parameter"
+            + " 'label.Foo.copyCondition'): unsupported operator foo:bar");
+  }
+
+  @Test
+  public void testFixNonParseableCopyCondition() throws Exception {
+    testChangingCopyCondition(/* initialCopyCondition= */ ":", /* newCopyCondition= */ "is:ANY");
+  }
+
+  @Test
+  public void testChangingCopyCondition() throws Exception {
+    testChangingCopyCondition(
+        /* initialCopyCondition= */ "is:ANY", /* newCopyCondition= */ "is:MAX");
+  }
+
+  @Test
+  public void testDeletingCopyCondition() throws Exception {
+    testChangingCopyCondition(/* initialCopyCondition= */ "is:ANY", /* newCopyCondition= */ null);
+  }
+
+  @Test
+  public void testDeletingNonParseableCopyCondition() throws Exception {
+    testChangingCopyCondition(/* initialCopyCondition= */ ":", /* newCopyCondition= */ null);
+  }
+
+  @Test
+  public void testChangingNonParseableCopyCondition() throws Exception {
+    testChangingCopyConditionExpectWarning(
+        /* initialCopyCondition= */ ":",
+        /* newCopyCondition= */ ":foo",
+        /* warningMessage= */ "Cannot parse copy condition ':foo' of label Foo (parameter"
+            + " 'label.Foo.copyCondition'): line 1:0 no viable alternative at input ':'");
+  }
+
+  private void testChangingCopyCondition(
+      String initialCopyCondition, @Nullable String newCopyCondition) throws Exception {
+    testChangingCopyCondition(
+        initialCopyCondition, newCopyCondition, /* type= */ null, /* message= */ null);
+  }
+
+  private void testChangingCopyConditionExpectError(
+      String initialCopyCondition, @Nullable String newCopyCondition, String errorMessage)
+      throws Exception {
+    testChangingCopyCondition(
+        initialCopyCondition, newCopyCondition, ValidationMessage.Type.ERROR, errorMessage);
+  }
+
+  private void testChangingCopyConditionExpectWarning(
+      String initialCopyCondition, @Nullable String newCopyCondition, String warningMessage)
+      throws Exception {
+    testChangingCopyCondition(
+        initialCopyCondition, newCopyCondition, ValidationMessage.Type.WARNING, warningMessage);
+  }
+
+  private void testChangingCopyCondition(
+      @Nullable String initialCopyCondition,
+      @Nullable String newCopyCondition,
+      @Nullable ValidationMessage.Type type,
+      @Nullable String message)
+      throws Exception {
+    if (initialCopyCondition != null) {
+      try (TestRepository<Repository> testRepo =
+          new TestRepository<>(repoManager.openRepository(project))) {
+        testRepo
+            .branch(RefNames.REFS_CONFIG)
+            .commit()
+            .add(
+                ProjectConfig.PROJECT_CONFIG,
+                String.format(
+                    "[label \"Foo\"]\n  %s = %s\n",
+                    ProjectConfig.KEY_COPY_CONDITION, initialCopyCondition))
+            .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+            .create();
+      }
+    }
+
     fetchRefsMetaConfig();
     PushOneCommit push =
         pushFactory.create(
@@ -1067,9 +1283,22 @@
             testRepo,
             "Test Change",
             ProjectConfig.PROJECT_CONFIG,
-            String.format("[label \"Foo\"]\n  %s = is:ANY", ProjectConfig.KEY_COPY_CONDITION));
+            newCopyCondition == null
+                ? "[label \"Foo\"]\n"
+                : String.format(
+                    "[label \"Foo\"]\n  %s = %s\n",
+                    ProjectConfig.KEY_COPY_CONDITION, newCopyCondition));
     PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
-    r.assertOkStatus();
+    if (!ValidationMessage.Type.ERROR.equals(type)) {
+      r.assertOkStatus();
+      return;
+    }
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    if (message != null) {
+      r.assertMessage(message);
+    }
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index a93c0a5..e34b985 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
@@ -1140,6 +1139,10 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.disabled",
+      // The test intentionally create an implicit merge change.
+      value = "GerritBackendFeature__reject_implicit_merges_on_merge")
   public void commitsIncludedInRefsMergedChangeNonTipCommit() throws Exception {
     String branchWithChange1 = R_HEADS + "branch-with-change1";
     String tagWithChange1 = R_TAGS + "tag-with-change1";
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index 4302b50..a99cf52 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
@@ -37,7 +37,6 @@
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import java.util.Collection;
-import java.util.Map;
 import java.util.Optional;
 import java.util.function.Consumer;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -69,7 +68,8 @@
     Iterable<byte[]> refState = result.get().getValue(ProjectField.REF_STATE_SPEC);
     assertThat(refState).isNotEmpty();
 
-    Map<Project.NameKey, Collection<RefState>> states = RefState.parseStates(refState).asMap();
+    ImmutableMap<Project.NameKey, Collection<RefState>> states =
+        RefState.parseStates(refState).asMap();
 
     fetch(testRepo, "refs/meta/config:refs/meta/config");
     Ref projectConfigRef = testRepo.getRepository().exactRef("refs/meta/config");
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
index 97a2d2b..e388dd1 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
@@ -58,7 +58,7 @@
     input.name = "code-review";
     input.applicabilityExpression = "topic:foo";
     input.submittabilityExpression = "label:code-review=+2";
-    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input);
 
     SubmitRequirementInfo info =
         gApi.projects().name(project.get()).submitRequirement("code-review").get();
@@ -74,7 +74,7 @@
     input.name = "code-review";
     input.applicabilityExpression = "topic:foo";
     input.submittabilityExpression = "label:code-review=+2";
-    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input);
 
     input.submittabilityExpression = "label:code-review=+1";
     SubmitRequirementInfo info =
@@ -88,7 +88,7 @@
     input.name = "code-review";
     input.applicabilityExpression = "topic:foo";
     input.submittabilityExpression = "label:code-review=+2";
-    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input);
 
     input.applicabilityExpression = null;
     SubmitRequirementInfo info =
@@ -102,7 +102,7 @@
     input.name = "code-review";
     input.overrideExpression = "topic:foo";
     input.submittabilityExpression = "label:code-review=+2";
-    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input);
 
     input.overrideExpression = null;
     SubmitRequirementInfo info =
@@ -115,7 +115,7 @@
     SubmitRequirementInput input = new SubmitRequirementInput();
     input.name = "code-review";
     input.submittabilityExpression = "label:code-review=+2";
-    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input);
 
     input.overrideExpression = "topic:foo";
     SubmitRequirementInfo info =
@@ -129,7 +129,7 @@
     input.name = "code-review";
     input.submittabilityExpression = "label:code-review=+2";
 
-    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input);
     input.submittabilityExpression = "label:code-review=+1";
     requestScopeOperations.setApiUserAnonymous();
     AuthException thrown =
@@ -166,7 +166,7 @@
     input.name = "code-review";
     input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
 
-    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input);
     input.submittabilityExpression = null;
     BadRequestException thrown =
         assertThrows(
@@ -183,7 +183,7 @@
     input.name = "code-review";
     input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
 
-    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input);
     input.submittabilityExpression = "invalid_field:invalid_value";
     BadRequestException thrown =
         assertThrows(
@@ -207,7 +207,7 @@
     input.name = "code-review";
     input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
 
-    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input);
     input.overrideExpression = "invalid_field:invalid_value";
     BadRequestException thrown =
         assertThrows(
@@ -231,7 +231,7 @@
     input.name = "code-review";
     input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
 
-    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input);
     input.applicabilityExpression = "invalid_field:invalid_value";
     BadRequestException thrown =
         assertThrows(
@@ -531,8 +531,11 @@
         .forUpdate()
         .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
         .update();
+
     requestScopeOperations.setApiUser(user.id());
-    gApi.projects().name(project.get()).submitRequirements().get();
+
+    @SuppressWarnings("unused")
+    var unused = gApi.projects().name(project.get()).submitRequirements().get();
   }
 
   @Test
@@ -547,8 +550,12 @@
         .forUpdate()
         .add(block(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
         .update();
+
     requestScopeOperations.setApiUser(user.id());
-    gApi.projects().name(project.get()).submitRequirements().withInherited(false).get();
+
+    @SuppressWarnings("unused")
+    var unused =
+        gApi.projects().name(project.get()).submitRequirements().withInherited(false).get();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/BUILD b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
index 517b041..9c6584e 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
@@ -4,7 +4,10 @@
     srcs = [f],
     group = f[:f.index(".")],
     labels = ["api"],
-    deps = [":revision-diff-it"],
+    deps = [
+        ":revision-diff-it",
+        "//javatests/com/google/gerrit/acceptance/server/change:util",
+    ],
 ) for f in glob(["*IT.java"])]
 
 # This is needed because RevisionDiffIT has subclasses that depend on it
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/CommentWithFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/CommentWithFixIT.java
new file mode 100644
index 0000000..d411a21
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/CommentWithFixIT.java
@@ -0,0 +1,1397 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.server.change.CommentsUtil.createFixReplacementInfo;
+import static com.google.gerrit.acceptance.server.change.CommentsUtil.createFixSuggestionInfo;
+import static com.google.gerrit.acceptance.server.change.CommentsUtil.createRange;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThatList;
+import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+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;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffInfo.IntraLineStatus;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.testing.BinaryResultSubject;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestCommentHelper;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CommentWithFixIT extends AbstractDaemonTest {
+  @Inject private TestCommentHelper testCommentHelper;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExperimentFeatures experimentFeatures;
+
+  private static final String PLAIN_TEXT_CONTENT_TYPE = "text/plain";
+  private static final String GERRIT_COMMIT_MESSAGE_TYPE = "text/x-gerrit-commit-message";
+
+  private static final String FILE_NAME = "file_to_fix.txt";
+  private static final String FILE_NAME2 = "another_file_to_fix.txt";
+  private static final String FILE_NAME3 = "file_without_newline_at_end.txt";
+  private static final String FILE_CONTENT =
+      "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
+          + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+  private static final String FILE_CONTENT3 = "1st line\n2nd line";
+  private String changeId;
+  private String commitId;
+  private FixReplacementInfo fixReplacementInfo;
+  private FixSuggestionInfo fixSuggestionInfo;
+  private CommentInput withFixCommentInput;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Provide files which can be used for fixes",
+            ImmutableMap.of(
+                FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2, FILE_NAME3, FILE_CONTENT3));
+    PushOneCommit.Result changeResult = push.to("refs/for/master");
+    changeId = changeResult.getChangeId();
+    commitId = changeResult.getCommit().getName();
+
+    fixReplacementInfo = createFixReplacementInfo();
+    fixSuggestionInfo = createFixSuggestionInfo(fixReplacementInfo);
+    withFixCommentInput = TestCommentHelper.createCommentInput(FILE_NAME, fixSuggestionInfo);
+  }
+
+  @ConfigSuite.Default
+  public static Config setExperimentFlag() {
+    Config cfg = new Config();
+    cfg.setString(
+        "experiments",
+        null,
+        "enabled",
+        ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS);
+    return cfg;
+  }
+
+  @Test
+  public void fixSuggestionCannotPointToPatchsetLevel() throws Exception {
+    CommentInput input = TestCommentHelper.createCommentInput(FILE_NAME);
+    FixReplacementInfo brokenFixReplacement = createFixReplacementInfo();
+    brokenFixReplacement.path = PATCHSET_LEVEL;
+    input.fixSuggestions = ImmutableList.of(createFixSuggestionInfo(brokenFixReplacement));
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> testCommentHelper.addComment(changeId, input));
+    assertThat(ex.getMessage()).contains("file path must not be " + PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void hugeCommentIsRejected() {
+    int defaultSizeLimit = 1 << 20;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit + 1);
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> testCommentHelper.addComment(changeId, withFixCommentInput));
+    assertThat(thrown).hasMessageThat().contains("limit");
+  }
+
+  @Test
+  public void reasonablyLargeCommentIsAccepted() throws Exception {
+    int defaultSizeLimit = 1 << 10;
+    // Allow for a few hundred bytes in other fields.
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - 666);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+
+    List<CommentInfo> commentInfos = getComments();
+    assertThat(commentInfos).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "change.commentSizeLimit", value = "0")
+  public void zeroForMaximumAllowedSizeOfCommentRemovesRestriction() throws Exception {
+    int defaultSizeLimit = 1 << 10;
+    fixReplacementInfo.replacement = getStringFor(2 * defaultSizeLimit);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+
+    List<CommentInfo> commentInfos = getComments();
+    assertThat(commentInfos).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "change.commentSizeLimit", value = "-1")
+  public void negativeValueForMaximumAllowedSizeOfCommentRemovesRestriction() throws Exception {
+    int defaultSizeLimit = 1 << 20;
+    fixReplacementInfo.replacement = getStringFor(2 * defaultSizeLimit);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+
+    List<CommentInfo> commentInfos = getComments();
+    assertThat(commentInfos).hasSize(1);
+  }
+
+  @Test
+  public void addedFixSuggestionCanBeRetrieved() throws Exception {
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    assertThatList(commentInfos).onlyElement().onlyFixSuggestion().isNotNull();
+  }
+
+  @Test
+  public void fixIdIsGeneratedForFixSuggestion() throws Exception {
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    assertThatList(commentInfos).onlyElement().onlyFixSuggestion().fixId().isNotEmpty();
+    assertThatList(commentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .fixId()
+        .isNotEqualTo(fixSuggestionInfo.fixId);
+  }
+
+  @Test
+  public void descriptionOfFixSuggestionIsAcceptedAsIs() throws Exception {
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    assertThatList(commentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .description()
+        .isEqualTo(fixSuggestionInfo.description);
+  }
+
+  @Test
+  public void descriptionOfFixSuggestionIsMandatory() {
+    fixSuggestionInfo.description = null;
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> testCommentHelper.addComment(changeId, withFixCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A description is required for the suggested fix of the comment on %s",
+                withFixCommentInput.path));
+  }
+
+  @Test
+  public void addedFixReplacementCanBeRetrieved() throws Exception {
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    assertThatList(commentInfos).onlyElement().onlyFixSuggestion().onlyReplacement().isNotNull();
+  }
+
+  @Test
+  public void fixReplacementsAreMandatory() {
+    fixSuggestionInfo.replacements = Collections.emptyList();
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> testCommentHelper.addComment(changeId, withFixCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "At least one replacement is required"
+                    + " for the suggested fix of the comment on %s",
+                withFixCommentInput.path));
+  }
+
+  @Test
+  public void pathOfFixReplacementIsAcceptedAsIs() throws Exception {
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+
+    List<CommentInfo> commentInfos = getComments();
+
+    assertThatList(commentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .onlyReplacement()
+        .path()
+        .isEqualTo(fixReplacementInfo.path);
+  }
+
+  @Test
+  public void pathOfFixReplacementIsMandatory() {
+    fixReplacementInfo.path = null;
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> testCommentHelper.addComment(changeId, withFixCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A file path must be given for the replacement of the comment on %s",
+                withFixCommentInput.path));
+  }
+
+  @Test
+  public void rangeOfFixReplacementIsAcceptedAsIs() throws Exception {
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+
+    List<CommentInfo> commentInfos = getComments();
+
+    assertThatList(commentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .onlyReplacement()
+        .range()
+        .isEqualTo(fixReplacementInfo.range);
+  }
+
+  @Test
+  public void rangeOfFixReplacementIsMandatory() {
+    fixReplacementInfo.range = null;
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> testCommentHelper.addComment(changeId, withFixCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A range must be given for the replacement of the comment on %s",
+                withFixCommentInput.path));
+  }
+
+  @Test
+  public void rangeOfFixReplacementNeedsToBeValid() {
+    fixReplacementInfo.range = createRange(13, 9, 5, 10);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> testCommentHelper.addComment(changeId, withFixCommentInput));
+    assertThat(thrown).hasMessageThat().contains("Range (13:9 - 5:10)");
+  }
+
+  @Test
+  public void commentWithRangeAndLine_lineIsIgnored() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    withFixCommentInput.line = 1;
+    withFixCommentInput.range = createRange(2, 0, 3, 1);
+    withFixCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> comments = getComments();
+    assertThat(comments.get(0).line).isEqualTo(3);
+  }
+
+  @Test
+  public void rangesOfFixReplacementsOfSameFixSuggestionForSameFileMayNotOverlap() {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> testCommentHelper.addComment(changeId, withFixCommentInput));
+    assertThat(thrown).hasMessageThat().contains("overlap");
+  }
+
+  @Test
+  public void rangesOfFixReplacementsOfSameFixSuggestionForDifferentFileMayOverlap()
+      throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+
+    List<CommentInfo> commentInfos = getComments();
+    assertThatList(commentInfos).onlyElement().fixSuggestions().hasSize(1);
+  }
+
+  @Test
+  public void rangesOfFixReplacementsOfDifferentFixSuggestionsForSameFileMayOverlap()
+      throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    withFixCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+
+    List<CommentInfo> commentInfos = getComments();
+    assertThatList(commentInfos).onlyElement().fixSuggestions().hasSize(2);
+  }
+
+  @Test
+  public void fixReplacementsDoNotNeedToBeOrderedAccordingToRange() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixReplacementInfo fixReplacementInfo3 = new FixReplacementInfo();
+    fixReplacementInfo3.path = FILE_NAME;
+    fixReplacementInfo3.range = createRange(4, 0, 5, 0);
+    fixReplacementInfo3.replacement = "Third modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo2, fixReplacementInfo1, fixReplacementInfo3);
+    withFixCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+
+    List<CommentInfo> commentInfos = getComments();
+    assertThatList(commentInfos).onlyElement().onlyFixSuggestion().replacements().hasSize(3);
+  }
+
+  @Test
+  public void replacementStringOfFixReplacementIsAcceptedAsIs() throws Exception {
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+
+    List<CommentInfo> commentInfos = getComments();
+
+    assertThatList(commentInfos)
+        .onlyElement()
+        .onlyFixSuggestion()
+        .onlyReplacement()
+        .replacement()
+        .isEqualTo(fixReplacementInfo.replacement);
+  }
+
+  @Test
+  public void replacementStringOfFixReplacementIsMandatory() {
+    fixReplacementInfo.replacement = null;
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> testCommentHelper.addComment(changeId, withFixCommentInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "A content for replacement must be "
+                    + "indicated for the replacement of the comment on %s",
+                withFixCommentInput.path));
+  }
+
+  @Test
+  public void storedFixWithinALineCanBeApplied() throws Exception {
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void 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.addComment(project + "~" + change.get(), withFixCommentInput);
+
+    // 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<CommentInfo> commentInfoList = gApi.changes().id(change.get()).current().commentsAsList();
+
+    List<String> fixIds = getFixIds(commentInfoList);
+    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";
+    fixReplacementInfo.range = createRange(3, 2, 5, 3);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nThModified content\n5th line\nSixth line\nSeventh line\n"
+                + "Eighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void storedFixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nSome other modified content\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoStoredFixesOnSameFileCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    CommentInput commentInput1 =
+        TestCommentHelper.createCommentInput(FILE_NAME, fixSuggestionInfo1);
+    CommentInput commentInput2 =
+        TestCommentHelper.createCommentInput(FILE_NAME, fixSuggestionInfo2);
+    testCommentHelper.addComment(changeId, commentInput1);
+    testCommentHelper.addComment(changeId, commentInput2);
+    List<CommentInfo> commentInfos = getComments();
+
+    List<String> fixIds = getFixIds(commentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoConflictingStoredFixesOnSameFileCannotBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    CommentInput commentInput1 =
+        TestCommentHelper.createCommentInput(FILE_NAME, fixSuggestionInfo1);
+    CommentInput commentInput2 =
+        TestCommentHelper.createCommentInput(FILE_NAME, fixSuggestionInfo2);
+    testCommentHelper.addComment(changeId, commentInput1);
+    testCommentHelper.addComment(changeId, commentInput2);
+    List<CommentInfo> commentInfos = getComments();
+
+    List<String> fixIds = getFixIds(commentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(fixIds.get(1)));
+    assertThat(thrown).hasMessageThat().contains("merge");
+  }
+
+  @Test
+  public void twoStoredFixesOfSameCommentCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    withFixCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    List<String> fixIds = getFixIds(commentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void storedFixReferringToDifferentFileThanCommentCanBeApplied() throws Exception {
+    fixReplacementInfo.path = FILE_NAME2;
+    fixReplacementInfo.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo.replacement = "Modified content\n";
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo("1st line\nModified content\n3rd line\n");
+  }
+
+  @Test
+  public void storedFixInvolvingTwoFilesCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "Different file modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nEighth line\nNinth line\nTenth line\n");
+    Optional<BinaryResult> file2 = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file2)
+        .value()
+        .asString()
+        .isEqualTo("Different file modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void storedFixReferringToNonExistentFileCannotBeApplied() throws Exception {
+    fixReplacementInfo.path = "a_non_existent_file.txt";
+    fixReplacementInfo.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo.replacement = "Modified content\n";
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(fixId));
+  }
+
+  @Test
+  public void storedFixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId));
+    assertThat(thrown).hasMessageThat().contains("current");
+  }
+
+  @Test
+  public void storedFixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+    assertThat(editInfo).baseRevision().isEqualTo(previousRevision);
+  }
+
+  @Test
+  public void storedFixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
+      throws Exception {
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    // Add another patch set.
+    amendChange(changeId);
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(fixId));
+    assertThat(thrown).hasMessageThat().contains("based");
+  }
+
+  @Test
+  public void storedFixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
+    String changeEditCommitMessage =
+        "This is the commit message of the change edit.\n\nChange-Id: " + changeId + "\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(changeEditCommitMessage);
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    String fixId = Iterables.getOnlyElement(getFixIds(getComments()));
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo(changeEditCommitMessage);
+  }
+
+  @Test
+  public void storedFixOnCommitMessageCanBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    withFixCommentInput.path = Patch.COMMIT_MSG;
+    fixReplacementInfo.path = Patch.COMMIT_MSG;
+    fixReplacementInfo.replacement = "Modified line\n";
+    fixReplacementInfo.range = createRange(7, 0, 8, 0);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    String fixId = Iterables.getOnlyElement(getFixIds(getComments()));
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo("Modified line\nLine 2 of commit message\n" + footer);
+  }
+
+  @Test
+  public void storedFixOnHeaderPartOfCommitMessageCannotBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "Change-Id: " + changeId;
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\n" + "\n" + footer + "\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    withFixCommentInput.path = Patch.COMMIT_MSG;
+    fixReplacementInfo.path = Patch.COMMIT_MSG;
+    fixReplacementInfo.replacement = "Modified line\n";
+    fixReplacementInfo.range = createRange(1, 0, 2, 0);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    String fixId = Iterables.getOnlyElement(getFixIds(getComments()));
+
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(fixId));
+    assertThat(exception).hasMessageThat().contains("header");
+  }
+
+  @Test
+  public void storedFixContainingSeveralModificationsOfCommitMessageCanBeApplied()
+      throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = Patch.COMMIT_MSG;
+    fixReplacementInfo2.range = createRange(9, 0, 10, 0);
+    fixReplacementInfo2.replacement = "Modified line 3\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+    withFixCommentInput.path = Patch.COMMIT_MSG;
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    String fixId = Iterables.getOnlyElement(getFixIds(getComments()));
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage)
+        .isEqualTo("Modified line 1\nLine 2 of commit message\nModified line 3\n" + footer);
+  }
+
+  @Test
+  public void storedFixModifyingTheCommitMessageAndAFileCanBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "File modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    String fixId = Iterables.getOnlyElement(getFixIds(getComments()));
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo("Modified line 1\nLine 2 of commit message\n" + footer);
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo("File modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void twoStoredFixesOnCommitMessageCanBeAppliedOneAfterTheOther() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = Patch.COMMIT_MSG;
+    fixReplacementInfo2.range = createRange(9, 0, 10, 0);
+    fixReplacementInfo2.replacement = "Modified line 3\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    CommentInput commentInput1 =
+        TestCommentHelper.createCommentInput(FILE_NAME, fixSuggestionInfo1);
+    CommentInput commentInput2 =
+        TestCommentHelper.createCommentInput(FILE_NAME, fixSuggestionInfo2);
+    testCommentHelper.addComment(changeId, commentInput1);
+    testCommentHelper.addComment(changeId, commentInput2);
+    List<String> fixIds = getFixIds(getComments());
+
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage)
+        .isEqualTo("Modified line 1\nLine 2 of commit message\nModified line 3\n" + footer);
+  }
+
+  @Test
+  public void twoConflictingStoredFixesOnCommitMessageCanNotBeAppliedOneAfterTheOther()
+      throws Exception {
+    // Set a dedicated commit message.
+    String footer = "Change-Id: " + changeId;
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n\n"
+            + footer
+            + "\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = Patch.COMMIT_MSG;
+    fixReplacementInfo2.range = createRange(7, 0, 10, 0);
+    fixReplacementInfo2.replacement = "Differently modified line 1\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    CommentInput commentInput1 =
+        TestCommentHelper.createCommentInput(FILE_NAME, fixSuggestionInfo1);
+    CommentInput commentInput2 =
+        TestCommentHelper.createCommentInput(FILE_NAME, fixSuggestionInfo2);
+    testCommentHelper.addComment(changeId, commentInput1);
+    testCommentHelper.addComment(changeId, commentInput2);
+    List<String> fixIds = getFixIds(getComments());
+
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    assertThrows(
+        ResourceConflictException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(fixIds.get(1)));
+  }
+
+  @Test
+  public void applyingStoredFixTwiceIsIdempotent() throws Exception {
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+    String expectedEditCommit =
+        gApi.changes().id(changeId).edit().get().map(edit -> edit.commit.commit).orElse("");
+
+    // Apply the fix again.
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> editInfo = gApi.changes().id(changeId).edit().get();
+    assertThat(editInfo).value().commit().commit().isEqualTo(expectedEditCommit);
+  }
+
+  @Test
+  public void nonExistentStoredFixCannotBeApplied() throws Exception {
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+    String nonExistentFixId = fixId + "_non-existent";
+
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(nonExistentFixId));
+  }
+
+  @Test
+  public void applyingStoredFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void applyingStoredFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit()
+      throws Exception {
+    gApi.changes().id(changeId).edit().create();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void previewStoredFixWithNonexistentFixId() throws Exception {
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().getFixPreview("Non existing fixId"));
+  }
+
+  @Test
+  public void previewStoredFixForCommitMsg() throws Exception {
+    String footer = "Change-Id: " + changeId;
+    updateCommitMessage(
+        changeId,
+        "Commit title\n\nCommit message line 1\nLine 2\nLine 3\nLast line\n\n" + footer + "\n");
+    FixReplacementInfo commitMsgReplacement = new FixReplacementInfo();
+    commitMsgReplacement.path = Patch.COMMIT_MSG;
+    // The test assumes that the first 5 lines is a header.
+    // Line 10 has content "Line 2"
+    commitMsgReplacement.range = createRange(10, 0, 11, 0);
+    commitMsgReplacement.replacement = "New content\n";
+
+    FixSuggestionInfo commitMsgSuggestionInfo = createFixSuggestionInfo(commitMsgReplacement);
+    CommentInput commitMsgCommentInput =
+        TestCommentHelper.createCommentInput(Patch.COMMIT_MSG, commitMsgSuggestionInfo);
+    testCommentHelper.addComment(changeId, commitMsgCommentInput);
+
+    List<CommentInfo> commentInfos = getComments();
+
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    Map<String, DiffInfo> fixPreview = gApi.changes().id(changeId).current().getFixPreview(fixId);
+    assertThat(fixPreview).hasSize(1);
+    assertThat(fixPreview).containsKey(Patch.COMMIT_MSG);
+
+    DiffInfo diff = fixPreview.get(Patch.COMMIT_MSG);
+    assertThat(diff).metaA().name().isEqualTo(Patch.COMMIT_MSG);
+    assertThat(diff).metaA().contentType().isEqualTo(GERRIT_COMMIT_MESSAGE_TYPE);
+    assertThat(diff).metaB().name().isEqualTo(Patch.COMMIT_MSG);
+    assertThat(diff).metaB().contentType().isEqualTo(GERRIT_COMMIT_MESSAGE_TYPE);
+
+    assertThat(diff).content().element(0).commonLines().hasSize(9);
+    // Header has a dynamic content, do not check it
+    assertThat(diff).content().element(0).commonLines().element(6).isEqualTo("Commit title");
+    assertThat(diff).content().element(0).commonLines().element(7).isEqualTo("");
+    assertThat(diff)
+        .content()
+        .element(0)
+        .commonLines()
+        .element(8)
+        .isEqualTo("Commit message line 1");
+    assertThat(diff).content().element(1).linesOfA().containsExactly("Line 2");
+    assertThat(diff).content().element(1).linesOfB().containsExactly("New content");
+    assertThat(diff)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Line 3", "Last line", "", footer, "");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.disabled",
+      values = {ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS})
+  public void commentWithFixFailsToPersistWithoutFeatureFlag() {
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class,
+            () -> testCommentHelper.addComment(changeId, withFixCommentInput));
+    assertThat(thrown).hasMessageThat().contains("feature flag prohibits setting fixSuggestions");
+  }
+
+  private void updateCommitMessage(String changeId, String newCommitMessage) throws Exception {
+    gApi.changes().id(changeId).edit().create();
+    gApi.changes().id(changeId).edit().modifyCommitMessage(newCommitMessage);
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    gApi.changes().id(changeId).edit().publish(publishInput);
+  }
+
+  @Test
+  public void previewStoredFixForNonExistingFile() throws Exception {
+    FixReplacementInfo replacement = new FixReplacementInfo();
+    replacement.path = "a_non_existent_file.txt";
+    replacement.range = createRange(1, 0, 2, 0);
+    replacement.replacement = "Modified content\n";
+
+    FixSuggestionInfo fixSuggestion = createFixSuggestionInfo(replacement);
+    CommentInput commentInput = TestCommentHelper.createCommentInput(FILE_NAME2, fixSuggestion);
+    testCommentHelper.addComment(changeId, commentInput);
+
+    List<CommentInfo> commentInfos = getComments();
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().getFixPreview(fixId));
+  }
+
+  @Test
+  public void previewStoredFix() throws Exception {
+    FixReplacementInfo fixReplacementInfoFile1 = new FixReplacementInfo();
+    fixReplacementInfoFile1.path = FILE_NAME;
+    fixReplacementInfoFile1.replacement = "some replacement code";
+    fixReplacementInfoFile1.range = createRange(3, 9, 8, 4);
+
+    FixReplacementInfo fixReplacementInfoFile2 = new FixReplacementInfo();
+    fixReplacementInfoFile2.path = FILE_NAME2;
+    fixReplacementInfoFile2.replacement = "New line\n";
+    fixReplacementInfoFile2.range = createRange(2, 0, 2, 0);
+
+    fixSuggestionInfo = createFixSuggestionInfo(fixReplacementInfoFile1, fixReplacementInfoFile2);
+
+    withFixCommentInput = TestCommentHelper.createCommentInput(FILE_NAME, fixSuggestionInfo);
+
+    testCommentHelper.addComment(changeId, withFixCommentInput);
+    List<CommentInfo> commentInfos = getComments();
+
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    Map<String, DiffInfo> fixPreview = gApi.changes().id(changeId).current().getFixPreview(fixId);
+    assertThat(fixPreview).hasSize(2);
+    assertThat(fixPreview).containsKey(FILE_NAME);
+    assertThat(fixPreview).containsKey(FILE_NAME2);
+
+    DiffInfo diff = fixPreview.get(FILE_NAME);
+    assertThat(diff).intralineStatus().isEqualTo(IntraLineStatus.OK);
+    assertThat(diff).webLinks().isNull();
+    assertThat(diff).binary().isNull();
+    assertThat(diff).diffHeader().isNull();
+    assertThat(diff).changeType().isEqualTo(ChangeType.MODIFIED);
+    assertThat(diff).metaA().totalLineCount().isEqualTo(11);
+    assertThat(diff).metaA().name().isEqualTo(FILE_NAME);
+    assertThat(diff).metaA().commitId().isEqualTo(commitId);
+    assertThat(diff).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff).metaA().webLinks().isNull();
+    assertThat(diff).metaB().totalLineCount().isEqualTo(6);
+    assertThat(diff).metaB().name().isEqualTo(FILE_NAME);
+    assertThat(diff).metaB().commitId().isNull();
+    assertThat(diff).metaB().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff).metaB().webLinks().isNull();
+
+    assertThat(diff).content().hasSize(3);
+    assertThat(diff)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsExactly("First line", "Second line");
+    assertThat(diff).content().element(0).linesOfA().isNull();
+    assertThat(diff).content().element(0).linesOfB().isNull();
+
+    assertThat(diff).content().element(1).commonLines().isNull();
+    assertThat(diff)
+        .content()
+        .element(1)
+        .linesOfA()
+        .containsExactly(
+            "Third line", "Fourth line", "Fifth line", "Sixth line", "Seventh line", "Eighth line");
+    assertThat(diff)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Third linsome replacement codeth line");
+
+    assertThat(diff)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Ninth line", "Tenth line", "");
+    assertThat(diff).content().element(2).linesOfA().isNull();
+    assertThat(diff).content().element(2).linesOfB().isNull();
+
+    DiffInfo diff2 = fixPreview.get(FILE_NAME2);
+    assertThat(diff2).intralineStatus().isEqualTo(IntraLineStatus.OK);
+    assertThat(diff2).webLinks().isNull();
+    assertThat(diff2).binary().isNull();
+    assertThat(diff2).diffHeader().isNull();
+    assertThat(diff2).changeType().isEqualTo(ChangeType.MODIFIED);
+    assertThat(diff2).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diff2).metaA().name().isEqualTo(FILE_NAME2);
+    assertThat(diff2).metaA().commitId().isEqualTo(commitId);
+    assertThat(diff2).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff2).metaA().webLinks().isNull();
+    assertThat(diff2).metaB().totalLineCount().isEqualTo(5);
+    assertThat(diff2).metaB().name().isEqualTo(FILE_NAME2);
+    assertThat(diff2).metaB().commitId().isNull();
+    assertThat(diff2).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff2).metaB().webLinks().isNull();
+
+    assertThat(diff2).content().hasSize(3);
+    assertThat(diff2).content().element(0).commonLines().containsExactly("1st line");
+    assertThat(diff2).content().element(0).linesOfA().isNull();
+    assertThat(diff2).content().element(0).linesOfB().isNull();
+
+    assertThat(diff2).content().element(1).commonLines().isNull();
+    assertThat(diff2).content().element(1).linesOfA().isNull();
+    assertThat(diff2).content().element(1).linesOfB().containsExactly("New line");
+
+    assertThat(diff2)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("2nd line", "3rd line", "");
+    assertThat(diff2).content().element(2).linesOfA().isNull();
+    assertThat(diff2).content().element(2).linesOfB().isNull();
+  }
+
+  @Test
+  public void previewStoredFixAddNewLineAtEnd() throws Exception {
+    FixReplacementInfo replacement = new FixReplacementInfo();
+    replacement.path = FILE_NAME3;
+    replacement.range = createRange(2, 8, 2, 8);
+    replacement.replacement = "\n";
+
+    FixSuggestionInfo fixSuggestion = createFixSuggestionInfo(replacement);
+    CommentInput commentInput = TestCommentHelper.createCommentInput(FILE_NAME3, fixSuggestion);
+    testCommentHelper.addComment(changeId, commentInput);
+
+    List<CommentInfo> commentInfos = getComments();
+
+    List<String> fixIds = getFixIds(commentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    Map<String, DiffInfo> fixPreview = gApi.changes().id(changeId).current().getFixPreview(fixId);
+
+    assertThat(fixPreview).hasSize(1);
+    assertThat(fixPreview).containsKey(FILE_NAME3);
+
+    DiffInfo diff = fixPreview.get(FILE_NAME3);
+    assertThat(diff).metaA().totalLineCount().isEqualTo(2);
+    // Original file doesn't have EOL marker at the end of file.
+    // Due to the additional EOL mark diff has one additional line
+    // This behavior is in line with ordinary get diff API.
+    assertThat(diff).metaB().totalLineCount().isEqualTo(3);
+
+    assertThat(diff).content().hasSize(2);
+    assertThat(diff).content().element(0).commonLines().containsExactly("1st line");
+    assertThat(diff).content().element(1).linesOfA().containsExactly("2nd line");
+    assertThat(diff).content().element(1).linesOfB().containsExactly("2nd line", "");
+  }
+
+  private List<CommentInfo> getComments() throws RestApiException {
+    return gApi.changes().id(changeId).current().commentsAsList();
+  }
+
+  private static String getStringFor(int numberOfBytes) {
+    char[] chars = new char[numberOfBytes];
+    // 'a' will require one byte even when mapped to a JSON string
+    Arrays.fill(chars, 'a');
+    return new String(chars);
+  }
+
+  private static List<String> getFixIds(List<CommentInfo> comments) {
+    assertThatList(comments).isNotNull();
+    return comments.stream()
+        .map(commentInfo -> commentInfo.fixSuggestions)
+        .filter(Objects::nonNull)
+        .flatMap(List::stream)
+        .map(fixSuggestionInfo -> fixSuggestionInfo.fixId)
+        .collect(toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index ba45fb2..d61c2d6 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -77,7 +77,7 @@
     newComment(patchset2Id).create();
     newComment(patchset3Id).create();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThatList(portedComments).comparingElementsUsing(hasUuid()).containsExactly(comment1Uuid);
   }
@@ -94,7 +94,7 @@
     String comment1Uuid = newComment(patchset1Id).create();
     String comment3Uuid = newComment(patchset3Id).create();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset4Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset4Id));
 
     assertThat(portedComments)
         .comparingElementsUsing(hasUuid())
@@ -111,7 +111,7 @@
     String comment1Uuid = newComment(patchset1Id).create();
     String comment2Uuid = newComment(patchset1Id).create();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThat(portedComments)
         .comparingElementsUsing(hasUuid())
@@ -129,7 +129,7 @@
     String child1CommentUuid = newComment(patchset1Id).parentUuid(rootCommentUuid).create();
     String child2CommentUuid = newComment(patchset1Id).parentUuid(child1CommentUuid).create();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThat(portedComments)
         .comparingElementsUsing(hasUuid())
@@ -146,7 +146,7 @@
     newComment(patchset1Id).resolved().create();
     String comment2Uuid = newComment(patchset1Id).unresolved().create();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(comment2Uuid);
   }
@@ -174,7 +174,7 @@
     rebaseChangeOn(changeId.toString(), newBase);
     PatchSet.Id ps3Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(ps3Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(ps3Id));
     assertThat(portedComments).hasSize(1);
     int portedLine = portedComments.get(0).line;
     BinaryResult fileContent = gApi.changes().id(changeId.get()).current().file(fileName).content();
@@ -195,7 +195,7 @@
     String comment1Uuid = newDraftComment(patchset1Id).author(accountId).resolved().create();
     String comment2Uuid = newDraftComment(patchset1Id).author(accountId).unresolved().create();
 
-    List<CommentInfo> portedComments =
+    ImmutableList<CommentInfo> portedComments =
         flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
 
     assertThat(portedComments)
@@ -216,7 +216,7 @@
     String rootComment2Uuid = newComment(patchset1Id).unresolved().create();
     newComment(patchset1Id).parentUuid(rootComment2Uuid).resolved().create();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThat(portedComments)
         .comparingElementsUsing(hasUuid())
@@ -246,7 +246,7 @@
             .createdOn(now.plusSeconds(10))
             .create();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThat(portedComments)
         .comparingElementsUsing(hasUuid())
@@ -272,7 +272,7 @@
 
     // Draft comments are only visible to their author.
     requestScopeOps.setApiUser(accountId);
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(rootComment2Uuid);
   }
@@ -289,7 +289,7 @@
 
     // Draft comments are only visible to their author.
     requestScopeOps.setApiUser(accountId);
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThatList(portedComments).isEmpty();
   }
@@ -304,7 +304,7 @@
     // Add comment.
     newComment(patchset1Id).author(accountId).create();
 
-    List<CommentInfo> portedComments =
+    ImmutableList<CommentInfo> portedComments =
         flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
 
     assertThatList(portedComments).isEmpty();
@@ -320,7 +320,7 @@
     // Add draft comment.
     newComment(patchset1Id).author(accountId).create();
 
-    List<CommentInfo> portedComments =
+    ImmutableList<CommentInfo> portedComments =
         flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
 
     assertThatList(portedComments).isEmpty();
@@ -337,7 +337,8 @@
     // Add draft comment.
     newComment(patchset1Id).author(otherUserId).create();
 
-    List<CommentInfo> portedComments = flatten(getPortedDraftCommentsOfUser(patchset2Id, userId));
+    ImmutableList<CommentInfo> portedComments =
+        flatten(getPortedDraftCommentsOfUser(patchset2Id, userId));
 
     assertThatList(portedComments).isEmpty();
   }
@@ -365,7 +366,7 @@
     String patchsetLevelCommentUuid =
         newComment(patchset1Id).message("Patchset-level comment").onPatchsetLevel().create();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThat(portedComments)
         .comparingElementsUsing(hasUuid())
@@ -382,7 +383,7 @@
     // Add comments.
     String commentUuid = newComment(patchset1Id).onParentCommit().create();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
   }
@@ -396,7 +397,7 @@
     // Add comments.
     String commentUuid = newComment(patchset1Id).onSecondParentCommit().create();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
   }
@@ -411,7 +412,7 @@
     String commentUuid1 = newComment(patchset1Id).onFileLevelOf("not-existing file").create();
     String commentUuid2 = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThat(portedComments)
         .comparingElementsUsing(hasUuid())
@@ -441,7 +442,7 @@
     // Add comment.
     String commentUuid = newComment(patchset1Id).create();
 
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
     assertThatList(portedComments).onlyElement().uuid().isEqualTo(commentUuid);
   }
@@ -1859,7 +1860,7 @@
         .revision(patchsetId1.get())
         .comment(commentUuid)
         .delete(new DeleteCommentInput());
-    List<CommentInfo> portedComments = flatten(getPortedComments(patchsetId2));
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchsetId2));
 
     assertThatList(portedComments).isEmpty();
   }
@@ -1918,7 +1919,7 @@
   }
 
   private Map<String, List<CommentInfo>> getPortedDraftCommentsOfUser(
-      PatchSet.Id patchsetId, Account.Id accountId) throws RestApiException {
+      PatchSet.Id patchsetId, Account.Id accountId) throws Exception {
     // Draft comments are only visible to their author.
     requestScopeOps.setApiUser(accountId);
     return gApi.changes().id(patchsetId.changeId().get()).revision(patchsetId.get()).portedDrafts();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 4a5c3dd..e4d4610 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.api.revision;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.PATCH;
@@ -800,7 +799,7 @@
     TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
     try (Registration registration =
         extensionRegistry.newRegistration().add(testCommitValidationListener)) {
-      gApi.changes().id(r.getChangeId()).current().cherryPickAsInfo(in);
+      gApi.changes().id(r.getChangeId()).current().cherryPick(in);
       assertThat(testCommitValidationListener.receiveEvent.pushOptions)
           .containsExactly("key", "value");
     }
@@ -1694,7 +1693,8 @@
 
   @Test
   public void queryRevisionFiles() throws Exception {
-    Map<String, String> files = ImmutableMap.of("file1.txt", "content 1", "file2.txt", "content 2");
+    ImmutableMap<String, String> files =
+        ImmutableMap.of("file1.txt", "content 1", "file2.txt", "content 2");
     PushOneCommit.Result result =
         pushFactory.create(admin.newIdent(), testRepo, SUBJECT, files).to("refs/for/master");
     result.assertOkStatus();
@@ -1814,7 +1814,7 @@
   @Test
   @GerritConfig(name = "change.maxFileSizeDownload", value = "10")
   public void content_maxFileSizeDownload() throws Exception {
-    Map<String, String> files =
+    ImmutableMap<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");
@@ -1854,7 +1854,7 @@
 
   @Test
   public void cannotGetContentOfDirectory() throws Exception {
-    Map<String, String> files = ImmutableMap.of("dir/file1.txt", "content 1");
+    ImmutableMap<String, String> files = ImmutableMap.of("dir/file1.txt", "content 1");
     PushOneCommit.Result result =
         pushFactory.create(admin.newIdent(), testRepo, SUBJECT, files).to("refs/for/master");
     result.assertOkStatus();
@@ -2236,7 +2236,7 @@
 
     // check that reviewer is notified.
     amendChange(r.getChangeId());
-    List<FakeEmailSender.Message> messages = sender.getMessages();
+    ImmutableList<FakeEmailSender.Message> messages = sender.getMessages();
     FakeEmailSender.Message m = Iterables.getOnlyElement(messages);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains("I'd like you to reexamine a change.");
@@ -2265,7 +2265,7 @@
 
     // check that watcher is notified
     amendChange(r.getChangeId());
-    List<FakeEmailSender.Message> messages = sender.getMessages();
+    ImmutableList<FakeEmailSender.Message> messages = sender.getMessages();
     FakeEmailSender.Message m = Iterables.getOnlyElement(messages);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
     assertThat(m.body()).contains(admin.fullName() + " has uploaded a new patch set (#2).");
@@ -2583,7 +2583,7 @@
     ChangeInfo info = gApi.changes().id(changeId).get(DETAILED_LABELS);
     LabelInfo li = info.labels.get(label);
     assertThat(li).isNotNull();
-    int accountId = atrScope.get().getUser().getAccountId().get();
+    int accountId = localCtx.getContext().getUser().getAccountId().get();
     return li.all.stream().filter(a -> a._accountId == accountId).findFirst().get();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index b31d35c..5322785d 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -18,6 +18,9 @@
 import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.acceptance.server.change.CommentsUtil.createFixReplacementInfo;
+import static com.google.gerrit.acceptance.server.change.CommentsUtil.createFixSuggestionInfo;
+import static com.google.gerrit.acceptance.server.change.CommentsUtil.createRange;
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
@@ -43,7 +46,6 @@
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
-import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -489,7 +491,7 @@
         .hasMessageThat()
         .contains(
             String.format(
-                "A description is required for the suggested fix of the robot comment on %s",
+                "A description is required for the suggested fix of the comment on %s",
                 withFixRobotCommentInput.path));
   }
 
@@ -518,7 +520,7 @@
         .contains(
             String.format(
                 "At least one replacement is required"
-                    + " for the suggested fix of the robot comment on %s",
+                    + " for the suggested fix of the comment on %s",
                 withFixRobotCommentInput.path));
   }
 
@@ -548,7 +550,7 @@
         .hasMessageThat()
         .contains(
             String.format(
-                "A file path must be given for the replacement of the robot comment on %s",
+                "A file path must be given for the replacement of the comment on %s",
                 withFixRobotCommentInput.path));
   }
 
@@ -578,7 +580,7 @@
         .hasMessageThat()
         .contains(
             String.format(
-                "A range must be given for the replacement of the robot comment on %s",
+                "A range must be given for the replacement of the comment on %s",
                 withFixRobotCommentInput.path));
   }
 
@@ -732,7 +734,7 @@
         .contains(
             String.format(
                 "A content for replacement must be "
-                    + "indicated for the replacement of the robot comment on %s",
+                    + "indicated for the replacement of the comment on %s",
                 withFixRobotCommentInput.path));
   }
 
@@ -1676,33 +1678,6 @@
     assertThat(diff).content().element(1).linesOfB().containsExactly("2nd line", "");
   }
 
-  private static FixSuggestionInfo createFixSuggestionInfo(
-      FixReplacementInfo... fixReplacementInfos) {
-    FixSuggestionInfo newFixSuggestionInfo = new FixSuggestionInfo();
-    newFixSuggestionInfo.fixId = "An ID which must be overwritten.";
-    newFixSuggestionInfo.description = "A description for a suggested fix.";
-    newFixSuggestionInfo.replacements = Arrays.asList(fixReplacementInfos);
-    return newFixSuggestionInfo;
-  }
-
-  private static FixReplacementInfo createFixReplacementInfo() {
-    FixReplacementInfo newFixReplacementInfo = new FixReplacementInfo();
-    newFixReplacementInfo.path = FILE_NAME;
-    newFixReplacementInfo.replacement = "some replacement code";
-    newFixReplacementInfo.range = createRange(3, 9, 8, 4);
-    return newFixReplacementInfo;
-  }
-
-  private static Comment.Range createRange(
-      int startLine, int startCharacter, int endLine, int endCharacter) {
-    Comment.Range range = new Comment.Range();
-    range.startLine = startLine;
-    range.startCharacter = startCharacter;
-    range.endLine = endLine;
-    range.endCharacter = endCharacter;
-    return range;
-  }
-
   private List<RobotCommentInfo> getRobotComments() throws RestApiException {
     return gApi.changes().id(changeId).current().robotCommentsAsList();
   }
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 9c691ae..62a095f 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.ChangeEditIdentityType;
 import com.google.gerrit.extensions.api.changes.FileContentInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
@@ -62,9 +63,12 @@
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -72,6 +76,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.restapi.change.ChangeEdits;
 import com.google.gerrit.server.restapi.change.ChangeEdits.EditMessage;
 import com.google.gerrit.server.restapi.change.ChangeEdits.Post;
 import com.google.gson.reflect.TypeToken;
@@ -137,10 +142,11 @@
     createArbitraryEditFor(changeId);
 
     // check that '0' is parsed as edit revision
-    gApi.changes().id(changeId).revision(0).comments();
+    @SuppressWarnings("unused")
+    var unused = gApi.changes().id(changeId).revision(0).comments();
 
     // check that 'edit' is parsed as edit revision
-    gApi.changes().id(changeId).revision("edit").comments();
+    unused = gApi.changes().id(changeId).revision("edit").comments();
   }
 
   @Test
@@ -485,6 +491,202 @@
   }
 
   @Test
+  public void updateCommitter() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyIdentity("Test", "test@example.com", ChangeEditIdentityType.COMMITTER);
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId).edit().publish(publishInput);
+    assertThat(getEdit(changeId)).isAbsent();
+
+    ChangeInfo info =
+        get(changeId, ListChangesOption.CURRENT_COMMIT, ListChangesOption.CURRENT_REVISION);
+    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+    assertThat(committer.name).isEqualTo("Test");
+    assertThat(committer.email).isEqualTo("test@example.com");
+  }
+
+  @Test
+  public void updateCommitterRest() throws Exception {
+    adminRestSession.get(urlEditIdentity(changeId)).assertNotFound();
+    ChangeEdits.EditIdentity.Input in = new ChangeEdits.EditIdentity.Input();
+    in.name = "Test";
+    in.email = "test@example.com";
+    in.type = ChangeEditIdentityType.COMMITTER;
+    adminRestSession.put(urlEditIdentity(changeId), in).assertNoContent();
+    adminRestSession.post(urlPublish(changeId)).assertNoContent();
+    ChangeInfo info =
+        get(changeId, ListChangesOption.CURRENT_COMMIT, ListChangesOption.CURRENT_REVISION);
+    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+    assertThat(committer.name).isEqualTo("Test");
+    assertThat(committer.email).isEqualTo("test@example.com");
+  }
+
+  @Test
+  public void updateCommitterRestWithDefaultName() throws Exception {
+    adminRestSession.get(urlEditIdentity(changeId)).assertNotFound();
+    ChangeEdits.EditIdentity.Input in = new ChangeEdits.EditIdentity.Input();
+    in.type = ChangeEditIdentityType.COMMITTER;
+    in.email = "test@example.com";
+    adminRestSession.put(urlEditIdentity(changeId), in).assertNoContent();
+    adminRestSession.post(urlPublish(changeId)).assertNoContent();
+    ChangeInfo info =
+        get(changeId, ListChangesOption.CURRENT_COMMIT, ListChangesOption.CURRENT_REVISION);
+    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+    assertThat(committer.name).isEqualTo("Administrator");
+    assertThat(committer.email).isEqualTo("test@example.com");
+  }
+
+  @Test
+  public void updateCommitterRestWithDefaultEmail() throws Exception {
+    adminRestSession.get(urlEditIdentity(changeId)).assertNotFound();
+    ChangeEdits.EditIdentity.Input in = new ChangeEdits.EditIdentity.Input();
+    in.type = ChangeEditIdentityType.COMMITTER;
+    in.name = "John Doe";
+    adminRestSession.put(urlEditIdentity(changeId), in).assertNoContent();
+    adminRestSession.post(urlPublish(changeId)).assertNoContent();
+    ChangeInfo info =
+        get(changeId, ListChangesOption.CURRENT_COMMIT, ListChangesOption.CURRENT_REVISION);
+    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+    assertThat(committer.name).isEqualTo("John Doe");
+    assertThat(committer.email).isEqualTo("admin@example.com");
+  }
+
+  @Test
+  public void cannotForgeCommitterWithoutPerm() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_COMMITTER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    createEmptyEditFor(changeId);
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            gApi.changes()
+                .id(changeId)
+                .edit()
+                .modifyIdentity("Test", "test@example.com", ChangeEditIdentityType.COMMITTER));
+  }
+
+  @Test
+  public void updateAuthor() throws Exception {
+    createEmptyEditFor(changeId);
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyIdentity("Test", "test@example.com", ChangeEditIdentityType.AUTHOR);
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId).edit().publish(publishInput);
+    assertThat(getEdit(changeId)).isAbsent();
+
+    ChangeInfo info =
+        get(changeId, ListChangesOption.CURRENT_COMMIT, ListChangesOption.CURRENT_REVISION);
+    GitPerson author = info.revisions.get(info.currentRevision).commit.author;
+    assertThat(author.name).isEqualTo("Test");
+    assertThat(author.email).isEqualTo("test@example.com");
+  }
+
+  @Test
+  public void updateAuthorRest() throws Exception {
+    adminRestSession.get(urlEditIdentity(changeId)).assertNotFound();
+    ChangeEdits.EditIdentity.Input in = new ChangeEdits.EditIdentity.Input();
+    in.name = "Test";
+    in.type = ChangeEditIdentityType.AUTHOR;
+    in.email = "test@example.com";
+    adminRestSession.put(urlEditIdentity(changeId), in).assertNoContent();
+    adminRestSession.post(urlPublish(changeId)).assertNoContent();
+    ChangeInfo info =
+        get(changeId, ListChangesOption.CURRENT_COMMIT, ListChangesOption.CURRENT_REVISION);
+    GitPerson author = info.revisions.get(info.currentRevision).commit.author;
+    assertThat(author.name).isEqualTo("Test");
+    assertThat(author.email).isEqualTo("test@example.com");
+  }
+
+  @Test
+  public void updateAuthorRestWithDefaultName() throws Exception {
+    adminRestSession.get(urlEditIdentity(changeId)).assertNotFound();
+    ChangeEdits.EditIdentity.Input in = new ChangeEdits.EditIdentity.Input();
+    in.type = ChangeEditIdentityType.AUTHOR;
+    in.email = "test@example.com";
+    adminRestSession.put(urlEditIdentity(changeId), in).assertNoContent();
+    adminRestSession.post(urlPublish(changeId)).assertNoContent();
+    ChangeInfo info =
+        get(changeId, ListChangesOption.CURRENT_COMMIT, ListChangesOption.CURRENT_REVISION);
+    GitPerson author = info.revisions.get(info.currentRevision).commit.author;
+    assertThat(author.name).isEqualTo("Administrator");
+    assertThat(author.email).isEqualTo("test@example.com");
+  }
+
+  @Test
+  public void updateAuthorRestWithDefaultEmail() throws Exception {
+    adminRestSession.get(urlEditIdentity(changeId)).assertNotFound();
+    ChangeEdits.EditIdentity.Input in = new ChangeEdits.EditIdentity.Input();
+    in.type = ChangeEditIdentityType.AUTHOR;
+    in.name = "John Doe";
+    adminRestSession.put(urlEditIdentity(changeId), in).assertNoContent();
+    adminRestSession.post(urlPublish(changeId)).assertNoContent();
+    ChangeInfo info =
+        get(changeId, ListChangesOption.CURRENT_COMMIT, ListChangesOption.CURRENT_REVISION);
+    GitPerson author = info.revisions.get(info.currentRevision).commit.author;
+    assertThat(author.name).isEqualTo("John Doe");
+    assertThat(author.email).isEqualTo("admin@example.com");
+  }
+
+  @Test
+  public void cannotForgeAuthorWithoutPerm() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    createEmptyEditFor(changeId);
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            gApi.changes()
+                .id(changeId)
+                .edit()
+                .modifyIdentity("Test", "test@example.com", ChangeEditIdentityType.AUTHOR));
+  }
+
+  @Test
+  public void updateAuthorAsOtherUser() throws Exception {
+    Account.Id otherUser =
+        accountOperations
+            .newAccount()
+            .preferredEmail("otherUser@example.com")
+            .fullname("otherUser")
+            .create();
+    requestScopeOperations.setApiUser(otherUser);
+    createEmptyEditFor(changeId);
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyIdentity("Test", "test@example.com", ChangeEditIdentityType.AUTHOR);
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId).edit().publish(publishInput);
+    assertThat(getEdit(changeId)).isAbsent();
+
+    ChangeInfo info =
+        get(changeId, ListChangesOption.CURRENT_COMMIT, ListChangesOption.CURRENT_REVISION);
+    GitPerson author = info.revisions.get(info.currentRevision).commit.author;
+    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+    assertThat(author.name).isEqualTo("Test");
+    assertThat(author.email).isEqualTo("test@example.com");
+    assertThat(committer.name).isEqualTo("otherUser");
+    assertThat(committer.email).isEqualTo("otherUser@example.com");
+  }
+
+  @Test
   public void updateMessage() throws Exception {
     createEmptyEditFor(changeId);
     String msg = String.format("New commit message\n\nChange-Id: %s\n", changeId);
@@ -552,6 +754,7 @@
     assertThat(updatedCommitMessage).isEqualTo(in.message);
 
     r = adminRestSession.getJsonAccept(urlEditMessage(changeId, true));
+    r.assertOK();
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.commitId().name()));
@@ -1068,10 +1271,12 @@
     String editCommitId = edit.get().commit.commit;
 
     RestResponse r = adminRestSession.getJsonAccept(urlRevisionFiles(changeId, editCommitId));
+    r.assertOK();
     Map<String, FileInfo> files = readContentFromJson(r, new TypeToken<Map<String, FileInfo>>() {});
     assertThat(files).containsKey(FILE_NAME);
 
     r = adminRestSession.getJsonAccept(urlRevisionFiles(changeId));
+    r.assertOK();
     files = readContentFromJson(r, new TypeToken<Map<String, FileInfo>>() {});
     assertThat(files).containsKey(FILE_NAME);
   }
@@ -1085,10 +1290,12 @@
     String editCommitId = edit.get().commit.commit;
 
     RestResponse r = adminRestSession.getJsonAccept(urlDiff(changeId, editCommitId, FILE_NAME));
+    r.assertOK();
     DiffInfo diff = readContentFromJson(r, DiffInfo.class);
     assertThat(diff.diffHeader.get(0)).contains(FILE_NAME);
 
     r = adminRestSession.getJsonAccept(urlDiff(changeId, FILE_NAME));
+    r.assertOK();
     diff = readContentFromJson(r, DiffInfo.class);
     assertThat(diff.diffHeader.get(0)).contains(FILE_NAME);
   }
@@ -1162,6 +1369,59 @@
     assertThat(info1.currentRevision).isNotEqualTo(info2.currentRevision);
   }
 
+  @Test
+  public void canCombineEdits() throws Exception {
+    createEmptyEditFor(changeId);
+
+    // update author
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyIdentity("Test Author", "test.author@example.com", ChangeEditIdentityType.AUTHOR);
+
+    // update message
+    String msg = String.format("New commit message\n\nChange-Id: %s\n", changeId);
+    gApi.changes().id(changeId).edit().modifyCommitMessage(msg);
+
+    // add new file
+    String newFile = "foobar";
+    gApi.changes().id(changeId).edit().modifyFile(newFile, RawInputUtil.create(CONTENT_NEW));
+
+    // update committer
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyIdentity(
+            "Test Committer", "test.committer@example.com", ChangeEditIdentityType.COMMITTER);
+
+    // delete file
+    gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
+
+    // rename file
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME2, FILE_NAME3);
+
+    // publish edit
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    publishInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(changeId).edit().publish(publishInput);
+    assertThat(getEdit(changeId)).isAbsent();
+
+    ChangeInfo info =
+        get(
+            changeId,
+            ListChangesOption.CURRENT_COMMIT,
+            ListChangesOption.CURRENT_REVISION,
+            ListChangesOption.ALL_FILES);
+    RevisionInfo currentRevision = info.revisions.get(info.currentRevision);
+    CommitInfo currentCommit = currentRevision.commit;
+    assertThat(currentCommit.committer.name).isEqualTo("Test Committer");
+    assertThat(currentCommit.committer.email).isEqualTo("test.committer@example.com");
+    assertThat(currentCommit.author.name).isEqualTo("Test Author");
+    assertThat(currentCommit.author.email).isEqualTo("test.author@example.com");
+    assertThat(currentCommit.message).isEqualTo(msg);
+    assertThat(currentRevision.files.keySet()).containsExactly(newFile, FILE_NAME3);
+  }
+
   private void createArbitraryEditFor(String changeId) throws Exception {
     createEmptyEditFor(changeId);
     arbitrarilyModifyEditOf(changeId);
@@ -1227,6 +1487,10 @@
     return "/changes/" + changeId + "/edit:message" + (base ? "?base" : "");
   }
 
+  private String urlEditIdentity(String changeId) {
+    return "/changes/" + changeId + "/edit:identity";
+  }
+
   private String urlEditFile(String changeId, String fileName) {
     return urlEditFile(changeId, fileName, false);
   }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
index d3e70b2..e2f81e7 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
@@ -104,7 +104,7 @@
     String remote = "anonymous";
     Config cfg = testRepo.git().getRepository().getConfig();
 
-    String uri = server.getUrl() + "/" + project.get();
+    String uri = server.getGitUrl() + "/" + project.get();
     cfg.setString("remote", remote, "url", uri);
     cfg.setString("remote", remote, "fetch", "+refs/heads/*:refs/remotes/origin/*");
 
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractImplicitMergeTest.java b/javatests/com/google/gerrit/acceptance/git/AbstractImplicitMergeTest.java
new file mode 100644
index 0000000..80eec84
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractImplicitMergeTest.java
@@ -0,0 +1,142 @@
+// 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.git;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.SubmitType;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
+import org.eclipse.jgit.util.RawParseUtils;
+
+/**
+ * Base class for different tests for implicit merge.
+ *
+ * <p>Provides shared methods for tests changes and branches manipulations.
+ */
+public abstract class AbstractImplicitMergeTest extends AbstractDaemonTest {
+
+  /** Creates and pushes a simple approved changes without files and with specified parents. */
+  protected PushOneCommit.Result createApprovedChange(String targetBranch, RevCommit... parents)
+      throws Exception {
+    PushOneCommit.Result result = pushTo("refs/for/" + targetBranch, ImmutableMap.of(), parents);
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
+    return result;
+  }
+
+  /** Creates and pushes simple approved changes without files and with specified parents. */
+  protected PushOneCommit.Result createApprovedChange(
+      String targetBranch, PushOneCommit.Result... parents) throws Exception {
+    return createApprovedChange(
+        targetBranch,
+        Arrays.stream(parents).map(PushOneCommit.Result::getCommit).toArray(RevCommit[]::new));
+  }
+
+  /** Creates and pushes a commit with specified files and parents. */
+  protected PushOneCommit.Result pushTo(
+      String ref, ImmutableMap<String, String> files, RevCommit... parents) throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, "Some commit", files);
+    push.setParents(List.of(parents));
+    PushOneCommit.Result result = push.to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
+  /**
+   * Creates a change in the in-memory repository but doesn't push it to gerrit.
+   *
+   * <p>The method can be used to create chain of changes. The last change in the chain can be
+   * created using {@link #createApprovedChange} or {@link #pushTo} methods - these method will push
+   * the whole chain to gerrit as a single git push operations.
+   */
+  protected RevCommit createChangeWithoutPush(
+      String changeId, ImmutableMap<String, String> files, RevCommit... parents) throws Exception {
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo
+            .commit()
+            .message("Change " + changeId)
+            // The passed changeId starts with 'I', but insertChangeId expects id without 'I'.
+            .insertChangeId(changeId.substring(1));
+    for (RevCommit parent : parents) {
+      commitBuilder.parent(parent);
+    }
+    for (Map.Entry<String, String> entry : files.entrySet()) {
+      commitBuilder.add(entry.getKey(), entry.getValue());
+    }
+
+    return commitBuilder.create();
+  }
+
+  protected void setRejectImplicitMerges() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ true);
+  }
+
+  protected void setRejectImplicitMerges(boolean reject) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateProject(
+              p ->
+                  p.setBooleanConfig(
+                      BooleanProjectConfig.REJECT_IMPLICIT_MERGES,
+                      reject ? InheritableBoolean.TRUE : InheritableBoolean.FALSE));
+      u.save();
+    }
+  }
+
+  protected void setSubmitType(SubmitType submitType) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateProject(p -> p.setSubmitType(submitType));
+      u.save();
+    }
+  }
+
+  protected ImmutableMap<String, String> getRemoteBranchRootPathContent(String refName)
+      throws Exception {
+    String revision = gApi.projects().name(project.get()).branch(refName).get().revision;
+    testRepo.git().fetch().setRemote("origin").call();
+    RevTree revTree =
+        testRepo.getRepository().parseCommit(testRepo.getRepository().resolve(revision)).getTree();
+    try (TreeWalk tw = new TreeWalk(testRepo.getRepository())) {
+      tw.setFilter(TreeFilter.ALL);
+      tw.setRecursive(false);
+      tw.reset(revTree);
+      ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
+      while (tw.next()) {
+        String path = tw.getPathString();
+        String content =
+            RawParseUtils.decode(
+                testRepo.getRepository().open(tw.getObjectId(0)).getCachedBytes(Integer.MAX_VALUE));
+        builder.put(path, content);
+      }
+      return builder.buildOrThrow();
+    }
+  }
+
+  protected PushOneCommit.Result push(String ref, String subject, String fileName, String content)
+      throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content);
+    return push.to(ref);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 2ab054b..8861a9e 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -19,7 +19,6 @@
 import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
@@ -221,6 +220,34 @@
   }
 
   @Test
+  public void pushMergeForMaster() throws Exception {
+    RevCommit initialHead =
+        testRepo.getRevWalk().parseCommit(testRepo.getRepository().resolve("HEAD"));
+
+    // Create a stable branch.
+    BranchInput in = new BranchInput();
+    in.revision = projectOperations.project(project).getHead("master").name();
+    gApi.projects().name(project.get()).branch("stable").create(in);
+
+    // Create a change on the stable branch and submit it.
+    PushOneCommit.Result r = pushTo("refs/for/stable");
+    r.assertOkStatus();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    testRepo.reset(initialHead);
+
+    // Merge stable back into master and push for review.
+    r =
+        pushFactory
+            .create(admin.newIdent(), testRepo)
+            .setParents(ImmutableList.of(initialHead, r.getCommit()))
+            .to("refs/for/master");
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null);
+  }
+
+  @Test
   @TestProjectInput(createEmptyCommit = false)
   public void pushInitialCommitForMasterBranch() throws Exception {
     RevCommit c = testRepo.commit().message("Initial commit").insertChangeId().create();
@@ -496,6 +523,40 @@
   }
 
   @Test
+  public void autocloseByChangeIdViaMerge() throws Exception {
+    RevCommit initialHead =
+        testRepo.getRevWalk().parseCommit(testRepo.getRepository().resolve("HEAD"));
+
+    // Create a change
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+
+    // Amend the commit locally
+    RevCommit c = testRepo.amend(r.getCommit()).create();
+    assertThat(c).isNotEqualTo(r.getCommit());
+
+    testRepo.reset(initialHead);
+
+    // Force push a merge commit that integrates the amended commit, closing it
+    pushFactory
+        .create(admin.newIdent(), testRepo)
+        .setParents(ImmutableList.of(initialHead, c))
+        .to("refs/heads/master")
+        .assertOkStatus();
+
+    // Attempt to push the amended commit to the same change
+    testRepo.reset(c);
+    String url = canonicalWebUrl.get() + "c/" + project.get() + "/+/" + r.getChange().getId();
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertErrorStatus("change " + url + " closed");
+
+    // Check that the new commit was added as a patch set
+    ChangeInfo change = change(r).get();
+    assertThat(change.revisions).hasSize(2);
+    assertThat(change.currentRevision).isEqualTo(c.name());
+  }
+
+  @Test
   public void pushForMasterWithTopic() throws Exception {
     TopicValidator topicValidator = new TopicValidator();
     try (Registration registration = extensionRegistry.newRegistration().add(topicValidator)) {
@@ -1472,7 +1533,7 @@
   public void pushForMasterWithHashtags() throws Exception {
     // specify a single hashtag as option
     String hashtag1 = "tag1";
-    Set<String> expected = ImmutableSet.of(hashtag1);
+    ImmutableSet<String> expected = ImmutableSet.of(hashtag1);
     PushOneCommit.Result r = pushTo("refs/for/master%hashtag=#" + hashtag1);
     r.assertOkStatus();
     r.assertChange(Change.Status.NEW, null);
@@ -1502,7 +1563,7 @@
     // specify multiple hashtags as options
     String hashtag1 = "tag1";
     String hashtag2 = "tag2";
-    Set<String> expected = ImmutableSet.of(hashtag1, hashtag2);
+    ImmutableSet<String> expected = ImmutableSet.of(hashtag1, hashtag2);
     PushOneCommit.Result r =
         pushTo("refs/for/master%hashtag=#" + hashtag1 + ",hashtag=##" + hashtag2);
     r.assertOkStatus();
@@ -2322,7 +2383,7 @@
     addDraft(r.getChangeId(), rev2, newDraft(FILE_NAME, 1, "comment_PS3."));
     amendChange(r.getChangeId(), "refs/for/master%publish-comments");
 
-    Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
+    List<CommentInfo> comments = getPublishedComments(r.getChangeId());
     List<ChangeMessageInfo> allMessages = getMessages(r.getChangeId());
 
     assertThat(allMessages.stream().map(m -> m.message).collect(toList()))
@@ -2385,7 +2446,7 @@
     sender.clear();
     amendChange(r.getChangeId(), "refs/for/master%publish-comments");
 
-    Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
+    List<CommentInfo> comments = getPublishedComments(r.getChangeId());
     assertThat(comments.stream().map(c -> c.id)).containsExactly(c1.id, c2.id, c3.id);
     assertThat(comments.stream().map(c -> c.message))
         .containsExactly("comment1", "comment2", "comment3");
@@ -2450,7 +2511,7 @@
 
     r = amendChange(r.getChangeId(), "refs/for/master%publish-comments,m=The_message");
 
-    Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
+    List<CommentInfo> comments = getPublishedComments(r.getChangeId());
     assertThat(comments.stream().map(c -> c.message)).containsExactly("comment1");
     assertThat(getLastMessage(r.getChangeId())).isEqualTo("Patch Set 2:\n" + "\n" + "(1 comment)");
   }
@@ -2469,7 +2530,7 @@
 
     amendChanges(initialHead, commits, "refs/for/master%publish-comments");
 
-    Collection<CommentInfo> cs1 = getPublishedComments(id1);
+    List<CommentInfo> cs1 = getPublishedComments(id1);
     List<ChangeMessageInfo> messages1 = getMessages(id1);
     assertThat(cs1.stream().map(c -> c.message)).containsExactly("comment1");
     assertThat(cs1.stream().map(c -> c.id)).containsExactly(c1.id);
@@ -2478,7 +2539,7 @@
         .isEqualTo("Uploaded patch set 2: Commit message was updated.");
     assertThat(messages1.get(2).message).isEqualTo("Patch Set 2:\n\n(1 comment)");
 
-    Collection<CommentInfo> cs2 = getPublishedComments(id2);
+    List<CommentInfo> cs2 = getPublishedComments(id2);
     List<ChangeMessageInfo> messages2 = getMessages(id2);
     assertThat(cs2.stream().map(c -> c.message)).containsExactly("comment2");
     assertThat(cs2.stream().map(c -> c.id)).containsExactly(c2.id);
@@ -2505,7 +2566,7 @@
     assertThat(getPublishedComments(id1)).isEmpty();
     assertThat(gApi.changes().id(id1).drafts()).hasSize(1);
 
-    Collection<CommentInfo> cs2 = getPublishedComments(id2);
+    List<CommentInfo> cs2 = getPublishedComments(id2);
     assertThat(cs2.stream().map(c -> c.message)).containsExactly("comment2");
     assertThat(cs2.stream().map(c -> c.id)).containsExactly(c2.id);
 
@@ -2776,7 +2837,7 @@
     assertThat(pr.getMessages()).contains("Invalid value in -o notedb=foobar");
     assertPushRejected(pr, ref, "NoteDb update requires -o notedb=allow");
 
-    List<String> opts = ImmutableList.of("notedb=allow");
+    ImmutableList<String> opts = ImmutableList.of("notedb=allow");
     pr = pushOne(testRepo, c.name(), ref, false, false, opts);
     assertPushRejected(pr, ref, "NoteDb update requires access database permission");
 
@@ -3136,7 +3197,7 @@
     return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
   }
 
-  private Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
+  private List<CommentInfo> getPublishedComments(String changeId) throws Exception {
     return gApi.changes().id(changeId).commentsRequest().get().values().stream()
         .flatMap(Collection::stream)
         .collect(toList());
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index 8295550..1b5b637 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -45,7 +46,6 @@
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.RefUpdateContextCollector;
 import com.google.inject.Inject;
-import java.util.List;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.api.errors.InvalidRemoteException;
 import org.eclipse.jgit.api.errors.TransportException;
@@ -228,7 +228,7 @@
     PushOneCommit.Result r = push("refs/for/master", PushOneCommit.SUBJECT, "two.txt", "Two");
     startEventRecorder();
     git().push().setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master")).call();
-    List<ChangeMergedEvent> changeMergedEvents =
+    ImmutableList<ChangeMergedEvent> changeMergedEvents =
         eventRecorder.getChangeMergedEvents(project.get(), "refs/heads/master", 2);
     assertThat(changeMergedEvents.get(0).newRev).isEqualTo(r.getPatchSet().commitId().name());
     assertThat(changeMergedEvents.get(1).newRev).isEqualTo(r.getPatchSet().commitId().name());
diff --git a/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java b/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
index e802604..c059ae4 100644
--- a/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckOnReceiveIT.java
similarity index 77%
rename from javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
rename to javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckOnReceiveIT.java
index e352e2d..90ba7c1 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckOnReceiveIT.java
@@ -17,16 +17,14 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
-import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.entities.BooleanProjectConfig;
-import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.git.ObjectIds;
 import java.util.Locale;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
-public class ImplicitMergeCheckIT extends AbstractDaemonTest {
+/** Checks that gerrit rejects/accepts implicit merges when receives a git push. */
+public class ImplicitMergeCheckOnReceiveIT extends AbstractImplicitMergeTest {
 
   @Test
   public void implicitMergeViaFastForward() throws Exception {
@@ -84,21 +82,4 @@
     return "implicit merge of "
         + ObjectIds.abbreviateName(commit, testRepo.getRevWalk().getObjectReader());
   }
-
-  private void setRejectImplicitMerges() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateProject(
-              p ->
-                  p.setBooleanConfig(
-                      BooleanProjectConfig.REJECT_IMPLICIT_MERGES, InheritableBoolean.TRUE));
-      u.save();
-    }
-  }
-
-  private PushOneCommit.Result push(String ref, String subject, String fileName, String content)
-      throws Exception {
-    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, subject, fileName, content);
-    return push.to(ref);
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT.java
new file mode 100644
index 0000000..592220e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT.java
@@ -0,0 +1,93 @@
+// 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.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests how implicit merges are submitted by the cherry pick and rebase always strategies.
+ *
+ * <p>Verifies that implicit merges can be submitted and that they don't add content from the
+ * implicitly merged branch to the target branch.
+ */
+public class ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT extends AbstractImplicitMergeTest {
+  @ConfigSuite.Configs
+  public static ImmutableMap<String, Config> configs() {
+    // The @RunWith(Parameterized.class) can't be used, because AbstractDaemonClass already
+    // uses @RunWith(ConfigSuite.class). Emulate parameters using configs.
+    ImmutableMap.Builder<String, Config> builder = ImmutableMap.builder();
+    for (SubmitType submitType : SubmitType.values()) {
+      if (submitType != SubmitType.CHERRY_PICK && submitType != SubmitType.REBASE_ALWAYS) {
+        continue;
+      }
+      Config cfg = new Config();
+      cfg.setString("test", null, "submitType", submitType.name());
+      builder.put(String.format("submitType=%s", submitType), cfg);
+    }
+    return builder.buildOrThrow();
+  }
+
+  private String implicitMergeChangeId;
+
+  @Before
+  public void setUp() throws Exception {
+    // The ConfigSuite runner always adds a default config. Ignore it (submitType is not set for
+    // it).
+    String submitType = cfg.getString("test", null, "submitType");
+    assume().that(submitType).isNotEmpty();
+    setSubmitType(SubmitType.valueOf(submitType));
+
+    setRejectImplicitMerges(false);
+    RevCommit base = repo().parseCommit(repo().exactRef("HEAD").getObjectId());
+    RevCommit masterBranchTip =
+        pushTo("refs/heads/master", ImmutableMap.of("master-content", "master-first-line\n"), base)
+            .getCommit();
+    pushTo("refs/heads/stable", ImmutableMap.of("stable-content", "stable-first-line\n"), base);
+    implicitMergeChangeId =
+        pushTo(
+                "refs/for/stable",
+                ImmutableMap.of("master-content2", "added-by-implicit-merge\n"),
+                masterBranchTip)
+            .getChangeId();
+    gApi.changes().id(implicitMergeChangeId).current().review(ReviewInput.approve());
+  }
+
+  @Test
+  public void doesntAddContentFromParentForImplicitMergeChange() throws Exception {
+    gApi.changes().id(implicitMergeChangeId).current().submit();
+
+    ChangeInfo ci = gApi.changes().id(implicitMergeChangeId).info();
+    assertThat(ci.submitted).isNotNull();
+    assertThat(ci.submitter).isNotNull();
+    assertThat(ci.submitter._accountId)
+        .isEqualTo(localCtx.getContext().getUser().getAccountId().get());
+
+    assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
+        .containsExactly(
+            "master-content2", "added-by-implicit-merge\n",
+            "stable-content", "stable-first-line\n");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java
new file mode 100644
index 0000000..d85e613
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitExperimentsIT.java
@@ -0,0 +1,335 @@
+// 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.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.util.CommitMessageUtil.generateChangeId;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Verifies that gerrit correctly rejects or submits implicit merges depending on experiments.
+ *
+ * <p>All tests use the same commit configuration (master branch is one commit ahead of stable
+ * branch):
+ *
+ * <pre>{@code
+ * change[1] (target - stable, explicit merge of stable branch and master branches)
+ * |         \
+ * |         change[0] (target - stable, i.e. implicit merge of master and stable branches)
+ * |          |
+ * |        master
+ * |           |
+ * stable <--- |
+ * }</pre>
+ */
+public class ImplicitMergeOnSubmitExperimentsIT extends AbstractImplicitMergeTest {
+  @Override
+  protected boolean enableExperimentsRejectImplicitMergesOnMerge() {
+    // Tests uses own experiment setup.
+    return false;
+  }
+
+  @ConfigSuite.Configs
+  public static ImmutableMap<String, Config> configs() {
+    // The @RunWith(Parameterized.class) can't be used, because AbstractDaemonClass already
+    // uses @RunWith(ConfigSuite.class). Emulate parameters using configs.
+    ImmutableMap.Builder<String, Config> builder = ImmutableMap.builder();
+    for (SubmitType submitType : SubmitType.values()) {
+      if (submitType == SubmitType.INHERIT
+          || submitType == SubmitType.CHERRY_PICK
+          || submitType == SubmitType.REBASE_ALWAYS) {
+        continue;
+      }
+      Config cfg = new Config();
+      cfg.setString("test", null, "submitType", submitType.name());
+      builder.put(String.format("submitType=%s", submitType), cfg);
+    }
+    return builder.buildOrThrow();
+  }
+
+  private String implicitMergeChangeId;
+  private String explicitMergeChangeId;
+
+  private SubmitType submitType;
+
+  @Before
+  public void setUp() throws Exception {
+    // The ConfigSuite runner always adds a default config. Ignore it (submitType is not set for
+    // it).
+    assume().that(cfg.getString("test", null, "submitType")).isNotEmpty();
+    RevCommit base = repo().parseCommit(repo().exactRef("HEAD").getObjectId());
+    RevCommit stableBranchTip =
+        pushTo("refs/heads/stable", ImmutableMap.of("stable-content", "stable-first-line\n"), base)
+            .getCommit();
+    RevCommit masterBranchTip =
+        pushTo(
+                "refs/heads/master",
+                ImmutableMap.of("master-content", "master-first-line\n"),
+                stableBranchTip)
+            .getCommit();
+    implicitMergeChangeId = "I" + generateChangeId().name();
+    RevCommit implicitMergeChange =
+        createChangeWithoutPush(
+            implicitMergeChangeId,
+            ImmutableMap.of("master-content2", "added-by-implicit-merge\n"),
+            masterBranchTip);
+    explicitMergeChangeId =
+        pushTo(
+                "refs/for/stable",
+                ImmutableMap.of("stable-content", "stable-first-line\nadded-by-explicit-merge\n"),
+                implicitMergeChange,
+                stableBranchTip)
+            .getChangeId();
+    gApi.changes().id(implicitMergeChangeId).current().review(ReviewInput.approve());
+    gApi.changes().id(explicitMergeChangeId).current().review(ReviewInput.approve());
+    submitType = SubmitType.valueOf(cfg.getString("test", null, "submitType"));
+    setSubmitType(submitType);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        "GerritBackendFeature__check_implicit_merges_on_merge",
+        "GerritBackendFeature__reject_implicit_merges_on_merge",
+        "GerritBackendFeature__always_reject_implicit_merges_on_merge"
+      })
+  public void alwaysRejectOnMerge_rejectImplicitMergeFalse_rejectImplicitMergeOnSubmit()
+      throws Exception {
+    setRejectImplicitMerges(/*reject=*/ false);
+    assertThatImplicitMergeSubmitRejected();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        "GerritBackendFeature__check_implicit_merges_on_merge",
+        "GerritBackendFeature__reject_implicit_merges_on_merge",
+        "GerritBackendFeature__always_reject_implicit_merges_on_merge"
+      })
+  public void alwaysRejectOnMerge_rejectImplicitMergeFalse_canSubmitExplicitMerge()
+      throws Exception {
+    setRejectImplicitMerges(/*reject=*/ false);
+    assertThatExcplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        "GerritBackendFeature__check_implicit_merges_on_merge",
+        "GerritBackendFeature__reject_implicit_merges_on_merge",
+        "GerritBackendFeature__always_reject_implicit_merges_on_merge"
+      })
+  public void alwaysRejectOnMerge_rejectImplicitMergeTrue_rejectImplicitMergeOnSubmit()
+      throws Exception {
+    setRejectImplicitMerges(/*reject=*/ true);
+    assertThatImplicitMergeSubmitRejected();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        "GerritBackendFeature__check_implicit_merges_on_merge",
+        "GerritBackendFeature__reject_implicit_merges_on_merge",
+        "GerritBackendFeature__always_reject_implicit_merges_on_merge"
+      })
+  public void alwaysRejectOnMerge_rejectImplicitMergeTrue_canSubmitExplicitMerge()
+      throws Exception {
+    setRejectImplicitMerges(/*reject=*/ true);
+    assertThatExcplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        "GerritBackendFeature__check_implicit_merges_on_merge",
+        "GerritBackendFeature__reject_implicit_merges_on_merge",
+      })
+  public void rejectOnMerge_rejectImplicitMergeFalse_canSubmitImplicitMerge() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ false);
+    assertThatImplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        "GerritBackendFeature__check_implicit_merges_on_merge",
+        "GerritBackendFeature__reject_implicit_merges_on_merge",
+      })
+  public void rejectOnMerge_rejectImplicitMergeFalse_canSubmitExplicitMerge() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ false);
+    assertThatExcplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        "GerritBackendFeature__check_implicit_merges_on_merge",
+        "GerritBackendFeature__reject_implicit_merges_on_merge",
+      })
+  public void rejectOnMerge_rejectImplicitMergeTrue_rejectImplicitMergeOnSubmit() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ true);
+    assertThatImplicitMergeSubmitRejected();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        "GerritBackendFeature__check_implicit_merges_on_merge",
+        "GerritBackendFeature__reject_implicit_merges_on_merge",
+      })
+  public void rejectOnMerge_rejectImplicitMergeTrue_canSubmitExplicitMerge() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ true);
+    assertThatExcplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        "GerritBackendFeature__check_implicit_merges_on_merge",
+      })
+  public void checkOnly_rejectImplicitMergeFalse_canSubmitImplicitMerge() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ false);
+    assertThatImplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        "GerritBackendFeature__check_implicit_merges_on_merge",
+      })
+  public void checkOnly_rejectImplicitMergeFalse_canSubmitExplicitMerge() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ false);
+    assertThatExcplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        "GerritBackendFeature__check_implicit_merges_on_merge",
+      })
+  public void checkOnly_rejectImplicitMergeTrue_canSubmitImplicitMerge() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ true);
+    assertThatImplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        "GerritBackendFeature__check_implicit_merges_on_merge",
+      })
+  public void checkOnly_rejectImplicitMergeTrue_canSubmitExplicitMerge() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ true);
+    assertThatExcplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  public void noExperiments_rejectImplicitMergeFalse_canSubmitImplicitMerge() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ false);
+    assertThatImplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  public void noExperiments_rejectImplicitMergeFalse_canSubmitExplicitMerge() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ false);
+    assertThatExcplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  public void noExperiments_rejectImplicitMergeTrue_canSubmitImplicitMerge() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ true);
+    assertThatImplicitMergeSubmitAllowed();
+  }
+
+  @Test
+  public void noExperiments_rejectImplicitMergeTrue_canSubmitExplicitMerge() throws Exception {
+    setRejectImplicitMerges(/*reject=*/ true);
+    assertThatExcplicitMergeSubmitAllowed();
+  }
+
+  private void assertThatImplicitMergeSubmitRejected() throws Exception {
+    ResourceConflictException e =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(implicitMergeChangeId).current().submit());
+    assertThat(e.getMessage().toLowerCase()).contains("submit makes implicit merge to the branch");
+    ChangeInfo ci = gApi.changes().id(implicitMergeChangeId).info();
+    assertThat(ci.submitted).isNull();
+    assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
+        .containsExactly("stable-content", "stable-first-line\n");
+  }
+
+  private void assertThatImplicitMergeSubmitAllowed() throws Exception {
+    gApi.changes().id(implicitMergeChangeId).current().submit();
+
+    ChangeInfo ci = gApi.changes().id(implicitMergeChangeId).info();
+    assertThat(ci.submitted).isNotNull();
+    assertThat(ci.submitter).isNotNull();
+    assertThat(ci.submitter._accountId)
+        .isEqualTo(localCtx.getContext().getUser().getAccountId().get());
+
+    if (submitType != SubmitType.REBASE_ALWAYS) {
+      assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
+          .containsExactly(
+              "master-content", "master-first-line\n",
+              "master-content2", "added-by-implicit-merge\n",
+              "stable-content", "stable-first-line\n");
+    } else {
+      assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
+          .containsExactly(
+              "master-content2", "added-by-implicit-merge\n",
+              "stable-content", "stable-first-line\n");
+    }
+  }
+
+  private void assertThatExcplicitMergeSubmitAllowed() throws Exception {
+    gApi.changes().id(explicitMergeChangeId).current().submit();
+
+    ChangeInfo ci = gApi.changes().id(explicitMergeChangeId).info();
+    assertThat(ci.submitted).isNotNull();
+    assertThat(ci.submitter).isNotNull();
+    assertThat(ci.submitter._accountId)
+        .isEqualTo(localCtx.getContext().getUser().getAccountId().get());
+    assertThat(getRemoteBranchRootPathContent("refs/heads/stable"))
+        .containsExactly(
+            "master-content", "master-first-line\n",
+            "master-content2", "added-by-implicit-merge\n",
+            "stable-content", "stable-first-line\nadded-by-explicit-merge\n");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitIT.java
new file mode 100644
index 0000000..ba881b6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeOnSubmitIT.java
@@ -0,0 +1,322 @@
+// 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.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Verifies that gerrit correctly detects implicit merges on submit..
+ *
+ * <p>The setup creates a repository with 2 branches: master and target. Both branches have common
+ * parent:
+ *
+ * <pre>{@code
+ * master                target
+ *  |                       |
+ *  ----->base commit <------
+ * }</pre>
+ *
+ * Tests use only MergeAlways strategy. All other submit strategies (except cherry pick and rebase
+ * always) use the same checks on submit. The {@link ImplicitMergeOnSubmitExperimentsIT} validates
+ * that the implicit merge check is applied to all strategies (except cherry pick and rebase always)
+ * and {@link ImplicitMergeOnSubmitByCherryPickOrRebaseAlwaysIT} contains tests for the cherry pick
+ * and rebase always strategies.
+ */
+public class ImplicitMergeOnSubmitIT extends AbstractImplicitMergeTest {
+  private RevCommit masterTip;
+  private RevCommit otherTip;
+  RevCommit baseCommit;
+
+  @Before
+  public void setUp() throws Exception {
+    setSubmitType(SubmitType.MERGE_ALWAYS);
+    gApi.projects().name(project.get()).branch("other").create(new BranchInput());
+    baseCommit =
+        repo()
+            .parseCommit(
+                ObjectId.fromString(
+                    gApi.projects().name(project.get()).branch("master").get().revision));
+    masterTip =
+        pushTo("refs/heads/master", ImmutableMap.of("master-file", "master-content"), baseCommit)
+            .getCommit();
+    otherTip =
+        pushTo("refs/heads/other", ImmutableMap.of("target-file", "target1-content"), baseCommit)
+            .getCommit();
+  }
+
+  @Test
+  public void singleChangeImplicitMerge() throws Exception {
+    PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
+    assertSubmitRejectedWithImplicitMerge(implicitMerge.getChangeId());
+  }
+
+  @Test
+  public void chainOfChangesImplicitMerge() throws Exception {
+    PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
+    PushOneCommit.Result c1 = createApprovedChange("master", implicitMerge);
+    PushOneCommit.Result c2 = createApprovedChange("master", c1);
+    assertSubmitRejectedWithImplicitMerge(implicitMerge.getChangeId());
+    assertSubmitRejectedWithImplicitMerge(c1.getChangeId());
+    assertSubmitRejectedWithImplicitMerge(c2.getChangeId());
+  }
+
+  @Test
+  public void chainOfChangesOnTopOfTargetBranchTipNoImplicitMerge() throws Exception {
+    PushOneCommit.Result c1 = createApprovedChange("master", masterTip);
+    PushOneCommit.Result c2 = createApprovedChange("master", c1);
+    PushOneCommit.Result c3 = createApprovedChange("master", c2);
+    assertThatChangeSubmittable(c1.getChangeId());
+    assertThatChangeSubmittable(c2.getChangeId());
+    assertThatChangeSubmittable(c3.getChangeId());
+  }
+
+  @Test
+  public void chainOfChangesNotOnTopOfTargetBranchTipNoImplicitMerge() throws Exception {
+    // Add one more commit to master branch.
+    pushTo("refs/heads/master", ImmutableMap.of(), masterTip);
+    PushOneCommit.Result c1 = createApprovedChange("master", masterTip);
+    PushOneCommit.Result c2 = createApprovedChange("master", c1);
+    PushOneCommit.Result c3 = createApprovedChange("master", c2);
+    assertThatChangeSubmittable(c1.getChangeId());
+    assertThatChangeSubmittable(c2.getChangeId());
+    assertThatChangeSubmittable(c3.getChangeId());
+  }
+
+  @Test
+  public void chainOfChangesNotOnTopOfTargetBranchTipWithImplicitMerge() throws Exception {
+    // Add one more commit to master branch.
+    pushTo("refs/heads/master", ImmutableMap.of(), masterTip);
+    PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
+    PushOneCommit.Result c2 = createApprovedChange("master", implicitMerge);
+    PushOneCommit.Result c3 = createApprovedChange("master", c2);
+    assertSubmitRejectedWithImplicitMerge(implicitMerge.getChangeId());
+    assertSubmitRejectedWithImplicitMerge(c2.getChangeId());
+    assertSubmitRejectedWithImplicitMerge(c3.getChangeId());
+  }
+
+  @Test
+  public void chainOfChangesEndsWithExplicitMerge_onlyExplcitMergeCanBeSubmitted()
+      throws Exception {
+    PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
+    PushOneCommit.Result changeInChange = createApprovedChange("master", implicitMerge);
+    PushOneCommit.Result explicitMerge =
+        createApprovedChange("master", changeInChange.getCommit(), masterTip);
+    assertSubmitRejectedWithImplicitMerge(implicitMerge.getChangeId());
+    assertSubmitRejectedWithImplicitMerge(changeInChange.getChangeId());
+    assertThatChangeSubmittable(explicitMerge.getChangeId());
+  }
+
+  @Test
+  public void twoChainOfChangesSameTopic_oneChainImplicitMerge_rejectedOnSubmit() throws Exception {
+    cfg.setBoolean("change", null, "submitWholeTopic", true);
+    PushOneCommit.Result c1 = createApprovedChange("master", masterTip);
+    PushOneCommit.Result c2 = createApprovedChange("master", c1);
+    PushOneCommit.Result c3 = createApprovedChange("master", c2.getCommit());
+    PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
+    PushOneCommit.Result im1 = createApprovedChange("master", implicitMerge);
+    PushOneCommit.Result im2 = createApprovedChange("master", im1.getCommit());
+    // The AbstractDaemonTest doesn't fully reset gerrit; it creates a new project for each test
+    // and doesn't remove changes created in tests. As a result, if the same topic is used in
+    // several tests gerrit tries to submit all changes, including changes from other tests.
+    // The name method returns name scoped to this test method .
+    String topic = name("topic");
+    gApi.changes().id(c1.getChangeId()).topic(topic);
+    gApi.changes().id(c2.getChangeId()).topic(topic);
+    gApi.changes().id(c3.getChangeId()).topic(topic);
+    gApi.changes().id(implicitMerge.getChangeId()).topic(topic);
+    gApi.changes().id(im1.getChangeId()).topic(topic);
+    gApi.changes().id(im2.getChangeId()).topic(topic);
+
+    assertSubmitRejectedWithImplicitMerge(c1.getChangeId());
+  }
+
+  @Test
+  public void twoChainOfChangesSameTopic_noImplicitMerge_canSubmit() throws Exception {
+    cfg.setBoolean("change", null, "submitWholeTopic", true);
+    PushOneCommit.Result chain1change1 = createApprovedChange("master", masterTip);
+    PushOneCommit.Result chain1change2 = createApprovedChange("master", chain1change1);
+    PushOneCommit.Result chain1change3 = createApprovedChange("master", chain1change2);
+    PushOneCommit.Result chain2change1 = createApprovedChange("master", masterTip);
+    PushOneCommit.Result chain2change2 = createApprovedChange("master", chain2change1);
+    PushOneCommit.Result chain2change3 = createApprovedChange("master", chain2change2);
+    // The AbstractDaemonTest doesn't fully reset gerrit; it creates a new project for each test
+    // and doesn't remove changes created in tests. As a result, if the same topic is used in
+    // several tests gerrit tries to submit all changes, including changes from other tests.
+    // The name method returns name scoped to this test method .
+    String topic = name("topic");
+    gApi.changes().id(chain1change1.getChangeId()).topic(topic);
+    gApi.changes().id(chain1change2.getChangeId()).topic(topic);
+    gApi.changes().id(chain1change3.getChangeId()).topic(topic);
+    gApi.changes().id(chain2change1.getChangeId()).topic(topic);
+    gApi.changes().id(chain2change2.getChangeId()).topic(topic);
+    gApi.changes().id(chain2change3.getChangeId()).topic(topic);
+
+    assertThatChangeSubmittable(chain1change1.getChangeId());
+  }
+
+  @Test
+  public void twoChainOfChangesSameTopicNotOnTopOfBranch_noImplicitMerge_canSubmit()
+      throws Exception {
+    // Add one more commit to master branch.
+    pushTo("refs/heads/master", ImmutableMap.of(), masterTip);
+    cfg.setBoolean("change", null, "submitWholeTopic", true);
+    PushOneCommit.Result chain1change1 = createApprovedChange("master", masterTip);
+    PushOneCommit.Result chain1change2 = createApprovedChange("master", chain1change1);
+    PushOneCommit.Result chain1change3 = createApprovedChange("master", chain1change2);
+    PushOneCommit.Result chain2change1 = createApprovedChange("master", masterTip);
+    PushOneCommit.Result chain2change2 = createApprovedChange("master", chain2change1);
+    PushOneCommit.Result chain2change3 = createApprovedChange("master", chain2change2);
+    // The AbstractDaemonTest doesn't fully reset gerrit; it creates a new project for each test
+    // and doesn't remove changes created in tests. As a result, if the same topic is used in
+    // several tests gerrit tries to submit all changes, including changes from other tests.
+    // The name method returns name scoped to this test method .
+    String topic = name("topic");
+    gApi.changes().id(chain1change1.getChangeId()).topic(topic);
+    gApi.changes().id(chain1change2.getChangeId()).topic(topic);
+    gApi.changes().id(chain1change3.getChangeId()).topic(topic);
+    gApi.changes().id(chain2change1.getChangeId()).topic(topic);
+    gApi.changes().id(chain2change2.getChangeId()).topic(topic);
+    gApi.changes().id(chain2change3.getChangeId()).topic(topic);
+
+    assertThatChangeSubmittable(chain1change1.getChangeId());
+  }
+
+  @Test
+  public void twoChainOfChangesEndsWithExplicitMergeSameTopicNotTipOfBranches_canBeSubmitted()
+      throws Exception {
+    cfg.setBoolean("change", null, "submitWholeTopic", true);
+    // Add one more commit to master branch.
+    pushTo("refs/heads/master", ImmutableMap.of(), masterTip);
+    PushOneCommit.Result implicitMerge1 = createApprovedChange("master", otherTip);
+    PushOneCommit.Result changeInChain1 = createApprovedChange("master", implicitMerge1);
+    PushOneCommit.Result explicitMerge1 =
+        createApprovedChange("master", changeInChain1.getCommit(), masterTip);
+    PushOneCommit.Result implicitMerge2 = createApprovedChange("master", otherTip);
+    PushOneCommit.Result changeInChain2 = createApprovedChange("master", implicitMerge2);
+    PushOneCommit.Result explicitMerge2 =
+        createApprovedChange("master", changeInChain2.getCommit(), masterTip);
+    // The AbstractDaemonTest doesn't fully reset gerrit; it creates a new project for each test
+    // and doesn't remove changes created in tests. As a result, if the same topic is used in
+    // several tests gerrit tries to submit all changes, including changes from other tests.
+    // The name method returns name scoped to this test method .
+    String topic = name("topic");
+    gApi.changes().id(implicitMerge1.getChangeId()).topic(topic);
+    gApi.changes().id(changeInChain1.getChangeId()).topic(topic);
+    gApi.changes().id(explicitMerge1.getChangeId()).topic(topic);
+    gApi.changes().id(implicitMerge2.getChangeId()).topic(topic);
+    gApi.changes().id(changeInChain2.getChangeId()).topic(topic);
+    gApi.changes().id(explicitMerge2.getChangeId()).topic(topic);
+
+    assertThatChangeSubmittable(explicitMerge2.getChangeId());
+  }
+
+  @Test
+  public void twoChainOfChangesDifferentBranchesSameTopic_oneChainImplicitMerge_rejectedOnSubmit()
+      throws Exception {
+    cfg.setBoolean("change", null, "submitWholeTopic", true);
+    PushOneCommit.Result implicitMerge = createApprovedChange("other", masterTip);
+    PushOneCommit.Result im1 = createApprovedChange("other", implicitMerge);
+    PushOneCommit.Result im2 = createApprovedChange("other", im1.getCommit());
+    PushOneCommit.Result c1 = createApprovedChange("master", masterTip);
+    PushOneCommit.Result c2 = createApprovedChange("master", c1);
+    PushOneCommit.Result c3 = createApprovedChange("master", c2.getCommit());
+    // The AbstractDaemonTest doesn't fully reset gerrit; it creates a new project for each test
+    // and doesn't remove changes created in tests. As a result, if the same topic is used in
+    // several tests gerrit tries to submit all changes, including changes from other tests.
+    // The name method returns name scoped to this test method .
+    String topic = name("topic");
+    gApi.changes().id(implicitMerge.getChangeId()).topic(topic);
+    gApi.changes().id(im1.getChangeId()).topic(topic);
+    gApi.changes().id(im2.getChangeId()).topic(topic);
+    gApi.changes().id(c1.getChangeId()).topic(topic);
+    gApi.changes().id(c2.getChangeId()).topic(topic);
+    gApi.changes().id(c3.getChangeId()).topic(topic);
+
+    assertSubmitRejectedWithImplicitMerge(implicitMerge.getChangeId());
+  }
+
+  @Test
+  public void explicitMergeOnTopOfChain_onlyTopSubmittable() throws Exception {
+    PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
+    PushOneCommit.Result im1 = createApprovedChange("master", implicitMerge);
+    PushOneCommit.Result im2 = createApprovedChange("master", im1.getCommit());
+    PushOneCommit.Result explicitMerge = createApprovedChange("master", masterTip, im2.getCommit());
+
+    assertSubmitRejectedWithImplicitMerge(implicitMerge.getChangeId());
+    assertSubmitRejectedWithImplicitMerge(im1.getChangeId());
+    assertSubmitRejectedWithImplicitMerge(im2.getChangeId());
+    assertThatChangeSubmittable(explicitMerge.getChangeId());
+  }
+
+  @Test
+  public void explicitMergeOnTopOfChainParentIsNotBranchTip_onlyTopSubmittable() throws Exception {
+    // Add one more commit to master and other branches.
+    pushTo("refs/heads/master", ImmutableMap.of(), masterTip);
+    pushTo("refs/heads/other", ImmutableMap.of(), otherTip);
+
+    PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip);
+    PushOneCommit.Result im1 = createApprovedChange("master", implicitMerge);
+    PushOneCommit.Result im2 = createApprovedChange("master", im1.getCommit());
+    PushOneCommit.Result explicitMerge = createApprovedChange("master", masterTip, im2.getCommit());
+
+    assertSubmitRejectedWithImplicitMerge(implicitMerge.getChangeId());
+    assertSubmitRejectedWithImplicitMerge(im1.getChangeId());
+    assertSubmitRejectedWithImplicitMerge(im2.getChangeId());
+    assertThatChangeSubmittable(explicitMerge.getChangeId());
+  }
+
+  @Test
+  public void threeBranches_onlyExplicitCommitSubmittable() throws Exception {
+    BranchInput bi = new BranchInput();
+    bi.revision = baseCommit.getName();
+    gApi.projects().name(project.get()).branch("third").create(bi);
+    RevCommit thirdBranchTip =
+        pushTo("refs/heads/third", ImmutableMap.of("third-file", "third-content"), baseCommit)
+            .getCommit();
+
+    PushOneCommit.Result explicitMerge = createApprovedChange("master", masterTip, otherTip);
+    PushOneCommit.Result implicitMerge = createApprovedChange("master", otherTip, thirdBranchTip);
+    PushOneCommit.Result explicitMerge2 =
+        createApprovedChange("master", explicitMerge, implicitMerge);
+
+    assertSubmitRejectedWithImplicitMerge(implicitMerge.getChangeId());
+    assertThatChangeSubmittable(explicitMerge.getChangeId());
+    assertThatChangeSubmittable(explicitMerge2.getChangeId());
+  }
+
+  private void assertSubmitRejectedWithImplicitMerge(String changeId) throws Exception {
+    ResourceConflictException e =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
+    assertThat(e.getMessage()).contains("implicit merge");
+  }
+
+  private void assertThatChangeSubmittable(String changeId) throws Exception {
+    ChangeInfo ci = gApi.changes().id(changeId).current().submit();
+    assertThat(ci.submitted).isNotNull();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 3bec694..6c5febd 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -163,8 +163,6 @@
   //    (c3_open)                            (c4_open)
   //
   private void setUpChanges() throws Exception {
-    gApi.projects().name(project.get()).branch("branch").create(new BranchInput());
-
     // First 2 changes are merged, which means the tags pointing to them are
     // visible.
     projectOperations
@@ -183,6 +181,9 @@
     metaRef1 = RefNames.changeMetaRef(cd1.getId());
 
     //   rcMaster (c1 master) <-- rcBranch (c2 branch)
+    BranchInput branchInput = new BranchInput();
+    branchInput.revision = mr.getCommit().getName();
+    gApi.projects().name(project.get()).branch("branch").create(branchInput);
     PushOneCommit.Result br =
         pushFactory.create(admin.newIdent(), testRepo).to("refs/for/branch%submit");
     br.assertOkStatus();
@@ -196,7 +197,7 @@
     //      \
     //    (c3_open)
     //
-    mr = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
+    mr = pushFactory.create(admin.newIdent(), testRepo).setParent(rcMaster).to("refs/for/master");
     mr.assertOkStatus();
     cd3 = mr.getChange();
     psRef3 = cd3.currentPatchSet().id().toRefName();
@@ -205,7 +206,7 @@
     //   rcMaster (c1 master) <-- rcBranch (c2 branch)
     //      \                        \
     //     (c3_open)                (c4_open)
-    br = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/branch");
+    br = pushFactory.create(admin.newIdent(), testRepo).setParent(rcBranch).to("refs/for/branch");
     br.assertOkStatus();
     cd4 = br.getChange();
     psRef4 = cd4.currentPatchSet().id().toRefName();
@@ -1405,7 +1406,7 @@
             .to("refs/for/" + RefNames.REFS_USERS_SELF);
     mr.assertOkStatus();
 
-    List<String> expectedNonMetaRefs =
+    ImmutableList<String> expectedNonMetaRefs =
         ImmutableList.of(
             RefNames.refsUsers(admin.id()),
             RefNames.refsUsers(user.id()),
@@ -1547,7 +1548,7 @@
     return AccountGroup.uuid(gApi.groups().create(groupInput).get().id);
   }
 
-  private static Collection<String> names(Collection<Ref> refs) {
+  private static ImmutableList<String> names(Collection<Ref> refs) {
     return refs.stream().map(Ref::getName).collect(toImmutableList());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
index 3cf54f3..e18c79d 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
@@ -91,7 +91,7 @@
             .add(
                 new RefOperationValidationListener() {
                   @Override
-                  public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+                  public ImmutableList<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
                       throws ValidationException {
                     return ImmutableList.of(
                         new ValidationMessage(message1, ValidationMessage.Type.HINT),
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index c9607f5..42c239b 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -418,7 +418,6 @@
     Project.NameKey config2Key =
         projectOperations.newProject().parent(configKey).submitType(getSubmitType()).create();
     grantPush(config2Key);
-    cloneProject(config2Key);
 
     subKey = projectOperations.newProject().parent(config2Key).submitType(getSubmitType()).create();
     grantPush(subKey);
@@ -734,8 +733,7 @@
     gApi.projects()
         .name(allProjects.get())
         .submitRequirement("Block-Submodule-Change")
-        .create(input)
-        .get();
+        .create(input);
   }
 
   private boolean getStatus(ChangeData cd) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 0d751f1..dd079de 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -645,12 +645,19 @@
     allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
     allowMatchingSubmoduleSubscription(superKey, "refs/heads/dev", subKey, "refs/heads/dev");
 
+    // Create 'dev' branches in both repos by pushing changes.
     pushChangeTo(subRepo, "dev");
     pushChangeTo(superRepo, "dev");
 
     createSubmoduleSubscription(superRepo, "master", subKey, "master");
     createSubmoduleSubscription(subRepo, "dev", superKey, "dev");
 
+    // Reset the state of local repositories to avoid implicit merge changes.
+    subRepo.git().fetch();
+    subRepo.reset(subRepo.git().getRepository().findRef("origin/master").getObjectId().getName());
+    superRepo.git().fetch();
+    superRepo.reset(superRepo.git().getRepository().findRef("origin/dev").getObjectId().getName());
+
     ObjectId subMasterHead =
         pushChangeTo(
             subRepo, "refs/for/master", "b.txt", "content b", "some message", "same-topic");
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index 7386a03..66c4078 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -18,10 +18,10 @@
 import static com.google.common.truth.StreamSubject.streams;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.extensions.client.ListGroupsOption.MEMBERS;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.io.MoreFiles;
 import com.google.common.io.RecursiveDeleteOption;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -30,6 +30,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.Schema;
@@ -45,7 +46,6 @@
 import com.google.inject.TypeLiteral;
 import java.nio.file.Files;
 import java.util.Collection;
-import java.util.Set;
 import java.util.function.Consumer;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
@@ -68,9 +68,45 @@
     Files.createDirectory(sitePaths.index_dir);
     assertServerStartupFails();
 
-    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
+    assertReady(ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion());
+    assertIndexQueries();
+  }
+
+  @Test
+  public void reindexWithSkipExistingDocumentsEnabled() throws Exception {
+    updateConfig(config -> config.setBoolean("index", null, "reuseExistingDocuments", true));
+    setUpChange();
+
+    MoreFiles.deleteRecursively(sitePaths.index_dir, RecursiveDeleteOption.ALLOW_INSECURE);
+    Files.createDirectory(sitePaths.index_dir);
+    assertServerStartupFails();
+
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
     assertReady(ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion());
 
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
+    assertIndexQueries();
+
+    Files.copy(sitePaths.index_dir, sitePaths.resolve("index-backup"));
+    try (ServerContext ctx = startServer()) {
+      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+      gApi.changes().id(changeId).revision(1).review(ReviewInput.approve());
+      // Query change index
+      assertThat(gApi.changes().query("label:Code-Review+2").get().stream().map(c -> c.changeId))
+          .containsExactly(changeId);
+    }
+    MoreFiles.deleteRecursively(sitePaths.index_dir, RecursiveDeleteOption.ALLOW_INSECURE);
+    Files.copy(sitePaths.resolve("index-backup"), sitePaths.index_dir);
+    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace", "--verbose");
+    try (ServerContext ctx = startServer()) {
+      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
+      assertThat(gApi.changes().query("label:Code-Review+2").get().stream().map(c -> c.changeId))
+          .containsExactly(changeId);
+    }
+  }
+
+  private void assertIndexQueries() throws Exception {
     try (ServerContext ctx = startServer()) {
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
       // Query change index
@@ -290,7 +326,8 @@
   }
 
   private void assertReady(int expectedReady) throws Exception {
-    Set<Integer> allVersions = ChangeSchemaDefinitions.INSTANCE.getSchemas().keySet();
+    ImmutableSortedSet<Integer> allVersions =
+        ChangeSchemaDefinitions.INSTANCE.getSchemas().keySet();
     GerritIndexStatus status = new GerritIndexStatus(sitePaths);
     assertWithMessage("ready state for index versions")
         .that(
diff --git a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
index 073f427..98228be 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.collect.ImmutableSet;
diff --git a/javatests/com/google/gerrit/acceptance/pgm/StartStopDaemonIT.java b/javatests/com/google/gerrit/acceptance/pgm/StartStopDaemonIT.java
index b3ae01f..b2dedc0 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/StartStopDaemonIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/StartStopDaemonIT.java
@@ -21,18 +21,43 @@
 import java.lang.management.ManagementFactory;
 import java.lang.management.ThreadMXBean;
 import org.junit.Test;
+import org.junit.rules.RuleChain;
+import org.junit.rules.TestRule;
 import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
 
 @Sandboxed
 public class StartStopDaemonIT extends AbstractDaemonTest {
   Description suiteDescription = Description.createSuiteDescription(StartStopDaemonIT.class);
 
+  @Override
+  protected TestRule createTopLevelTestRule() {
+    TestRule innerRules = super.createTopLevelTestRule();
+    return RuleChain.outerRule(
+            new TestRule() {
+              @Override
+              public Statement apply(Statement statement, Description description) {
+                return new Statement() {
+                  @Override
+                  public void evaluate() throws Throwable {
+                    ThreadMXBean thbean = ManagementFactory.getThreadMXBean();
+                    int startThreads = thbean.getThreadCount();
+                    statement.evaluate();
+                    assertThat(Thread.activeCount()).isLessThan(startThreads);
+                  }
+                };
+              }
+            })
+        .around(innerRules);
+  }
+
   @Test
-  public void sandboxedDaemonDoesNotLeakThreads() throws Exception {
-    ThreadMXBean thbean = ManagementFactory.getThreadMXBean();
-    int startThreads = thbean.getThreadCount();
-    beforeTest(suiteDescription);
-    afterTest();
-    assertThat(Thread.activeCount()).isLessThan(startThreads);
+  public void sandboxedDaemonDoesNotLeakThreads_1() throws Exception {
+    // dummy test - the sandboxed server will be started and then stopped
+  }
+
+  @Test
+  public void sandboxedDaemonDoesNotLeakThreads_2() throws Exception {
+    // dummy test - the sandboxed server will be started and then stopped
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
index 0393f2b..5c1f7c2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.httpd.restapi.RestApiServlet.X_GERRIT_UPDATED_REF_ENABLED;
 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.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
@@ -34,7 +35,6 @@
 import com.google.gerrit.httpd.restapi.RestApiServlet;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.util.List;
 import java.util.regex.Pattern;
 import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -202,7 +202,7 @@
             "/changes/" + change.getChangeId(), X_GERRIT_UPDATED_REF_ENABLED_HEADER);
     response.assertNoContent();
 
-    List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+    ImmutableList<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
 
     // The change was deleted, so the refs were deleted which means they are ObjectId.zeroId().
     assertThat(headers)
@@ -236,7 +236,7 @@
             "/changes/" + change.getChangeId(), X_GERRIT_UPDATED_REF_ENABLED_HEADER);
     response.assertNoContent();
 
-    List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+    ImmutableList<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
 
     // The change was deleted, so the refs were deleted which means they are ObjectId.zeroId().
     assertThat(headers)
@@ -286,7 +286,7 @@
       ObjectId firstMetaRefSha1 = getMetaRefSha1(change1);
       ObjectId secondMetaRefSha1 = getMetaRefSha1(change2);
 
-      List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+      ImmutableList<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
 
       String branch = change1.getChange().change().getDest().branch();
       String branchSha1 =
@@ -363,7 +363,7 @@
       ObjectId firstMetaRefSha1 = getMetaRefSha1(change1);
       ObjectId secondMetaRefSha1 = getMetaRefSha1(change2);
 
-      List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+      ImmutableList<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
 
       String branchSha1Project1 =
           repository1
@@ -473,13 +473,10 @@
     return change.getChange().notes().getRevision();
   }
 
-  private RestResponse assertRestResponseWithParameters(int status, String k, String v)
-      throws Exception {
+  private void assertRestResponseWithParameters(int status, String k, String v) throws Exception {
     RestResponse response =
         adminRestSession.getWithHeaders(ANY_REST_API + "?" + k + "=" + v, ACCEPT_STAR_HEADER);
     assertThat(response.getStatusCode()).isEqualTo(status);
-
-    return response;
   }
 
   private RestResponse prettyJsonRestResponse(String ppArgument, int ppValue) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
index 79e8ab0..9b6b260 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.entities.Account;
@@ -49,7 +50,7 @@
    * @param actual the AccountInfos that should be asserted
    */
   public static void assertAccountInfos(List<TestAccount> expected, List<AccountInfo> actual) {
-    Iterable<Account.Id> expectedIds = TestAccount.ids(expected);
+    ImmutableList<Account.Id> expectedIds = TestAccount.ids(expected);
     Iterable<Account.Id> actualIds = Iterables.transform(actual, a -> Account.id(a._accountId));
     assertThat(actualIds).containsExactlyElementsIn(expectedIds).inOrder();
     for (int i = 0; i < expected.size(); i++) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java
index 8d801f1..5fef74c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CreateAccountIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.rest.account;
 
-import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
index f40910a..794634f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -15,14 +15,12 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.Multimap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
@@ -48,6 +46,7 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
+import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -193,15 +192,13 @@
     assertThat(externalIds.get(mailtoExtIdKey)).isEmpty();
     assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
 
-    Context oldCtx = createContextWithCustomRealm(new RealmWithAdditionalEmails(admin.id(), email));
-    try {
+    try (ManualRequestContext newCtx =
+        createContextWithCustomRealm(new RealmWithAdditionalEmails(admin.id(), email))) {
       gApi.accounts().self().email(email).setPreferred();
       Optional<ExternalId> mailtoExtId = externalIds.get(mailtoExtIdKey);
       assertThat(mailtoExtId).isPresent();
       assertThat(mailtoExtId.get().accountId()).isEqualTo(admin.id());
       assertThat(gApi.accounts().self().get().email).isEqualTo(email);
-    } finally {
-      atrScope.set(oldCtx);
     }
   }
 
@@ -210,16 +207,13 @@
     ExternalId mailToExtId = externalIdFactory.createEmail(user.id(), user.email());
     assertThat(externalIds.get(mailToExtId.key())).isPresent();
 
-    Context oldCtx =
-        createContextWithCustomRealm(new RealmWithAdditionalEmails(admin.id(), user.email()));
-    try {
+    try (ManualRequestContext newCtx =
+        createContextWithCustomRealm(new RealmWithAdditionalEmails(admin.id(), user.email()))) {
       ResourceConflictException thrown =
           assertThrows(
               ResourceConflictException.class,
               () -> gApi.accounts().self().email(user.email()).setPreferred());
       assertThat(thrown).hasMessageThat().contains("email in use by another account");
-    } finally {
-      atrScope.set(oldCtx);
     }
   }
 
@@ -280,7 +274,7 @@
     r.assertCreated();
   }
 
-  private Context createContextWithCustomRealm(Realm realm) {
+  private ManualRequestContext createContextWithCustomRealm(Realm realm) {
     IdentifiedUser.GenericFactory userFactory =
         new IdentifiedUser.GenericFactory(
             authConfig,
@@ -291,7 +285,7 @@
             enablePeerIPInReflogRecord,
             accountCache,
             groupBackend);
-    return atrScope.set(atrScope.newContext(null, userFactory.create(admin.id())));
+    return new ManualRequestContext(userFactory.create(admin.id()), localCtx);
   }
 
   private class RealmWithAdditionalEmails extends DefaultRealm {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 749ca79..e62d365 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
@@ -110,7 +109,7 @@
 
   @Test
   public void getExternalIds() throws Exception {
-    Collection<ExternalId> expectedIds = getAccountState(user.id()).externalIds();
+    ImmutableSet<ExternalId> expectedIds = getAccountState(user.id()).externalIds();
     List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
 
     RestResponse response = userRestSession.get("/accounts/self/external.ids");
@@ -125,7 +124,7 @@
   }
 
   @Test
-  public void getExternalIdsOfOtherUserNotAllowed() {
+  public void getExternalIdsOfOtherUserNotAllowed() throws Exception {
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
         assertThrows(
@@ -140,7 +139,7 @@
         .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
         .update();
 
-    Collection<ExternalId> expectedIds = getAccountState(admin.id()).externalIds();
+    ImmutableSet<ExternalId> expectedIds = getAccountState(admin.id()).externalIds();
     List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
 
     RestResponse response = userRestSession.get("/accounts/" + admin.id() + "/external.ids");
@@ -510,11 +509,11 @@
     insertValidExternalIds();
     insertInvalidButParsableExternalIds();
 
-    Set<ExternalId> parseableExtIds = externalIds.all();
+    ImmutableSet<ExternalId> parseableExtIds = externalIds.all();
 
     insertNonParsableExternalIds();
 
-    Set<ExternalId> extIds = externalIds.all();
+    ImmutableSet<ExternalId> extIds = externalIds.all();
     assertThat(extIds).containsExactlyElementsIn(parseableExtIds);
 
     for (ExternalId parseableExtId : parseableExtIds) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 4477140..2e706b8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.RestSession;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.config.GerritConfig;
@@ -98,13 +97,11 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  private RestSession anonRestSession;
   private TestAccount admin2;
   private GroupInfo newGroup;
 
   @Before
   public void setUp() throws Exception {
-    anonRestSession = new RestSession(server, null);
     admin2 = accountCreator.admin2();
     GroupInput gi = new GroupInput();
     gi.name = name("New-Group");
@@ -759,7 +756,7 @@
   @Test
   public void runAsNeverPermittedForAnonymousUsers() throws Exception {
     allowRunAs();
-    RestResponse res = anonRestSession.getWithHeaders("/changes/", runAsHeader(user.id()));
+    RestResponse res = anonymousRestSession.getWithHeaders("/changes/", runAsHeader(user.id()));
     res.assertForbidden();
     assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
index 00b1c55..1951bdd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.acceptance.rest.auth;
 
+import static com.google.common.truth.Truth.assertThat;
+
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.RestSession;
+import java.io.BufferedReader;
+import java.util.stream.Collectors;
 import org.junit.Test;
 
 public class AuthenticationCheckIT extends AbstractDaemonTest {
@@ -29,8 +32,17 @@
 
   @Test
   public void authCheck_anonymousUser_returnsForbidden() throws Exception {
-    RestSession anonymous = new RestSession(server, null);
-    RestResponse r = anonymous.get("/auth-check");
+    RestResponse r = anonymousRestSession.get("/auth-check");
     r.assertForbidden();
   }
+
+  @Test
+  public void authCheckSvg_loggedInUser_returnsOk() throws Exception {
+    RestResponse r = adminRestSession.get("/auth-check.svg");
+    r.assertOK();
+    BufferedReader br = new BufferedReader(r.getReader());
+    String content = br.lines().collect(Collectors.joining());
+    assertThat(content).contains("<svg xmlns");
+    assertThat(r.getHeader("Content-Type")).isEqualTo("image/svg+xml;charset=utf-8");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 6a5441c..cc86d02 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -78,6 +78,7 @@
           RestCall.get("/changes/%s/meta_diff"),
           RestCall.post("/changes/%s/merge"),
           RestCall.get("/changes/%s/messages"),
+          RestCall.get("/changes/%s/message"),
           RestCall.put("/changes/%s/message"),
           RestCall.post("/changes/%s/move"),
           RestCall.post("/changes/%s/patch:apply"),
@@ -471,7 +472,10 @@
     RestApiCallHelper.execute(
         adminRestSession,
         CHANGE_EDIT_CREATE_ENDPOINTS,
-        () -> adminRestSession.delete("/changes/" + changeId + "/edit"),
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = adminRestSession.delete("/changes/" + changeId + "/edit");
+        },
         changeId,
         FILENAME);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
index 57279d3..6667421 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.rest.binding;
 
-import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
@@ -25,6 +25,7 @@
 import com.google.gerrit.acceptance.rest.util.RestCall;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
@@ -51,6 +52,7 @@
           RestCall.get("/config/server/capabilities"),
           RestCall.post("/config/server/check.consistency"),
           RestCall.put("/config/server/email.confirm"),
+          RestCall.get("/config/server/experiments"),
           RestCall.post("/config/server/index.changes"),
           RestCall.get("/config/server/info"),
           RestCall.get("/config/server/preferences"),
@@ -73,6 +75,13 @@
       ImmutableList.of(RestCall.get("/config/server/caches/%s"));
 
   /**
+   * Experiment REST endpoints to be tested, the URLs contain a placeholder for the experiment name.
+   * Since there is only a single supported config identifier ('server') it can be hard-coded.
+   */
+  private static final ImmutableList<RestCall> EXPERIMENT_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/config/server/experiments/%s"));
+
+  /**
    * Task REST endpoints to be tested, the URLs contain a placeholder for the task identifier. Since
    * there is only a single supported config identifier ('server') it can be hard-coded.
    */
@@ -102,6 +111,14 @@
   }
 
   @Test
+  public void experimentEndpoints() throws Exception {
+    RestApiCallHelper.execute(
+        adminRestSession,
+        EXPERIMENT_ENDPOINTS,
+        ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS);
+  }
+
+  @Test
   public void taskEndpoints() throws Exception {
     RestResponse r = adminRestSession.get("/config/server/tasks/");
     List<TaskInfo> result =
@@ -110,7 +127,7 @@
 
     Optional<String> id =
         result.stream()
-            .filter(t -> "Log File Compressor".equals(t.command))
+            .filter(t -> "Log File Manager".equals(t.command))
             .map(t -> t.id)
             .findFirst();
     assertThat(id).isPresent();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index ecae27e..25af040 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -17,7 +17,6 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
@@ -38,6 +37,7 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
+import com.github.rholder.retry.RetryException;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
@@ -90,6 +90,7 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
@@ -126,6 +127,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
@@ -137,6 +139,17 @@
     return submitWholeTopicEnabledConfig();
   }
 
+  @ConfigSuite.Config
+  public static Config mergeabilityCheckEnabled() {
+    Config cfg = new Config();
+    cfg.setEnum(
+        "change",
+        null,
+        "mergeabilityComputationBehavior",
+        MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX);
+    return cfg;
+  }
+
   @Inject private ApprovalsUtil approvalsUtil;
   @Inject private IdentifiedUser.GenericFactory userFactory;
   @Inject private ProjectOperations projectOperations;
@@ -147,8 +160,15 @@
 
   @Inject private ChangeIndexer changeIndex;
 
+  protected MergeabilityComputationBehavior mcb;
+
   protected abstract SubmitType getSubmitType();
 
+  @Before
+  public void setUp() {
+    mcb = MergeabilityComputationBehavior.fromConfig(cfg);
+  }
+
   @Test
   @TestProjectInput(createEmptyCommit = false)
   public void submitToEmptyRepo() throws Throwable {
@@ -668,7 +688,17 @@
 
     List<RevCommit> log = getRemoteLog();
     assertThat(log).contains(stable.getCommit());
-    assertThat(log).contains(mergeReview.getCommit());
+
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      // the merge commit has been rebased
+      RevCommit newHead = projectOperations.project(project).getHead("master");
+      assertThat(newHead.getParentCount()).isEqualTo(2);
+
+      assertThat(newHead.getParent(0).getId()).isEqualTo(master);
+      assertThat(newHead.getParent(1).getId()).isEqualTo(stable.getCommit());
+    } else {
+      assertThat(log).contains(mergeReview.getCommit());
+    }
   }
 
   @Test
@@ -712,7 +742,17 @@
 
     List<RevCommit> log = getRemoteLog();
     assertThat(log).contains(s1.getCommit());
-    assertThat(log).contains(mergeReview.getCommit());
+
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      // the merge commit has been rebased
+      RevCommit newHead = projectOperations.project(project).getHead("master");
+      assertThat(newHead.getParentCount()).isEqualTo(2);
+
+      assertThat(newHead.getParent(0).getId()).isEqualTo(m.getCommit());
+      assertThat(newHead.getParent(1).getId()).isEqualTo(s1.getCommit());
+    } else {
+      assertThat(log).contains(mergeReview.getCommit());
+    }
   }
 
   @Test
@@ -941,9 +981,18 @@
     assertMerged(mergeId);
     testRepo.git().fetch().call();
     RevWalk rw = testRepo.getRevWalk();
-    master = rw.parseCommit(projectOperations.project(project).getHead("master"));
-    assertThat(rw.isMergedInto(merge, master)).isTrue();
-    assertThat(rw.isMergedInto(fix, master)).isTrue();
+    RevCommit newMaster = rw.parseCommit(projectOperations.project(project).getHead("master"));
+    assertThat(rw.isMergedInto(fix, newMaster)).isTrue();
+
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      // the merge commit has been rebased
+      assertThat(newMaster.getParentCount()).isEqualTo(2);
+
+      assertThat(newMaster.getParent(0).getId()).isEqualTo(master);
+      assertThat(newMaster.getParent(1).getId()).isEqualTo(fix);
+    } else {
+      assertThat(rw.isMergedInto(merge, newMaster)).isTrue();
+    }
   }
 
   @Test
@@ -989,7 +1038,11 @@
     testMetricMaker.reset();
 
     Throwable thrown = assertThrows(StorageException.class, () -> submit(id, input));
-    assertThat(thrown.getCause()).hasMessageThat().contains("missing from ChangeSet[][]");
+    assertThat(thrown.getCause()).hasMessageThat().contains("Computing mergeSuperset has failed");
+    assertThat(thrown.getCause()).hasCauseThat().isInstanceOf(RetryException.class);
+    assertThat(thrown.getCause().getCause().getCause())
+        .hasMessageThat()
+        .contains("missing from ChangeSet[][]");
 
     // We retried more than once before giving up
     assertThat(
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 9d98ecb5..c47b3a4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
@@ -140,7 +141,9 @@
     approve(change2Result.getChangeId());
 
     // submit button is disabled.
-    assertSubmitDisabled(change2Result.getChangeId());
+    if (mcb != MergeabilityComputationBehavior.NEVER) {
+      assertSubmitDisabled(change2Result.getChangeId());
+    }
 
     submitWithConflict(
         change2Result.getChangeId(),
@@ -191,7 +194,9 @@
     approve(change2Result.getChangeId());
 
     // submit button is disabled.
-    assertSubmitDisabled(change2Result.getChangeId());
+    if (mcb != MergeabilityComputationBehavior.NEVER) {
+      assertSubmitDisabled(change2Result.getChangeId());
+    }
 
     submitWithConflict(
         change2Result.getChangeId(),
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index a30b5c4..c34625e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -36,6 +36,7 @@
 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.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
@@ -168,17 +169,74 @@
   }
 
   @Test
-  public void submitWithRebaseMergeCommit() throws Throwable {
+  public void submitMergeCommitThatDependsOnNormalChangeViaTheFirstParent() throws Throwable {
     /*
-       *  (HEAD, origin/master, origin/HEAD) Merge changes X,Y
-       |\
-       | *   Merge branch 'master' into origin/master
-       | |\
-       | | * SHA Added a
+         *  change2 (merge, rebased)
+         | \
+         *  \  change1 (rebased)
+         |   |
+         *   | change3 (new tip, rebased if 'Merge Always')
+         |   |
+         | * | change2 (merge)
+         | |\|
+         | | |
+         | * | change1
+          \|/
+           * initialHead
+    */
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
+
+    PushOneCommit change2Push =
+        pushFactory.create(admin.newIdent(), testRepo, "Merge to master", "m.txt", "");
+    change2Push.setParents(ImmutableList.of(change1.getCommit(), initialHead));
+    PushOneCommit.Result change2 = change2Push.to("refs/for/master");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change3 = createChange("New tip", "b.txt", "");
+
+    approve(change3.getChangeId());
+    submit(change3.getChangeId());
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+    submit(change2.getChangeId());
+
+    RevCommit newHead = projectOperations.project(project).getHead("master");
+    assertThat(newHead.getParentCount()).isEqualTo(2);
+
+    RevCommit headParent1 = parse(newHead.getParent(0).getId());
+    RevCommit headParent2 = parse(newHead.getParent(1).getId());
+
+    assertCurrentRevision(change1.getChangeId(), 2, headParent1.getId());
+    assertThat(headParent2.getId()).isEqualTo(initialHead.getId());
+
+    assertThat(headParent1.getParentCount()).isEqualTo(1);
+    RevCommit headGrandparent1 = parse(headParent1.getParent(0).getId());
+    if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
+      assertCurrentRevision(change3.getChangeId(), 2, headGrandparent1.getId());
+    } else {
+      assertThat(change3.getCommit().getId()).isEqualTo(headGrandparent1.getId());
+    }
+
+    assertThat(headGrandparent1.getParentCount()).isEqualTo(1);
+    assertThat(headGrandparent1.getParent(0).getId()).isEqualTo(initialHead.getId());
+  }
+
+  @Test
+  public void submitMergeCommitThatDependsOnNormalChangeViaTheSecondParent() throws Throwable {
+    /*
+       *  change2 (merge, rebased)
+       | \
+       *  \  change3 (new tip, rebased if 'Rebase Always')
+       |   |
+       | * | change2 (merge)
+       | |\|
+       | | * change1
        | |/
-       * | Before
+       | |
        |/
-       * Initial empty repository
+       * initialHead
     */
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 = createChange("Added a", "a.txt", "");
@@ -189,7 +247,7 @@
     PushOneCommit.Result change2 = change2Push.to("refs/for/master");
 
     testRepo.reset(initialHead);
-    PushOneCommit.Result change3 = createChange("Before", "b.txt", "");
+    PushOneCommit.Result change3 = createChange("New tip", "b.txt", "");
 
     approve(change3.getChangeId());
     submit(change3.getChangeId());
@@ -212,14 +270,12 @@
     assertThat(headParent1.getParentCount()).isEqualTo(1);
     assertThat(headParent1.getParent(0)).isEqualTo(initialHead);
 
-    assertThat(headParent2.getId()).isEqualTo(change2.getCommit().getId());
-    assertThat(headParent2.getParentCount()).isEqualTo(2);
+    assertThat(headParent2.getId()).isEqualTo(change1.getCommit().getId());
+    assertThat(headParent2.getParentCount()).isEqualTo(1);
 
-    RevCommit headGrandparent1 = parse(headParent2.getParent(0).getId());
-    RevCommit headGrandparent2 = parse(headParent2.getParent(1).getId());
+    RevCommit headGrandparent = parse(headParent2.getParent(0).getId());
 
-    assertThat(headGrandparent1.getId()).isEqualTo(initialHead.getId());
-    assertThat(headGrandparent2.getId()).isEqualTo(change1.getCommit().getId());
+    assertThat(headGrandparent.getId()).isEqualTo(initialHead.getId());
   }
 
   @Test
@@ -410,7 +466,9 @@
     approve(change2Result.getChangeId());
 
     // submit button is disabled.
-    assertSubmitDisabled(change2Result.getChangeId());
+    if (mcb != MergeabilityComputationBehavior.NEVER) {
+      assertSubmitDisabled(change2Result.getChangeId());
+    }
 
     submitWithConflict(
         change2Result.getChangeId(),
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index fbcc5fa..78d6737 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.change.RevisionJson;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
@@ -42,6 +43,7 @@
 import java.util.Set;
 import java.util.TreeSet;
 import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
 import org.junit.Test;
 
 public class ActionsIT extends AbstractDaemonTest {
@@ -50,9 +52,22 @@
     return submitWholeTopicEnabledConfig();
   }
 
+  @ConfigSuite.Config
+  public static Config mergeabilityCheckEnabled() {
+    Config cfg = new Config();
+    cfg.setEnum(
+        "change",
+        null,
+        "mergeabilityComputationBehavior",
+        MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX);
+    return cfg;
+  }
+
   @Inject private RevisionJson.Factory revisionJsonFactory;
   @Inject private ExtensionRegistry extensionRegistry;
 
+  private MergeabilityComputationBehavior mcb;
+
   protected Map<String, ActionInfo> getActions(String id) throws Exception {
     return gApi.changes().id(id).revision(1).actions();
   }
@@ -61,6 +76,11 @@
     return gApi.changes().id(id).get().actions;
   }
 
+  @Before
+  public void setUp() {
+    mcb = MergeabilityComputationBehavior.fromConfig(cfg);
+  }
+
   @Test
   public void changeActionOneMergedChangeHasOnlyNormalRevert() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
@@ -160,10 +180,12 @@
     commonActionsAssertions(actions);
     if (isSubmitWholeTopicEnabled()) {
       ActionInfo info = actions.get("submit");
-      assertThat(info.enabled).isNull();
+      if (mcb != MergeabilityComputationBehavior.NEVER) {
+        assertThat(info.enabled).isNull();
+        assertThat(info.title).isEqualTo("Problems with change(s): " + changeNum2);
+      }
       assertThat(info.label).isEqualTo("Submit whole topic");
       assertThat(info.method).isEqualTo("POST");
-      assertThat(info.title).isEqualTo("Problems with change(s): " + changeNum2);
     } else {
       noSubmitWholeTopicAssertions(actions, 1);
     }
@@ -323,7 +345,11 @@
 
       // ...via ChangeJson directly.
       ChangeData cd = changeDataFactory.create(project, changeId);
-      revisionJsonFactory.create(opts).getRevisionInfo(cd, cd.patchSet(PatchSet.id(changeId, 1)));
+      revisionInfo =
+          revisionJsonFactory
+              .create(opts)
+              .getRevisionInfo(cd, cd.patchSet(PatchSet.id(changeId, revisionInfo._number)));
+      visitedCurrentRevisionActionsAssertions(origActions, revisionInfo.actions);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index cbc3f9d..c7672d1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
@@ -138,7 +139,7 @@
   }
 
   @Test
-  public void addUser() throws Exception {
+  public void addUser_updateReason() throws Exception {
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
     int accountId =
@@ -149,19 +150,62 @@
             fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.ADD, "first");
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
 
-    // Second add is ignored.
-    accountId =
-        change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "second"))._accountId;
-    assertThat(accountId).isEqualTo(admin.id().get());
-    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
-
-    // Only one email since the second add was ignored.
+    // Check that email was sent
     String emailBody = Iterables.getOnlyElement(sender.getMessages()).body();
     assertThat(emailBody)
         .contains(
             String.format(
                 "%s requires the attention of %s to this change.\n The reason is: first.",
                 user.fullName(), admin.fullName()));
+
+    // Update the reason
+    sender.clear();
+    accountId =
+        change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "second"))._accountId;
+    assertThat(accountId).isEqualTo(admin.id().get());
+    expectedAttentionSetUpdate =
+        AttentionSetUpdate.createFromRead(
+            fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.ADD, "second");
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+    // Check that email was sent
+    emailBody = Iterables.getOnlyElement(sender.getMessages()).body();
+    assertThat(emailBody)
+        .contains(
+            String.format(
+                "%s requires the attention of %s to this change.\n The reason is: second.",
+                user.fullName(), admin.fullName()));
+  }
+
+  @Test
+  public void addUser_addWithSameReasonIsIgnored() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    int accountId =
+        change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"))._accountId;
+    assertThat(accountId).isEqualTo(admin.id().get());
+    AttentionSetUpdate expectedAttentionSetUpdate =
+        AttentionSetUpdate.createFromRead(
+            fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.ADD, "reason");
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+    // Check that email was sent
+    String emailBody = Iterables.getOnlyElement(sender.getMessages()).body();
+    assertThat(emailBody)
+        .contains(
+            String.format(
+                "%s requires the attention of %s to this change.\n The reason is: reason.",
+                user.fullName(), admin.fullName()));
+
+    // Second add with the same reason is ignored.
+    sender.clear();
+    accountId =
+        change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"))._accountId;
+    assertThat(accountId).isEqualTo(admin.id().get());
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+    // Check that no email was sent
+    assertThat(sender.getMessages()).isEmpty();
   }
 
   @Test
@@ -987,14 +1031,13 @@
   }
 
   @Test
-  public void repliesAddsOwner() throws Exception {
+  public void repliesAddsOwner_voteAdded() throws Exception {
     PushOneCommit.Result r = createChange();
 
     requestScopeOperations.setApiUser(user.id());
+    change(r).current().review(ReviewInput.dislike());
 
-    ReviewInput reviewInput = new ReviewInput();
-    change(r).current().review(reviewInput);
-
+    // The change owner has been added to the attention set
     AttentionSetUpdate attentionSet =
         Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
@@ -1003,6 +1046,224 @@
   }
 
   @Test
+  public void repliesAddsOwner_voteCopied() throws Exception {
+    // Define a label with a copy condition that copies all votes.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder fooReview =
+          labelBuilder(
+                  "Foo-Review",
+                  value(1, "Looks good to me, but someone else must approve"),
+                  value(0, "No score"),
+                  value(-1, "I would prefer this is not submitted as is"))
+              .setCopyCondition("is:ANY");
+      u.getConfig().upsertLabelType(fooReview.build());
+      u.save();
+    }
+
+    // Allow voting on the label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Foo-Review")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Create a change with 2 patch sets.
+    PushOneCommit.Result r = createChange();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+    r = amendChange(r.getChangeId());
+    r.assertOkStatus();
+
+    // Apply a negative vote on the first patch set which is copied since the label has a copy
+    // condition that copies all votes
+    requestScopeOperations.setApiUser(user.id());
+    change(r).revision(patchSet1.number()).review(new ReviewInput().label("Foo-Review", -1));
+
+    // The change owner has been added to the attention set
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+  }
+
+  @Test
+  public void repliesDoesntAddOwner_voteNotCopied_userNotRemovedReasonUpdated() throws Exception {
+    // Define a label without any copy condition.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder fooReview =
+          labelBuilder(
+              "Foo-Review",
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"));
+      u.getConfig().upsertLabelType(fooReview.build());
+      u.save();
+    }
+
+    // Allow voting on the label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Foo-Review")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Create a change with 2 patch sets.
+    PushOneCommit.Result r = createChange();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+    r = amendChange(r.getChangeId());
+    r.assertOkStatus();
+
+    // Add user as a reviewer so that the user is in the attention set
+    change(r).addReviewer(user.email());
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isNotEmpty();
+
+    // Apply a negative vote on the first patch set which is not copied since the label doesn't have
+    // a copy condition
+    requestScopeOperations.setApiUser(user.id());
+    change(r).revision(patchSet1.number()).review(new ReviewInput().label("Foo-Review", -1));
+
+    // The change owner has not been added to the attention set
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+
+    // The reason for the user to be in the attention set has been updated
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("Some votes were not copied to the current patch set");
+  }
+
+  @Test
+  public void repliesDoesntAddOwner_voteNotCopied_userAdded() throws Exception {
+    // Define a label without any copy condition.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder fooReview =
+          labelBuilder(
+              "Foo-Review",
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"));
+      u.getConfig().upsertLabelType(fooReview.build());
+      u.save();
+    }
+
+    // Allow voting on the label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Foo-Review")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Create a change with 2 patch sets.
+    PushOneCommit.Result r = createChange();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+    r = amendChange(r.getChangeId());
+    r.assertOkStatus();
+
+    // User is not in the attention set yet
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+
+    // Apply a negative vote on the first patch set which is not copied since the label doesn't have
+    // a copy condition
+    requestScopeOperations.setApiUser(user.id());
+    change(r).revision(patchSet1.number()).review(new ReviewInput().label("Foo-Review", -1));
+
+    // The change owner has not been added to the attention set
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+
+    // The user has been added to the attention set because the vote was not copied
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("Some votes were not copied to the current patch set");
+  }
+
+  @Test
+  public void repliesAddsOwner_changeMessagePosted() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.message = "A message";
+    change(r).current().review(reviewInput);
+
+    // The change owner has been added to the attention set
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+  }
+
+  @Test
+  public void repliesAddsOwner_commentAdded() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput reviewInput = new ReviewInput();
+    ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+    comment.side = Side.REVISION;
+    comment.path = Patch.COMMIT_MSG;
+    comment.message = "comment";
+    reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
+    change(r).current().review(reviewInput);
+
+    // The change owner has been added to the attention set
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+  }
+
+  @Test
+  public void repliesAddsOwner_markedAsReady() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+
+    requestScopeOperations.setApiUser(accountCreator.admin2().id());
+    ReviewInput reviewInput = ReviewInput.create().setReady(true);
+    change(r).current().review(reviewInput);
+
+    // The change owner has been added to the attention set
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+  }
+
+  @Test
+  public void noOpRepliesDontAddOwner() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // post a no-op reply (a reply that does neither add any vote, change message, comment nor
+    // changes the change to ready)
+    requestScopeOperations.setApiUser(user.id());
+    change(r).current().review(new ReviewInput());
+
+    // The change owner has not been added to the attention set
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+  }
+
+  @Test
   public void robotRepliesDoNotAddToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
     change(r).addReviewer(user.email());
@@ -1076,18 +1337,16 @@
 
   @Test
   public void repliesAddsOwnerAndUploader() throws Exception {
-    // Create change with owner: admin
+    // Create change with admin as the owner and user as the uploader
     PushOneCommit.Result r = createChange();
     r = amendChangeWithUploader(r, project, user);
+    change(r).attention(user.email()).remove(new AttentionSetInput("reason"));
 
+    // Post a comment by another user
     TestAccount user2 = accountCreator.user2();
     requestScopeOperations.setApiUser(user2.id());
-
-    change(r).attention(user.email()).remove(new AttentionSetInput("reason"));
     ReviewInput reviewInput = new ReviewInput();
-    change(r).current().review(reviewInput);
-
-    reviewInput = new ReviewInput();
+    reviewInput.message = "A message";
     change(r).current().review(reviewInput);
 
     // Uploader added
@@ -1105,6 +1364,25 @@
   }
 
   @Test
+  public void noOpRepliesDontAddOwnerAndUploader() throws Exception {
+    // Create change with admin as the owner and user as the uploader
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    change(r).attention(user.email()).remove(new AttentionSetInput("reason"));
+
+    // Post a no-op reply by another user (a reply that does neither add any vote, change message,
+    // comment nor changes the change to ready)
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    // Neither the change owner nor the uploader have been added to the attention set
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
   public void reviewIgnoresRobotCommentsForAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
@@ -1339,7 +1617,9 @@
 
     requestScopeOperations.setApiUser(user.id());
 
-    change(r).current().review(new ReviewInput());
+    reviewInput = new ReviewInput();
+    reviewInput.message = "A message";
+    change(r).current().review(reviewInput);
 
     // Reviewer and CC not added since the uploader didn't reply to their comments
     assertThat(getAttentionSetUpdatesForUser(r, cc)).isEmpty();
@@ -1530,6 +1810,99 @@
   }
 
   @Test
+  public void robotReviewWithNegativeLabelOnOutdatedPatchSetAddsOwnerIfVoteWasCopied()
+      throws Exception {
+    // Define a label with a copy condition that copies all votes.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder fooReview =
+          labelBuilder(
+                  "Foo-Review",
+                  value(1, "Looks good to me, but someone else must approve"),
+                  value(0, "No score"),
+                  value(-1, "I would prefer this is not submitted as is"))
+              .setCopyCondition("is:ANY");
+      u.getConfig().upsertLabelType(fooReview.build());
+      u.save();
+    }
+
+    // Allow voting on the label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Foo-Review")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Create a change with 2 patch sets.
+    PushOneCommit.Result r = createChange();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+    r = amendChange(r.getChangeId());
+    r.assertOkStatus();
+
+    // Create a service user and apply a negative vote on the first patch set which is copied since
+    // the label has a copy condition that copies all votes
+    TestAccount robot =
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).revision(patchSet1.number()).review(new ReviewInput().label("Foo-Review", -1));
+
+    // The change owner has been added to the attention set
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("A robot voted negatively on a label");
+  }
+
+  @Test
+  public void robotReviewWithNegativeLabelOnOutdatedPatchSetDoesntAddOwnerIfVoteWasNotCopied()
+      throws Exception {
+    // Define a label without any copy condition.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder fooReview =
+          labelBuilder(
+              "Foo-Review",
+              value(1, "Looks good to me, but someone else must approve"),
+              value(0, "No score"),
+              value(-1, "I would prefer this is not submitted as is"));
+      u.getConfig().upsertLabelType(fooReview.build());
+      u.save();
+    }
+
+    // Allow voting on the label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Foo-Review")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Create a change with 2 patch sets.
+    PushOneCommit.Result r = createChange();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+    r = amendChange(r.getChangeId());
+    r.assertOkStatus();
+
+    // Create a service user and apply a negative vote on the first patch set which is not copied
+    // since the label doesn't have a copy condition
+    TestAccount robot =
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).revision(patchSet1.number()).review(new ReviewInput().label("Foo-Review", -1));
+
+    // The change owner has not been added to the attention set
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+  }
+
+  @Test
   public void robotReviewWithNegativeLabelDoesntAddOwnerIfChangeIsMerged() throws Exception {
     TestAccount robot =
         accountCreator.create(
@@ -1835,7 +2208,8 @@
     setEmailStrategyForUser(EmailStrategy.ATTENTION_SET_ONLY);
 
     // Ensure emails that don't relate to changes are still sent.
-    gApi.accounts().id(user.id().get()).generateHttpPassword();
+    @SuppressWarnings("unused")
+    var unused = gApi.accounts().id(user.id().get()).generateHttpPassword();
     assertThat(sender.getMessages()).isNotEmpty();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
index dd85cb0..0dd2c46 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
@@ -19,20 +19,38 @@
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.server.change.FilterIncludedIn;
 import com.google.inject.Inject;
+import java.util.function.Predicate;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 @NoHttpd
 public class ChangeIncludedInIT extends AbstractDaemonTest {
 
   @Inject private ProjectOperations projectOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  private static class TestFilter implements FilterIncludedIn {
+    @Override
+    public Predicate<String> getBranchFilter(Project.NameKey project, RevCommit commit) {
+      return branch -> !branch.startsWith("t");
+    }
+
+    @Override
+    public Predicate<String> getTagFilter(Project.NameKey project, RevCommit commit) {
+      return tag -> !tag.startsWith("bad");
+    }
+  }
 
   @Test
   public void includedInOpenChange() throws Exception {
@@ -41,8 +59,7 @@
     assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags).isEmpty();
   }
 
-  @Test
-  public void includedInMergedChange() throws Exception {
+  private String baseTestCase() throws Exception {
     Result result = createChange();
     gApi.changes()
         .id(result.getChangeId())
@@ -61,12 +78,30 @@
         .update();
     gApi.projects().name(project.get()).tag("test-tag").create(new TagInput());
 
-    assertThat(gApi.changes().id(result.getChangeId()).includedIn().tags)
-        .containsExactly("test-tag");
-
     createBranch(BranchNameKey.create(project, "test-branch"));
+    return result.getChangeId();
+  }
 
-    assertThat(gApi.changes().id(result.getChangeId()).includedIn().branches)
+  @Test
+  public void includedInMergedChange() throws Exception {
+    String changeId = baseTestCase();
+    assertThat(gApi.changes().id(changeId).includedIn().tags).containsExactly("test-tag");
+
+    assertThat(gApi.changes().id(changeId).includedIn().branches)
         .containsExactly("master", "test-branch");
   }
+
+  @Test
+  public void includedInFiltered() throws Exception {
+    String changeId = baseTestCase();
+    gApi.projects().name(project.get()).tag("bad-tag").create(new TagInput());
+    assertThat(gApi.changes().id(changeId).includedIn().tags)
+        .containsExactly("bad-tag", "test-tag");
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(new TestFilter())) {
+      assertThat(gApi.changes().id(changeId).includedIn().tags).containsExactly("test-tag");
+
+      assertThat(gApi.changes().id(changeId).includedIn().branches).containsExactly("master");
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index a6f3917..32b9010 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
@@ -53,7 +52,6 @@
 import com.google.inject.Inject;
 import java.nio.charset.Charset;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Optional;
@@ -180,7 +178,7 @@
         messageTemplate,
         Iterables.getLast(gApi.changes().id(changeId).get(MESSAGES).messages).message);
 
-    Collection<ChangeMessageInfo> listMessages = gApi.changes().id(changeId).messages();
+    List<ChangeMessageInfo> listMessages = gApi.changes().id(changeId).messages();
     assertThat(listMessages).hasSize(2);
     ChangeMessageInfo changeMessageApi = Iterables.getLast(gApi.changes().id(changeId).messages());
     assertMessage("Review by " + admin.getNameEmail(), changeMessageApi.message);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index 6a748a5..009ec08 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -20,7 +20,6 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
@@ -32,6 +31,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -118,11 +118,8 @@
   }
 
   private void approve(TestAccount a, String changeId) throws Exception {
-    Context old = requestScopeOperations.setApiUser(a.id());
-    try {
+    try (ManualRequestContext newCtx = requestScopeOperations.setNestedApiUser(a.id())) {
       gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    } finally {
-      atrScope.set(old);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index 70a3cf2..ca3a345 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -16,6 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -34,10 +37,12 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import java.lang.reflect.Type;
+import java.util.Iterator;
 import java.util.List;
 import org.junit.Before;
 import org.junit.Test;
@@ -54,7 +59,7 @@
 
   @Test
   public void addByEmail() throws Exception {
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@example.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
@@ -72,8 +77,49 @@
   }
 
   @Test
+  public void addByEmailToReviewerUpdateInfo() throws Exception {
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@example.com");
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ReviewerInput input = new ReviewerInput();
+    input.reviewer = toRfcAddressString(acc);
+    input.state = CC;
+    gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+    input.state = REVIEWER;
+    gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+    adminRestSession.delete("/changes/" + changeId + "/reviewers/" + acc.email).assertNoContent();
+
+    ChangeInfo c = gApi.changes().id(changeId).get();
+    assertThat(c.reviewerUpdates).isNotNull();
+    assertThat(c.reviewerUpdates).hasSize(3);
+
+    Iterator<ReviewerUpdateInfo> it = c.reviewerUpdates.iterator();
+    ReviewerUpdateInfo reviewerUpdateInfo = it.next();
+    assertThat(reviewerUpdateInfo.state).isEqualTo(CC);
+    assertThat(reviewerUpdateInfo.reviewer._accountId).isNull();
+    assertThat(reviewerUpdateInfo.reviewer.email).isEqualTo(acc.email);
+    assertThat(reviewerUpdateInfo.updatedBy._accountId).isEqualTo(admin.id().get());
+
+    reviewerUpdateInfo = it.next();
+    assertThat(reviewerUpdateInfo.state).isEqualTo(REVIEWER);
+    assertThat(reviewerUpdateInfo.reviewer._accountId).isNull();
+    assertThat(reviewerUpdateInfo.reviewer.email).isEqualTo(acc.email);
+    assertThat(reviewerUpdateInfo.updatedBy._accountId).isEqualTo(admin.id().get());
+
+    reviewerUpdateInfo = it.next();
+    assertThat(reviewerUpdateInfo.state).isEqualTo(REMOVED);
+    assertThat(reviewerUpdateInfo.reviewer._accountId).isNull();
+    assertThat(reviewerUpdateInfo.reviewer.email).isEqualTo(acc.email);
+    assertThat(reviewerUpdateInfo.updatedBy._accountId).isEqualTo(admin.id().get());
+  }
+
+  @Test
   public void addByEmailAndById() throws Exception {
-    AccountInfo byEmail = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo byEmail = new AccountInfo("Foo Bar", "foo.bar@example.com");
     AccountInfo byId = new AccountInfo(user.id().get());
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
@@ -98,7 +144,7 @@
 
   @Test
   public void listReviewersByEmail() throws Exception {
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@example.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
@@ -125,7 +171,7 @@
 
   @Test
   public void removeByEmail() throws Exception {
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@example.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
@@ -144,7 +190,7 @@
 
   @Test
   public void convertFromCCToReviewer() throws Exception {
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@example.com");
 
     PushOneCommit.Result r = createChange();
 
@@ -165,7 +211,7 @@
 
   @Test
   public void addedReviewersGetNotified() throws Exception {
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@example.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
@@ -175,7 +221,7 @@
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
 
-      List<Message> messages = sender.getMessages();
+      ImmutableList<Message> messages = sender.getMessages();
       assertThat(messages).hasSize(1);
       assertThat(messages.get(0).rcpt()).containsExactly(Address.parse(input.reviewer));
       sender.clear();
@@ -184,7 +230,7 @@
 
   @Test
   public void removingReviewerTriggersNotification() throws Exception {
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@example.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
@@ -206,7 +252,7 @@
       // Delete as admin
       gApi.changes().id(r.getChangeId()).reviewer(addInput.reviewer).remove();
 
-      List<Message> messages = sender.getMessages();
+      ImmutableList<Message> messages = sender.getMessages();
       assertThat(messages).hasSize(1);
       assertThat(messages.get(0).rcpt())
           .containsExactly(Address.parse(addInput.reviewer), user.getNameEmail());
@@ -216,7 +262,7 @@
 
   @Test
   public void reviewerAndCCReceiveRegularNotification() throws Exception {
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@example.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
@@ -242,7 +288,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       for (int i = 0; i < 10; i++) {
         ReviewerInput input = new ReviewerInput();
-        input.reviewer = String.format("%s-%s@gerritcodereview.com", state, i);
+        input.reviewer = String.format("%s-%s@example.com", state, i);
         input.state = state;
         gApi.changes().id(r.getChangeId()).addReviewer(input);
       }
@@ -266,7 +312,7 @@
     ReviewInput reviewInput = new ReviewInput();
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       for (int i = 0; i < 10; i++) {
-        reviewInput.reviewer(String.format("%s-%s@gerritcodereview.com", state, i), state, true);
+        reviewInput.reviewer(String.format("%s-%s@example.com", state, i), state, true);
       }
     }
     assertThat(reviewInput.reviewers).hasSize(20);
@@ -277,11 +323,19 @@
   }
 
   @Test
-  public void rejectMissingEmail() throws Exception {
+  public void rejectIfReviewerUserIdentifierIsMissing() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("");
-    assertThat(result.error).isEqualTo(" is not a valid user identifier");
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer((String) null);
+    assertThat(result.error).isEqualTo("reviewer user identifier is required");
+    assertThat(result.reviewers).isNull();
+
+    result = gApi.changes().id(r.getChangeId()).addReviewer("");
+    assertThat(result.error).isEqualTo("reviewer user identifier is required");
+    assertThat(result.reviewers).isNull();
+
+    result = gApi.changes().id(r.getChangeId()).addReviewer("   ");
+    assertThat(result.error).isEqualTo("reviewer user identifier is required");
     assertThat(result.reviewers).isNull();
   }
 
@@ -303,18 +357,18 @@
     PushOneCommit.Result r = createChange();
 
     ReviewerResult result =
-        gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
+        gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@example.com>");
     assertThat(result.error)
         .isEqualTo(
-            "Account 'Foo Bar <foo.bar@gerritcodereview.com>' not found\n"
-                + "Foo Bar <foo.bar@gerritcodereview.com> does not identify a registered user or"
+            "Account 'Foo Bar <foo.bar@example.com>' not found\n"
+                + "Foo Bar <foo.bar@example.com> does not identify a registered user or"
                 + " group");
     assertThat(result.reviewers).isNull();
   }
 
   @Test
   public void reviewersByEmailAreServedFromIndex() throws Exception {
-    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@example.com");
 
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 6fc9b2b..cf88b5a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -154,7 +154,7 @@
     assertReviewers(c, CC, user);
 
     // Verify email was sent to CCed account.
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
@@ -232,7 +232,7 @@
     assertReviewers(c, CC, firstUsers);
 
     // Verify emails were sent to each of the group's accounts.
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     List<Address> expectedAddresses = new ArrayList<>(firstUsers.size());
@@ -444,7 +444,7 @@
     assertReviewers(c, CC, observer);
 
     // Verify emails were sent to added reviewers.
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(2);
 
     Message m = messages.get(0);
@@ -540,7 +540,7 @@
   }
 
   @Test
-  public void addReviewerToReviewerChangeInfo() throws Exception {
+  public void addReviewerToReviewerUpdateInfo() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     ReviewerInput in = new ReviewerInput();
@@ -564,20 +564,20 @@
     assertThat(c.reviewerUpdates).hasSize(3);
 
     Iterator<ReviewerUpdateInfo> it = c.reviewerUpdates.iterator();
-    ReviewerUpdateInfo reviewerChange = it.next();
-    assertThat(reviewerChange.state).isEqualTo(CC);
-    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.id().get());
-    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.id().get());
+    ReviewerUpdateInfo reviewerUpdateInfo = it.next();
+    assertThat(reviewerUpdateInfo.state).isEqualTo(CC);
+    assertThat(reviewerUpdateInfo.reviewer._accountId).isEqualTo(user.id().get());
+    assertThat(reviewerUpdateInfo.updatedBy._accountId).isEqualTo(admin.id().get());
 
-    reviewerChange = it.next();
-    assertThat(reviewerChange.state).isEqualTo(REVIEWER);
-    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.id().get());
-    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.id().get());
+    reviewerUpdateInfo = it.next();
+    assertThat(reviewerUpdateInfo.state).isEqualTo(REVIEWER);
+    assertThat(reviewerUpdateInfo.reviewer._accountId).isEqualTo(user.id().get());
+    assertThat(reviewerUpdateInfo.updatedBy._accountId).isEqualTo(admin.id().get());
 
-    reviewerChange = it.next();
-    assertThat(reviewerChange.state).isEqualTo(REMOVED);
-    assertThat(reviewerChange.reviewer._accountId).isEqualTo(user.id().get());
-    assertThat(reviewerChange.updatedBy._accountId).isEqualTo(admin.id().get());
+    reviewerUpdateInfo = it.next();
+    assertThat(reviewerUpdateInfo.state).isEqualTo(REMOVED);
+    assertThat(reviewerUpdateInfo.reviewer._accountId).isEqualTo(user.id().get());
+    assertThat(reviewerUpdateInfo.updatedBy._accountId).isEqualTo(admin.id().get());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index f8eccb5..698eac8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -51,6 +51,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.converter.ChangeInputProtoConverter;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -82,14 +83,17 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.restapi.change.ApplyPatchUtil;
+import com.google.gerrit.server.restapi.change.CreateChange;
+import com.google.gerrit.server.restapi.change.CreateChange.CommitTreeSupplier;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Map;
 import java.util.concurrent.Callable;
 import java.util.concurrent.CyclicBarrier;
 import java.util.concurrent.ExecutorService;
@@ -111,9 +115,13 @@
 
 @UseClockStep
 public class CreateChangeIT extends AbstractDaemonTest {
+  private static final ChangeInputProtoConverter CHANGE_INPUT_PROTO_CONVERTER =
+      ChangeInputProtoConverter.INSTANCE;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private CreateChange createChangeImpl;
+  @Inject private BatchUpdate.Factory updateFactory;;
 
   @Before
   public void addNonCommitHead() throws Exception {
@@ -371,7 +379,7 @@
     requestScopeOperations.setApiUser(admin.id());
     assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
 
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
@@ -564,7 +572,7 @@
 
   @Test
   public void createChangeWithParentCommit() throws Exception {
-    Map<String, PushOneCommit.Result> setup =
+    ImmutableMap<String, PushOneCommit.Result> setup =
         changeInTwoBranches("foo", "foo.txt", "bar", "bar.txt");
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
     input.baseCommit = setup.get("master").getCommit().getId().name();
@@ -603,7 +611,7 @@
 
   @Test
   public void createChangeWithParentCommitOnWrongBranchFails() throws Exception {
-    Map<String, PushOneCommit.Result> setup =
+    ImmutableMap<String, PushOneCommit.Result> setup =
         changeInTwoBranches("foo", "foo.txt", "bar", "bar.txt");
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
     input.branch = "foo";
@@ -638,7 +646,7 @@
 
   @Test
   public void createChangeWithoutAccessToParentCommitFails() throws Exception {
-    Map<String, PushOneCommit.Result> results =
+    ImmutableMap<String, PushOneCommit.Result> results =
         changeInTwoBranches("invisible-branch", "a.txt", "visible-branch", "b.txt");
     projectOperations
         .project(project)
@@ -1158,6 +1166,26 @@
   }
 
   @Test
+  public void createChangeWithCommitTreeSupplier_success() throws Exception {
+    createBranch(BranchNameKey.create(project, "other"));
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.branch = "other";
+    input.subject = "custom commit message";
+    ApplyPatchInput applyPatchInput = new ApplyPatchInput();
+    applyPatchInput.patch = PATCH_INPUT;
+    CommitTreeSupplier commitTreeSupplier =
+        (repo, oi, in, mergeTip) ->
+            ApplyPatchUtil.applyPatch(repo, oi, applyPatchInput, mergeTip).getTreeId();
+
+    ChangeInfo info = assertCreateWithCommitTreeSupplierSucceeds(input, commitTreeSupplier);
+
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+    assertThat(info.revisions.get(info.currentRevision).commit.message)
+        .isEqualTo("custom commit message\n\nChange-Id: " + info.changeId + "\n");
+  }
+
+  @Test
   @UseSystemTime
   public void sha1sOfTwoNewChangesDiffer() throws Exception {
     ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
@@ -1352,6 +1380,18 @@
     return out;
   }
 
+  private ChangeInfo assertCreateWithCommitTreeSupplierSucceeds(
+      ChangeInput input, CommitTreeSupplier commitTreeSupplier) throws Exception {
+    ChangeInfo res =
+        createChangeImpl
+            .execute(updateFactory, CHANGE_INPUT_PROTO_CONVERTER.toProto(input), commitTreeSupplier)
+            .value();
+    // The original result doesn't contain any revision data.
+    ChangeInfo out = gApi.changes().id(res.changeId).get(ALL_REVISIONS, CURRENT_COMMIT);
+    validateCreateSucceeds(input, out);
+    return out;
+  }
+
   private static <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
     try (JsonReader jsonReader = new JsonReader(r.getReader())) {
       return newGson().fromJson(jsonReader, clazz);
@@ -1470,7 +1510,7 @@
    * @param fileB name of file to commit to branchB
    * @return A {@code Map} of branchName => commit result.
    */
-  private Map<String, Result> changeInTwoBranches(
+  private ImmutableMap<String, Result> changeInTwoBranches(
       String branchA, String fileA, String branchB, String fileB) throws Exception {
     return changeInTwoBranches(
         branchA, "change A", fileA, "A content", branchB, "change B", fileB, "B content");
@@ -1489,7 +1529,7 @@
    * @param contentB file content to commit to branchB
    * @return A {@code Map} of branchName => commit result.
    */
-  private Map<String, Result> changeInTwoBranches(
+  private ImmutableMap<String, Result> changeInTwoBranches(
       String branchA,
       String subjectA,
       String fileA,
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index 6491202..b1e8ba1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -42,7 +43,6 @@
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import java.util.Collection;
-import java.util.List;
 import java.util.Map;
 import org.junit.Test;
 
@@ -192,7 +192,7 @@
     RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
     response.assertNoContent();
 
-    List<FakeEmailSender.Message> messages = sender.getMessages();
+    ImmutableList<FakeEmailSender.Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     FakeEmailSender.Message msg = messages.get(0);
     assertThat(msg.rcpt()).containsExactly(admin.getNameEmail(), user2.getNameEmail());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index d5c7610..7cc72af 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -333,7 +333,7 @@
     assertLabelVote(user, changeId, testLabelA, (short) 1);
 
     requestScopeOperations.setApiUser(admin.id());
-    assertThat(atrScope.get().getUser().getAccountId()).isEqualTo(admin.id());
+    assertThat(localCtx.getContext().getUser().getAccountId()).isEqualTo(admin.id());
 
     // Move the change to the destination branch.
     assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
@@ -396,7 +396,7 @@
     assertLabelVote(user, changeId, testLabelA, (short) 2);
 
     requestScopeOperations.setApiUser(admin.id());
-    assertThat(atrScope.get().getUser().getAccountId()).isEqualTo(admin.id());
+    assertThat(localCtx.getContext().getUser().getAccountId()).isEqualTo(admin.id());
 
     // Move the change to the destination branch.
     assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index a60f757..1d8e0b8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.inject.Inject;
 import java.util.Map;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -157,7 +158,9 @@
 
     assertThat(actions).containsKey("submit");
     ActionInfo info = actions.get("submit");
-    assertThat(info.enabled).isNull();
+    if (mcb != MergeabilityComputationBehavior.NEVER) {
+      assertThat(info.enabled).isNull();
+    }
 
     submitWithConflict(
         change2.getChangeId(),
@@ -226,8 +229,10 @@
     approve(changeResult.getChangeId());
     approve(change2Result.getChangeId());
 
-    // submit button is disabled.
-    assertSubmitDisabled(change2Result.getChangeId());
+    if (mcb != MergeabilityComputationBehavior.NEVER) {
+      // submit button is disabled.
+      assertSubmitDisabled(change2Result.getChangeId());
+    }
 
     submitWithConflict(
         change2Result.getChangeId(),
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index ac3622f..dccc057 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -26,6 +26,7 @@
 
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
@@ -42,6 +43,7 @@
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
@@ -280,6 +282,10 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.disabled",
+      // The test intentionally create an implicit merge change.
+      value = "GerritBackendFeature__reject_implicit_merges_on_merge")
   public void submitWithMergedAncestorsOnOtherBranch() throws Throwable {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
 
@@ -329,6 +335,10 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.disabled",
+      // The test intentionally create an implicit merge change.
+      value = "GerritBackendFeature__reject_implicit_merges_on_merge")
   public void submitWithOpenAncestorsOnOtherBranch() throws Throwable {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change1 =
@@ -549,19 +559,27 @@
     PushOneCommit.Result changeResult = change.to("refs/for/master");
     approve(changeResult.getChangeId());
 
-    // Create a successor change.
+    // Create a destination branch that later will be made non-visible to user.
+    BranchNameKey secretBranch = BranchNameKey.create(project, "secretBranch");
+    String secretBranchTip =
+        gApi.projects()
+            .name(secretBranch.project().get())
+            .branch(secretBranch.branch())
+            .create(new BranchInput())
+            .get()
+            .revision;
+
+    // Create a successor change which merges visible and non-visible branch. This change
+    // is created as an explicit merge - otherwise Gerrit rejects it on submit as implicit merge.
     PushOneCommit change2 =
         pushFactory.create(admin.newIdent(), testRepo, "feature", "b.txt", "bar");
+    change2.setParents(
+        List.of(
+            changeResult.getCommit(), repo().parseCommit(ObjectId.fromString(secretBranchTip))));
     PushOneCommit.Result change2Result = change2.to("refs/for/master");
-
-    // Move the first change to a destination branch that is non-visible to user so that user cannot
-    // this change anymore.
-    BranchNameKey secretBranch = BranchNameKey.create(project, "secretBranch");
-    gApi.projects()
-        .name(secretBranch.project().get())
-        .branch(secretBranch.branch())
-        .create(new BranchInput());
     gApi.changes().id(changeResult.getChangeId()).move(secretBranch.branch());
+
+    // Hide branch from the user so that user cannot this change anymore.
     projectOperations
         .project(project)
         .forUpdate()
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 6dfa82b..d3a622f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -26,6 +26,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.TestAccount;
@@ -48,7 +49,6 @@
 import com.google.inject.Inject;
 import java.util.List;
 import java.util.Locale;
-import java.util.Set;
 import java.util.stream.IntStream;
 import org.junit.Before;
 import org.junit.Test;
@@ -476,17 +476,17 @@
     String name = name("foo");
     TestAccount foo1 = accountCreator.create(name + "-1");
     reviewChange(changeIdReviewed, foo1);
-    assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo1.id().get()).getActive()).isTrue();
 
     TestAccount foo2 = accountCreator.create(name + "-2");
     reviewChange(changeIdReviewed, foo2);
-    assertThat(gApi.accounts().id(foo2.username()).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isTrue();
 
     assertReviewers(
         suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.accounts().id(foo2.username()).setActive(false);
+    gApi.accounts().id(foo2.id().get()).setActive(false);
     assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
     assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo1), ImmutableList.of());
     assertReviewers(
@@ -654,7 +654,7 @@
   }
 
   private AccountGroup.UUID createGroupWithArbitraryMembers(int numMembers) {
-    Set<Account.Id> members =
+    ImmutableSet<Account.Id> members =
         IntStream.rangeClosed(1, numMembers)
             .mapToObj(i -> accountOperations.newAccount().create())
             .collect(toImmutableSet());
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/BUILD b/javatests/com/google/gerrit/acceptance/rest/config/BUILD
index 8550423..7a16841 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/config/BUILD
@@ -6,5 +6,6 @@
     labels = ["rest"],
     deps = [
         "//java/com/google/gerrit/server/restapi",
+        "//lib/lucene:lucene-core",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
index 164f683..03c17cf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
@@ -33,7 +33,8 @@
   @Test
   public void flushCache() throws Exception {
     // access the admin group once so that it is loaded into the group cache
-    adminGroup();
+    @SuppressWarnings("unused")
+    var unused = adminGroup();
 
     RestResponse r = adminRestSession.get("/config/server/caches/groups_byname");
     CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class);
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
index a9e3cf6..9ed6d15 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
@@ -33,7 +33,7 @@
     TaskInfo info = newGson().fromJson(r.getReader(), new TypeToken<TaskInfo>() {}.getType());
     assertThat(info.id).isNotNull();
     Long.parseLong(info.id, 16);
-    assertThat(info.command).isEqualTo("Log File Compressor");
+    assertThat(info.command).isEqualTo("Log File Manager");
     assertThat(info.startTime).isNotNull();
   }
 
@@ -49,7 +49,7 @@
         newGson().fromJson(r.getReader(), new TypeToken<List<TaskInfo>>() {}.getType());
     r.consume();
     for (TaskInfo info : result) {
-      if ("Log File Compressor".equals(info.command)) {
+      if ("Log File Manager".equals(info.command)) {
         return info.id;
       }
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/IndexSnapshotsIT.java b/javatests/com/google/gerrit/acceptance/rest/config/IndexSnapshotsIT.java
new file mode 100644
index 0000000..904de9a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/config/IndexSnapshotsIT.java
@@ -0,0 +1,228 @@
+// 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.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.IndexResource;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.account.AccountIndexDefinition;
+import com.google.gerrit.server.index.change.ChangeIndexDefinition;
+import com.google.gerrit.server.index.group.GroupIndexDefinition;
+import com.google.gerrit.server.index.project.ProjectIndexDefinition;
+import com.google.gerrit.server.restapi.config.SnapshotIndex;
+import com.google.gerrit.server.restapi.config.SnapshotIndexes;
+import com.google.gerrit.server.restapi.config.SnapshotInfo;
+import com.google.gerrit.testing.SystemPropertiesTestRule;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collection;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.TermQuery;
+import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+public class IndexSnapshotsIT extends AbstractDaemonTest {
+
+  @ClassRule
+  public static SystemPropertiesTestRule systemProperties =
+      new SystemPropertiesTestRule(IndexType.SYS_PROP, "lucene");
+
+  @Inject private SnapshotIndex snapshotIndex;
+  @Inject private SnapshotIndexes snapshotIndexes;
+  @Inject private AccountIndexDefinition accountIndexDefinition;
+  @Inject private ChangeIndexDefinition changeIndexDefinition;
+  @Inject private GroupIndexDefinition groupIndexDefinition;
+  @Inject private ProjectIndexDefinition projectIndexDefinition;
+
+  @Inject private SitePaths sitePaths;
+
+  @Test
+  @UseLocalDisk
+  public void createAccountsIndexSnapshot() throws Exception {
+    Query query = new TermQuery(new Term("is", "active"));
+    createAndVerifySnapshot(new IndexResource(accountIndexDefinition), "accounts", query);
+  }
+
+  @Test
+  @UseLocalDisk
+  public void createFullSnapshot() throws Exception {
+    File snapshot = createSnapshotOfAllIndexes();
+    File[] members = snapshot.listFiles();
+    for (File member : members) {
+      assertThat(member.isDirectory()).isTrue();
+      verifyIndexCanBeOpen(member);
+    }
+  }
+
+  @Test
+  @UseLocalDisk
+  public void createChangesIndexSnapshot() throws Exception {
+    Query query = new TermQuery(new Term("status", "open"));
+    createAndVerifySnapshot(new IndexResource(changeIndexDefinition), "changes", query);
+  }
+
+  @Test
+  @UseLocalDisk
+  public void createGroupsIndexSnapshot() throws Exception {
+    Query query = new TermQuery(new Term("is", "active"));
+    createAndVerifySnapshot(new IndexResource(groupIndexDefinition), "groups", query);
+  }
+
+  @Test
+  @UseLocalDisk
+  public void createProjectsIndexSnapshot() throws Exception {
+    Query query = new TermQuery(new Term("name", "foo"));
+    createAndVerifySnapshot(new IndexResource(projectIndexDefinition), "projects", query);
+  }
+
+  private File createAndVerifySnapshot(IndexResource rsrc, String prefix, Query query)
+      throws IOException {
+    File snapshot = createSnapshot(rsrc);
+
+    File[] subdirs = snapshot.listFiles();
+    Collection<? extends Index<?, ?>> indexes =
+        rsrc.getIndexDefinition().getIndexCollection().getWriteIndexes();
+    assertThat(subdirs).hasLength(indexes.size());
+    for (Index<?, ?> i : indexes) {
+      String indexDirName = String.format("%s_%04d", prefix, i.getSchema().getVersion());
+      File[] result = snapshot.listFiles((d, n) -> n.equals(indexDirName));
+      assertThat(result).hasLength(1);
+      File accountsIndexSnapshot = result[0];
+      openIndexAndQuery(accountsIndexSnapshot, query);
+    }
+    return snapshot;
+  }
+
+  private File createSnapshot(IndexResource rsrc) throws IOException {
+    Response<?> rsp = snapshotIndex.apply(rsrc, new SnapshotIndex.Input());
+    return verifySnapshot(rsp);
+  }
+
+  private File createSnapshotOfAllIndexes() throws IOException {
+    Response<?> rsp = snapshotIndexes.apply(new ConfigResource(), new SnapshotIndexes.Input());
+    return verifySnapshot(rsp);
+  }
+
+  private File verifySnapshot(Response<?> rsp) {
+    assertThat(rsp.value()).isInstanceOf(SnapshotInfo.class);
+    SnapshotInfo snapshotInfo = (SnapshotInfo) rsp.value();
+    Path snapshotDir = sitePaths.index_dir.resolve("snapshots").resolve(snapshotInfo.id);
+    File snapshot = snapshotDir.toFile();
+    assertThat(snapshot.exists()).isTrue();
+    assertThat(snapshot.isDirectory()).isTrue();
+    return snapshot;
+  }
+
+  private void verifyIndexCanBeOpen(File indexDir) throws IOException {
+    createIndex(indexDir).tryOpen();
+  }
+
+  private void openIndexAndQuery(File indexDir, Query query) throws IOException {
+    BaseIndex index = createIndex(indexDir);
+    index.openAndQuery(query);
+  }
+
+  private BaseIndex createIndex(File indexDir) {
+    BaseIndex index;
+    if (indexDir.getName().startsWith("changes")) {
+      index = new ChangeIndex(indexDir);
+    } else {
+      index = new SimpleIndex(indexDir);
+    }
+    return index;
+  }
+
+  private abstract static class BaseIndex {
+    protected File indexDir;
+
+    BaseIndex(File indexDir) {
+      this.indexDir = indexDir;
+    }
+
+    abstract void tryOpen() throws IOException;
+
+    abstract void openAndQuery(Query query) throws IOException;
+  }
+
+  private static class SimpleIndex extends BaseIndex {
+    SimpleIndex(File indexDir) {
+      super(indexDir);
+    }
+
+    @Override
+    void tryOpen() throws IOException {
+      Directory index = FSDirectory.open(indexDir.toPath());
+      try (IndexReader reader = DirectoryReader.open(index)) {}
+    }
+
+    @Override
+    void openAndQuery(Query query) throws IOException {
+      Directory index = FSDirectory.open(indexDir.toPath());
+      try (IndexReader reader = DirectoryReader.open(index)) {
+        IndexSearcher searcher = new IndexSearcher(reader);
+        TopDocs result = searcher.search(query, 10);
+        System.out.printf("query result length = %d\n", result.scoreDocs.length);
+      }
+    }
+  }
+
+  private static class ChangeIndex extends BaseIndex {
+    private SimpleIndex open;
+    private SimpleIndex closed;
+
+    ChangeIndex(File indexDir) {
+      super(indexDir);
+      File[] subDirs = indexDir.listFiles();
+      for (File subDir : subDirs) {
+        String name = subDir.getName();
+        if (name.equals("open")) {
+          this.open = new SimpleIndex(subDir);
+        } else if (name.equals("closed")) {
+          this.closed = new SimpleIndex(subDir);
+        } else {
+          throw new IllegalStateException("Unexpected subdir in changes index " + name);
+        }
+      }
+    }
+
+    @Override
+    void tryOpen() throws IOException {
+      open.tryOpen();
+      closed.tryOpen();
+    }
+
+    @Override
+    void openAndQuery(Query query) throws IOException {
+      open.openAndQuery(query);
+      closed.openAndQuery(query);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
index 2a891aa..ab3689b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -37,7 +36,7 @@
 
     Optional<String> id =
         result.stream()
-            .filter(t -> "Log File Compressor".equals(t.command))
+            .filter(t -> "Log File Manager".equals(t.command))
             .map(t -> t.id)
             .findFirst();
     assertThat(id).isPresent();
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
index 674ca79..cad0875 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
@@ -34,7 +34,7 @@
     assertThat(result).isNotEmpty();
     boolean foundLogFileCompressorTask = false;
     for (TaskInfo info : result) {
-      if ("Log File Compressor".equals(info.command)) {
+      if ("Log File Manager".equals(info.command)) {
         foundLogFileCompressorTask = true;
       }
       assertThat(info.id).isNotNull();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index d45c90b..03af621 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
@@ -127,7 +126,7 @@
             .add(
                 new RefOperationValidationListener() {
                   @Override
-                  public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+                  public ImmutableList<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
                       throws ValidationException {
                     try (Repository repo = repoManager.openRepository(project)) {
                       RefUpdate u = repo.updateRef(testBranch.branch());
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
index 7b42d93..1f7454d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.entities.RefNames.REFS_HEADS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 59e23a9..755581c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectInfo;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertProjectOwners;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
@@ -351,7 +350,7 @@
   @GerritConfig(name = "gerrit.defaultBranch", value = "main")
   public void createProject_WhenDefaultBranchIsSet() throws Exception {
     String newProjectName = name("newProject");
-    gApi.projects().create(newProjectName).get();
+    gApi.projects().create(newProjectName);
     ImmutableMap<String, BranchInfo> branches = getProjectBranches(newProjectName);
     // HEAD symbolic ref is set to the default, but the actual ref is not created.
     assertThat(branches.keySet()).containsExactly("HEAD", "refs/meta/config");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index d8b0cb1..f7fa8d9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -113,7 +113,9 @@
     RestResponse r =
         userRestSession.delete("/projects/" + project.get() + "/branches/" + testBranch.branch());
     r.assertNotFound();
-    branch(testBranch).get();
+
+    @SuppressWarnings("unused")
+    var unused = branch(testBranch).get();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
index 491a0d5..6a8c9d8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -120,6 +120,7 @@
     amendChange(changeId);
 
     // Assert no throws.
-    gApi.changes().id(changeId).get(DETAILED_LABELS);
+    @SuppressWarnings("unused")
+    var unused = gApi.changes().id(changeId).get(DETAILED_LABELS);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
index cf3bf89..a05f099 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
@@ -83,7 +83,7 @@
   }
 
   @Test
-  public void cannotGetNonVisibleBranch() {
+  public void cannotGetNonVisibleBranch() throws Exception {
     String branchName = "master";
 
     // block read access to the branch
@@ -98,7 +98,7 @@
   }
 
   @Test
-  public void cannotGetNonVisibleBranchByShortName() {
+  public void cannotGetNonVisibleBranchByShortName() throws Exception {
     String branchName = "master";
 
     // block read access to the branch
@@ -441,7 +441,7 @@
   }
 
   @Test
-  public void cannotGetSymbolicRefThatPointsToNonVisibleBranch() {
+  public void cannotGetSymbolicRefThatPointsToNonVisibleBranch() throws Exception {
     // block read access to the branch to which HEAD points by default
     projectOperations
         .project(project)
@@ -562,8 +562,7 @@
     assertBranchNotFound(project, RefNames.refsCacheAutomerge(mergeRevision));
   }
 
-  private void testGetRefWithAccessDatabase(Project.NameKey project, String ref)
-      throws RestApiException {
+  private void testGetRefWithAccessDatabase(Project.NameKey project, String ref) throws Exception {
     projectOperations
         .project(allProjects)
         .forUpdate()
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index 21a4c98..191444f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -37,7 +37,6 @@
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.util.List;
 import org.junit.Test;
 
 @NoHttpd
@@ -290,7 +289,7 @@
     listBranches.setNextPageToken(ListBranches.encodeToken("refs/heads/someBranch1"));
     Response<ImmutableList<BranchInfo>> response =
         listBranches.apply(projects.parse(project.get()));
-    List<String> continuationToken =
+    ImmutableList<String> continuationToken =
         response.headers().get(ListBranches.NEXT_PAGE_TOKEN_HEADER).asList();
     // Since branch1 does not exist, the server continues from branch2.
     assertRefs(ImmutableList.of(branch2), response.value());
@@ -307,7 +306,7 @@
     listBranches.setNextPageToken(ListBranches.encodeToken("refs/heads/someBranch4"));
     Response<ImmutableList<BranchInfo>> response =
         listBranches.apply(projects.parse(project.get()));
-    List<String> continuationToken =
+    ImmutableList<String> continuationToken =
         response.headers().get(ListBranches.NEXT_PAGE_TOKEN_HEADER).asList();
     // Since branch1 does not exist, the server continues from branch2.
     assertRefs(ImmutableList.of(), response.value());
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index 7a717d1..463f9cf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -192,7 +192,8 @@
     requestScopeOperations.setApiUser(user.id());
 
     // can list labels without inheritance
-    gApi.projects().name(project.get()).labels().get();
+    @SuppressWarnings("unused")
+    var unused = gApi.projects().name(project.get()).labels().get();
 
     // cannot list labels with inheritance
     AuthException thrown =
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index de9b579..0a13935 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -350,7 +350,8 @@
     assertThat(info.state).isEqualTo(input.state);
 
     // Project is still accessible directly
-    gApi.projects().name(hidden.get()).get();
+    @SuppressWarnings("unused")
+    var unused = gApi.projects().name(hidden.get()).get();
 
     // Hidden project is not included in the list
     assertThatNameList(gApi.projects().list().get())
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
index 3f583a2..f267958 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
@@ -26,12 +26,11 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.project.ProjectState;
-import java.util.List;
 import java.util.Set;
 
 public class ProjectAssert {
   public static IterableSubject assertThatNameList(Iterable<ProjectInfo> actualIt) {
-    List<ProjectInfo> actual = ImmutableList.copyOf(actualIt);
+    ImmutableList<ProjectInfo> actual = ImmutableList.copyOf(actualIt);
     for (ProjectInfo info : actual) {
       assertWithMessage("missing project name").that(info.name).isNotNull();
       assertWithMessage("project name does not match id")
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SuggestBranchReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SuggestBranchReviewersIT.java
index 98f5716..8311d96 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SuggestBranchReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SuggestBranchReviewersIT.java
@@ -150,15 +150,15 @@
 
     String name = name("foo");
     TestAccount foo1 = accountCreator.create(name + "-1");
-    assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo1.id().get()).getActive()).isTrue();
 
     TestAccount foo2 = accountCreator.create(name + "-2");
-    assertThat(gApi.accounts().id(foo2.username()).getActive()).isTrue();
+    assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isTrue();
 
     assertReviewers(suggestReviewers(name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.accounts().id(foo2.username()).setActive(false);
+    gApi.accounts().id(foo2.id().get()).setActive(false);
     assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
     assertReviewers(suggestReviewers(name), ImmutableList.of(foo1), ImmutableList.of());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 795e22c..e2809b0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -23,9 +23,11 @@
 
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.AccessSection;
@@ -34,6 +36,7 @@
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.common.ListTagSortOption;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -51,7 +54,7 @@
 
 @NoHttpd
 public class TagsIT extends AbstractDaemonTest {
-  private static final List<String> testTags =
+  private static final ImmutableList<String> testTags =
       ImmutableList.of("tag-A", "tag-B", "tag-C", "tag-D", "tag-E", "tag-F", "tag-G", "tag-H");
 
   private static final String SIGNED_ANNOTATION =
@@ -119,6 +122,7 @@
   }
 
   @Test
+  @UseClockStep
   public void listTags() throws Exception {
     createTags();
 
@@ -155,6 +159,23 @@
 
     // With conflicting options
     assertBadRequest(getTags().withSubstring("ag-B").withRegex("^tag-[c|d]$"));
+
+    // with descending order
+    result = getTags().withDescendingOrder(true).get();
+    assertTagList(FluentIterable.from(Lists.reverse(testTags)), result);
+
+    // with sortBy creation time
+    result = getTags().withSortBy(ListTagSortOption.CREATION_TIME).get();
+    assertTagList(FluentIterable.from(Lists.reverse(testTags)), result);
+
+    // with sortBy, descending order and limit
+    result =
+        getTags()
+            .withDescendingOrder(true)
+            .withLimit(2)
+            .withSortBy(ListTagSortOption.CREATION_TIME)
+            .get();
+    assertTagList(FluentIterable.from(ImmutableList.of("tag-A", "tag-B")), result);
   }
 
   @Test
@@ -476,9 +497,10 @@
     TagInput input = new TagInput();
     input.revision = revision;
 
-    for (String tagname : testTags) {
+    // Creating the tags in reverse order to allow testing the sortBy option
+    for (String tagname : Lists.reverse(testTags)) {
+      input.message = tagname; // This updates the 'created' time of the tag
       TagInfo result = tag(tagname).create(input).get();
-      assertThat(result.revision).isEqualTo(input.revision);
       assertThat(result.ref).isEqualTo(R_TAGS + tagname);
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index b150491..2476f00 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.base.Splitter;
@@ -106,7 +105,7 @@
     gApi.accounts().id(user.id().get()).setActive(false);
 
     requestScopeOperations.setApiUser(user.id());
-    assertThat(gApi.accounts().id("self").getActive()).isFalse();
+    assertThat(gApi.accounts().self().getActive()).isFalse();
 
     Result result = resolveAsResult("self");
     assertThat(result.asIdSet()).containsExactly(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
index 379a712..6bfd988 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
@@ -33,7 +33,7 @@
 import com.google.common.truth.StandardSubjectBuilder;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth8;
+import com.google.common.truth.Truth;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -49,9 +49,9 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.truth.ListSubject;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
@@ -59,6 +59,8 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Predicate;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
@@ -122,10 +124,12 @@
   public void forInitialPatchSet_noApprovals() throws Exception {
     ChangeData changeData = createChange().getChange();
     try (Repository repo = repoManager.openRepository(project);
-        RevWalk revWalk = new RevWalk(repo)) {
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
       ApprovalCopier.Result approvalCopierResult =
           approvalCopier.forPatchSet(
-              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+              changeData.notes(), changeData.currentPatchSet(), new RepoView(repo, revWalk, ins));
       assertThat(approvalCopierResult.copiedApprovals()).isEmpty();
       assertThat(approvalCopierResult.outdatedApprovals()).isEmpty();
     }
@@ -436,7 +440,7 @@
   }
 
   private void vote(String changeId, TestAccount testAccount, String label, int value)
-      throws RestApiException {
+      throws Exception {
     requestScopeOperations.setApiUser(testAccount.id());
     gApi.changes().id(changeId).current().review(new ReviewInput().label(label, value));
     requestScopeOperations.setApiUser(admin.id());
@@ -453,9 +457,11 @@
     ChangeData changeData = changeDataFactory.create(project, changeId);
     assertThat(changeData.currentPatchSet().id().get()).isEqualTo(expectedCurrentPatchSetNum);
     try (Repository repo = repoManager.openRepository(project);
-        RevWalk revWalk = new RevWalk(repo)) {
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk revWalk = new RevWalk(reader)) {
       return approvalCopier.forPatchSet(
-          changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+          changeData.notes(), changeData.currentPatchSet(), new RepoView(repo, revWalk, ins));
     }
   }
 
@@ -483,7 +489,7 @@
                       approvalData.patchSetApproval().label().equals(labelId)
                           && approvalData.patchSetApproval().accountId().equals(accountId))
               .findAny();
-      Truth8.assertThat(approvalDataForLabelAndAccount).isPresent();
+      Truth.assertThat(approvalDataForLabelAndAccount).isPresent();
       return assertAbout(approvalDatas()).that(approvalDataForLabelAndAccount.get());
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/change/BUILD b/javatests/com/google/gerrit/acceptance/server/change/BUILD
index 4514ea3..33dfe67 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/change/BUILD
@@ -13,8 +13,9 @@
 
 java_library(
     name = "util",
+    testonly = 1,
     srcs = ["CommentsUtil.java"],
-    visibility = ["//javatests/com/google/gerrit/acceptance/api/change:__subpackages__"],
+    visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/entities",
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 7938ab9..a6cdfa1 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -16,9 +16,9 @@
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.acceptance.server.change.CommentsUtil.createRange;
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -32,6 +32,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.change.TestHumanComment;
@@ -63,6 +64,7 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.DeleteCommentRewriter;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
@@ -119,7 +121,7 @@
   private final Integer[] lines = {0, 1};
 
   @Before
-  public void setUp() {
+  public void setUp() throws Exception {
     requestScopeOperations.setApiUser(user.id());
   }
 
@@ -155,6 +157,130 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS})
+  public void createDraftWithFixSuggestions() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    String path = "file1";
+    DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, 0, "comment 1");
+    comment.fixSuggestions =
+        ImmutableList.of(
+            CommentsUtil.createFixSuggestionInfo(CommentsUtil.createFixReplacementInfo()));
+    addDraft(changeId, revId, comment);
+    Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
+    assertThat(result).hasSize(1);
+    CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+    // FixId is generated, use the one provided by the server.
+    comment.fixSuggestions.get(0).fixId = actual.fixSuggestions.get(0).fixId;
+    assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+
+    actual =
+        Iterables.getOnlyElement(
+            Iterables.getOnlyElement(gApi.changes().id(changeId).drafts().values()));
+    comment.fixSuggestions.get(0).fixId = actual.fixSuggestions.get(0).fixId;
+    assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+
+    List<CommentInfo> list = getDraftCommentsAsList(changeId);
+    assertThat(list).hasSize(1);
+    actual = list.get(0);
+    assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+
+    // Publish draft comment
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    reviewInput.message = "bar";
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    actual = gApi.changes().id(changeId).commentsRequest().getAsList().get(0);
+    assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS})
+  public void createDraftWithoutFixSuggestionsThenUpdateWithFixSuggestions() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    String path = "file1";
+    DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, 0, "comment 1");
+    addDraft(changeId, revId, comment);
+    Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
+    assertThat(result).hasSize(1);
+    CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+    // FixId is generated, use the one provided by the server.
+    assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+
+    List<CommentInfo> list = getDraftCommentsAsList(changeId);
+    assertThat(list).hasSize(1);
+    actual = list.get(0);
+    assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+
+    // Try to update draft comment
+    comment.fixSuggestions =
+        ImmutableList.of(
+            CommentsUtil.createFixSuggestionInfo(CommentsUtil.createFixReplacementInfo()));
+    updateDraft(changeId, revId, comment, actual.id);
+
+    // FixId is generated, use the one provided by the server.
+    actual =
+        Iterables.getOnlyElement(
+            Iterables.getOnlyElement(gApi.changes().id(changeId).drafts().values()));
+    comment.fixSuggestions.get(0).fixId = actual.fixSuggestions.get(0).fixId;
+    assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+
+    list = getDraftCommentsAsList(changeId);
+    assertThat(list).hasSize(1);
+    actual = list.get(0);
+    assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+
+    // Publish draft comment
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    reviewInput.message = "bar";
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    actual = gApi.changes().id(changeId).commentsRequest().getAsList().get(0);
+    assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+  }
+
+  @Test
+  public void createDraftWithFixSuggestionsFailsWithoutExperimentFlag() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    String path = "file1";
+    DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, 0, "comment 1");
+    comment.fixSuggestions =
+        ImmutableList.of(
+            CommentsUtil.createFixSuggestionInfo(CommentsUtil.createFixReplacementInfo()));
+    IllegalStateException thrown =
+        assertThrows(IllegalStateException.class, () -> addDraft(changeId, revId, comment));
+    assertThat(thrown).hasMessageThat().contains("feature flag prohibits setting fixSuggestions");
+  }
+
+  @Test
+  public void createDraftWithFixInvalidSuggestions() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    String path = "file1";
+    DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, 0, "comment 1");
+    comment.fixSuggestions =
+        ImmutableList.of(
+            CommentsUtil.createFixSuggestionInfo(CommentsUtil.createFixReplacementInfo()));
+    // Invalid range
+    comment.fixSuggestions.get(0).replacements.get(0).range = createRange(13, 9, 5, 10);
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
+    assertThat(thrown).hasMessageThat().contains("Range (13:9 - 5:10)");
+  }
+
+  @Test
   public void fireEventsForOperationsOnDrafts() throws Exception {
     TestGitReferenceUpdatedListener listener = new TestGitReferenceUpdatedListener();
     requestScopeOperations.setApiUser(user.id());
@@ -1078,7 +1204,10 @@
       ChangeResource changeRsrc =
           changes.get().parse(TopLevelResource.INSTANCE, IdString.fromDecoded(changeId));
       RevisionResource revRsrc = revisions.parse(changeRsrc, IdString.fromDecoded(revId));
-      postReview.get().apply(revRsrc, input, timestamp);
+
+      @SuppressWarnings("unused")
+      var unused = postReview.get().apply(revRsrc, input, timestamp);
+
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
@@ -2233,6 +2362,7 @@
       DraftInput draftInput = new DraftInput();
       draftInput.path = path;
       draftInput.unresolved = info.unresolved;
+      draftInput.fixSuggestions = info.fixSuggestions;
       copy(info, draftInput);
       return draftInput;
     };
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
index f32cf32..e25ae74 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
@@ -27,6 +27,8 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import java.util.Arrays;
 import java.util.HashMap;
 
@@ -190,4 +192,31 @@
     in.omitDuplicateComments = omitDuplicateComments;
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
   }
+
+  public static FixSuggestionInfo createFixSuggestionInfo(
+      FixReplacementInfo... fixReplacementInfos) {
+    FixSuggestionInfo newFixSuggestionInfo = new FixSuggestionInfo();
+    newFixSuggestionInfo.fixId = "An ID which must be overwritten.";
+    newFixSuggestionInfo.description = "A description for a suggested fix.";
+    newFixSuggestionInfo.replacements = Arrays.asList(fixReplacementInfos);
+    return newFixSuggestionInfo;
+  }
+
+  public static FixReplacementInfo createFixReplacementInfo() {
+    FixReplacementInfo newFixReplacementInfo = new FixReplacementInfo();
+    newFixReplacementInfo.path = FILE_NAME;
+    newFixReplacementInfo.replacement = "some replacement code";
+    newFixReplacementInfo.range = createRange(3, 9, 8, 4);
+    return newFixReplacementInfo;
+  }
+
+  public static Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
index e011ffc..e172153 100644
--- a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -29,6 +29,11 @@
 
   @Inject ExperimentFeatures experimentFeatures;
 
+  @Override
+  public boolean enableExperimentsRejectImplicitMergesOnMerge() {
+    return false;
+  }
+
   @Test
   public void emptyConfig_defaultFeatures_enabled() {
     for (String defaultFeature : ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES) {
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java
index b2836fd..6945329 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java
@@ -14,8 +14,13 @@
 
 package com.google.gerrit.acceptance.server.git.receive;
 
+import static com.google.common.truth.Truth.assertThat;
+
 import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
@@ -24,10 +29,7 @@
 
   @Test
   public void disallowTruncatingChangeIdAcrossPatchSets() throws Exception {
-    // Create the parent.
-    RevCommit parent =
-        commitBuilder().add("foo.txt", "foo content").message("base commit").create();
-    testRepo.reset(parent);
+    RevCommit parent = createParentCommit();
 
     String changeId = "I0000000000000000000000000000000000000012";
     String truncatedChangeId = "I000000000000000000000000000000000000001";
@@ -55,4 +57,49 @@
         .to("refs/for/master")
         .assertErrorStatus("invalid Change-Id");
   }
+
+  @Test
+  public void pushWithMissingChangeId_rejectedWithDefaultCommitMessageHook() throws Exception {
+    createParentCommit();
+    PushOneCommit.Result pushResult =
+        pushFactory
+            .create(admin.newIdent(), testRepo, /* insertChangeIdIfNotExist= */ false)
+            .to("refs/for/master");
+    String missingChangeIdRegex =
+        "^commit [a-z0-9]+: missing Change-Id in message footer[\\s\\S]+"
+            + "Hint: to automatically insert a Change-Id, install the hook:\n"
+            + "f=\"\\$\\(git rev-parse --git-dir\\)/hooks/commit-msg\"; "
+            + "curl -o \"\\$f\" "
+            + "http://localhost:[0-9]+/tools/hooks/commit-msg ; "
+            + "chmod \\+x \"\\$f\"\n"
+            + "and then amend the commit:\n"
+            + "  git commit --amend --no-edit\n"
+            + "Finally, push your changes again\n\n$";
+    assertThat(pushResult.getMessage()).matches(missingChangeIdRegex);
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.installCommitMsgHookCommand", value = "Install custom hook")
+  public void pushWithMissingChangeId_rejectedWithCustomCommitMessageHook() throws Exception {
+    createParentCommit();
+    PushOneCommit.Result pushResult =
+        pushFactory
+            .create(admin.newIdent(), testRepo, /* insertChangeIdIfNotExist= */ false)
+            .to("refs/for/master");
+    String missingChangeIdRegex =
+        "^commit [a-z0-9]+: missing Change-Id in message footer[\\s\\S]+"
+            + "Hint: to automatically insert a Change-Id, install the hook:\n"
+            + "Install custom hook\n"
+            + "and then amend the commit:\n"
+            + "  git commit --amend --no-edit\n"
+            + "Finally, push your changes again\n\n$";
+    assertThat(pushResult.getMessage()).matches(missingChangeIdRegex);
+  }
+
+  @CanIgnoreReturnValue
+  private RevCommit createParentCommit() throws Exception {
+    RevCommit parent = commitBuilder().add("f.txt", "content").message("base commit").create();
+    testRepo.reset(parent);
+    return parent;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
index 83a0153..807fd5b 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -48,7 +48,6 @@
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import com.google.inject.Module;
-import java.util.List;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -131,7 +130,7 @@
     testCommentHelper.addDraft(changeId, revId, comment);
     amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
 
-    List<FakeEmailSender.Message> messages = sender.getMessages();
+    ImmutableList<FakeEmailSender.Message> messages = sender.getMessages();
     assertThat(messages).hasSize(2);
 
     FakeEmailSender.Message newPatchsetMessage = messages.get(0);
diff --git a/javatests/com/google/gerrit/acceptance/server/index/BUILD b/javatests/com/google/gerrit/acceptance/server/index/BUILD
new file mode 100644
index 0000000..1d4ef02
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/index/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["**/*IT.java"]),
+    group = "server_index",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/index/ReindexIndexVersionIT.java b/javatests/com/google/gerrit/acceptance/server/index/ReindexIndexVersionIT.java
new file mode 100644
index 0000000..d433ca7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/index/ReindexIndexVersionIT.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.index;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.server.config.IndexVersionResource;
+import com.google.gerrit.server.restapi.config.ReindexIndexVersion;
+import com.google.inject.Inject;
+import java.util.Collection;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ReindexIndexVersionIT extends AbstractDaemonTest {
+
+  @Inject private ReindexIndexVersion reindexIndexVersion;
+  @Inject private Collection<IndexDefinition<?, ?, ?>> indexDefs;
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  private IndexDefinition<?, ?, ?> def;
+  private Index<?, ?> changeIndex;
+  private Change.Id C1;
+  private Change.Id C2;
+
+  private ChangeIndexedListener changeIndexedListener;
+  private ReindexIndexVersion.Input input = new ReindexIndexVersion.Input();
+
+  @Before
+  public void setUp() throws Exception {
+    def = indexDefs.stream().filter(i -> i.getName().equals("changes")).findFirst().get();
+    changeIndex = def.getIndexCollection().getSearchIndex();
+    C1 = createChange().getChange().getId();
+    C2 = createChange().getChange().getId();
+    changeIndexedListener = mock(ChangeIndexedListener.class);
+    input = new ReindexIndexVersion.Input();
+  }
+
+  @Test
+  public void reindexWithListenerNotification() throws Exception {
+    input.notifyListeners = true;
+    reindex();
+    verify(changeIndexedListener, times(1)).onChangeIndexed(project.get(), C1.get());
+    verify(changeIndexedListener, times(1)).onChangeIndexed(project.get(), C2.get());
+  }
+
+  @Test
+  public void reindexWithoutListenerNotification() throws Exception {
+    input.notifyListeners = false;
+    reindex();
+    verifyNoInteractions(changeIndexedListener);
+  }
+
+  private void reindex() throws ResourceNotFoundException {
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedListener)) {
+      Response<?> rsp =
+          reindexIndexVersion.apply(new IndexVersionResource(def, changeIndex), input);
+      assertThat(rsp.statusCode()).isEqualTo(HttpServletResponse.SC_ACCEPTED);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/index/change/LuceneChangeIndexerIT.java b/javatests/com/google/gerrit/acceptance/server/index/change/LuceneChangeIndexerIT.java
new file mode 100644
index 0000000..b8af367
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/index/change/LuceneChangeIndexerIT.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.index.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ChangeIndexedCounter;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.RefState;
+import com.google.gerrit.index.SiteIndexer.Result;
+import com.google.gerrit.server.index.change.AllChangesIndexer;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+public class LuceneChangeIndexerIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("index", null, "autoReindexIfStale", false);
+    cfg.setString("index", null, "type", "lucene");
+    return cfg;
+  }
+
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Inject private Collection<IndexDefinition<?, ?, ?>> indexDefs;
+  private AllChangesIndexer allChangesIndexer;
+  private ChangeIndex index;
+
+  @Before
+  public void setup() {
+    IndexDefinition<?, ?, ?> changeIndex =
+        indexDefs.stream().filter(i -> i.getName().equals("changes")).findFirst().get();
+    allChangesIndexer = (AllChangesIndexer) changeIndex.getSiteIndexer();
+    index = (ChangeIndex) changeIndex.getIndexCollection().getWriteIndexes().iterator().next();
+  }
+
+  @Test
+  @GerritConfig(name = "index.reuseExistingDocuments", value = "false")
+  public void testReindexWithoutReuse() throws Exception {
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      createChange();
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(1);
+      changeIndexedCounter.clear();
+      reindexChanges();
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(1);
+
+      createIndexWithMissingChangeAndReindex(changeIndexedCounter);
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(2);
+
+      createIndexWithStaleChangeAndReindex(changeIndexedCounter);
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(3);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "index.reuseExistingDocuments", value = "true")
+  public void testReindexWithReuse() throws Exception {
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      createChange();
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(1);
+      changeIndexedCounter.clear();
+      reindexChanges();
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(0);
+
+      createIndexWithMissingChangeAndReindex(changeIndexedCounter);
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(1);
+
+      createIndexWithStaleChangeAndReindex(changeIndexedCounter);
+      assertThat(changeIndexedCounter.getTotalCount()).isEqualTo(1);
+    }
+  }
+
+  private void createIndexWithMissingChangeAndReindex(ChangeIndexedCounter changeIndexedCounter)
+      throws Exception {
+    PushOneCommit.Result res = createChange();
+    index.delete(res.getChange().getId());
+    changeIndexedCounter.clear();
+    reindexChanges();
+  }
+
+  private void createIndexWithStaleChangeAndReindex(ChangeIndexedCounter changeIndexedCounter)
+      throws Exception {
+    PushOneCommit.Result res = createChange();
+    ChangeData wrongChangeData = res.getChange();
+    ListMultimap<NameKey, RefState> refStates =
+        LinkedListMultimap.create(wrongChangeData.getRefStates());
+    refStates.replaceValues(
+        project,
+        Set.of(
+            RefState.create(
+                "refs/changes/abcd",
+                ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))));
+    wrongChangeData.setRefStates(ImmutableSetMultimap.copyOf(refStates));
+    index.replace(wrongChangeData);
+    changeIndexedCounter.clear();
+    reindexChanges();
+  }
+
+  private void reindexChanges() throws Exception {
+    Result res = allChangesIndexer.indexAll(index);
+    assertThat(res.success()).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index b606271..2ee5360 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -294,11 +294,7 @@
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, sc.owner, reviewer.email());
     // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
+    assertThat(sender).sent("newchange", sc).to(reviewer).noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -336,12 +332,7 @@
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), CC_ON_OWN_COMMENTS, null);
     // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(sc.owner)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
+    assertThat(sender).sent("newchange", sc).to(reviewer).cc(sc.owner).noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -361,11 +352,7 @@
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, other, reviewer.email());
     // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
+    assertThat(sender).sent("newchange", sc).to(reviewer).noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -385,12 +372,7 @@
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, other, reviewer.email(), CC_ON_OWN_COMMENTS, null);
     // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(other)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
+    assertThat(sender).sent("newchange", sc).to(reviewer).cc(other).noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -409,11 +391,7 @@
     StagedChange sc = stageReviewableChange();
     addReviewer(adder, sc.changeId, sc.owner, email);
     // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(email)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
+    assertThat(sender).sent("newchange", sc).to(email).noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -464,11 +442,7 @@
     // For a review-started WIP change, same as in the notify=ALL case. It's not especially
     // important to notify just because a reviewer is added, but we do want to notify in the other
     // case that hits this codepath: posting an actual review.
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
+    assertThat(sender).sent("newchange", sc).to(reviewer).noOneElse();
   }
 
   private void addReviewerToWipChangeNotifyAll(Adder adder) throws Exception {
@@ -476,11 +450,7 @@
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), NotifyHandling.ALL);
     // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
+    assertThat(sender).sent("newchange", sc).to(reviewer).noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -499,11 +469,7 @@
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(adder, sc.changeId, sc.owner, reviewer.email(), OWNER_REVIEWERS);
     // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(reviewer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
+    assertThat(sender).sent("newchange", sc).to(reviewer).noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -556,11 +522,7 @@
   private void addNonUserReviewerByEmail(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
     addReviewer(adder, sc.changeId, sc.owner, "nonexistent@example.com");
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to("nonexistent@example.com")
-        .cc(StagedUsers.CC_BY_EMAIL, StagedUsers.REVIEWER_BY_EMAIL)
-        .noOneElse();
+    assertThat(sender).sent("newchange", sc).to("nonexistent@example.com").noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -577,11 +539,7 @@
   private void addNonUserCcByEmail(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
     addReviewer(adder, sc.changeId, sc.owner, "nonexistent@example.com");
-    assertThat(sender)
-        .sent("newchange", sc)
-        .cc("nonexistent@example.com")
-        .cc(StagedUsers.CC_BY_EMAIL, StagedUsers.REVIEWER_BY_EMAIL)
-        .noOneElse();
+    assertThat(sender).sent("newchange", sc).cc("nonexistent@example.com").noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -1023,11 +981,7 @@
         .bcc(ALL_COMMENTS)
         .noOneElse();
     // TODO(logan): Should CCs be included?
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(other)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
+    assertThat(sender).sent("newchange", sc).to(other).noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -1932,7 +1886,6 @@
         .sent("newpatchset", sc)
         .to(sc.reviewer)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
@@ -1948,7 +1901,6 @@
         .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
         .to(sc.reviewer, other)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
@@ -1964,7 +1916,6 @@
         .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
         .to(sc.reviewer, other)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
@@ -1981,7 +1932,6 @@
         .to(sc.reviewer)
         .cc(other)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
   }
@@ -1997,7 +1947,6 @@
         .to(sc.reviewer)
         .cc(other)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
   }
@@ -2056,7 +2005,6 @@
         .sent("newpatchset", sc)
         .to(sc.reviewer)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
@@ -2071,7 +2019,6 @@
         .sent("newpatchset", sc)
         .to(sc.reviewer)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
@@ -2094,7 +2041,6 @@
         .sent("newpatchset", sc)
         .to(sc.reviewer, newReviewer)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
@@ -2118,7 +2064,6 @@
         .sent("newpatchset", sc)
         .to(sc.reviewer, newReviewer)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
@@ -2133,7 +2078,6 @@
         .sent("newpatchset", sc)
         .to(sc.reviewer)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
@@ -2170,7 +2114,6 @@
         .sent("newpatchset", sc)
         .to(sc.reviewer)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
@@ -2185,7 +2128,6 @@
         .sent("newpatchset", sc)
         .to(sc.owner, sc.reviewer)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
@@ -2200,7 +2142,6 @@
         .sent("newpatchset", sc)
         .to(sc.owner, sc.reviewer, other)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
@@ -2211,12 +2152,7 @@
   public void editCommitMessageByOtherOnReviewableChangeNotifyOwnerReviewers() throws Exception {
     StagedChange sc = stageReviewableChange();
     editCommitMessage(sc, other, OWNER_REVIEWERS);
-    assertThat(sender)
-        .sent("newpatchset", sc)
-        .to(sc.owner, sc.reviewer)
-        .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
+    assertThat(sender).sent("newpatchset", sc).to(sc.owner, sc.reviewer).cc(sc.ccer).noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -2229,7 +2165,6 @@
         .sent("newpatchset", sc)
         .to(sc.owner, sc.reviewer)
         .cc(sc.ccer, other)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
   }
@@ -2295,7 +2230,6 @@
         .sent("newpatchset", sc)
         .to(sc.reviewer)
         .cc(sc.ccer)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .bcc(sc.starrer)
         .bcc(NEW_PATCHSETS)
         .noOneElse();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
index 1ad27eb..d7f19aa 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -35,7 +36,6 @@
 import java.time.ZonedDateTime;
 import java.util.Collection;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 import org.junit.Test;
 
@@ -50,7 +50,7 @@
     PushOneCommit.Result newChange = createChange();
     gApi.changes().id(newChange.getChangeId()).addReviewer(user.id().toString());
 
-    List<FakeEmailSender.Message> emails = sender.getMessages();
+    ImmutableList<FakeEmailSender.Message> emails = sender.getMessages();
     assertThat(emails).hasSize(1);
     FakeEmailSender.Message message = emails.get(0);
 
@@ -87,7 +87,7 @@
         gApi.changes().id(newChange.getChangeId()).get().messages;
     assertThat(result).isNotEmpty();
 
-    List<FakeEmailSender.Message> emails = sender.getMessages();
+    ImmutableList<FakeEmailSender.Message> emails = sender.getMessages();
     assertThat(emails).hasSize(1);
     FakeEmailSender.Message message = emails.get(0);
 
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index 5e00230..6197132 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.config.GerritConfig;
@@ -35,14 +36,14 @@
   @Inject private SitePaths sitePaths;
 
   @Test
-  @GerritConfig(name = "sendemail.replyToAddress", value = "custom@gerritcodereview.com")
+  @GerritConfig(name = "sendemail.replyToAddress", value = "custom@example.com")
   @GerritConfig(name = "receiveemail.protocol", value = "POP3")
   public void outgoingMailHasCustomReplyToHeader() throws Exception {
     createChangeWithReview(user);
     // Check that the custom address was added as Reply-To
     assertThat(sender.getMessages()).hasSize(1);
-    Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
-    assertThat(headerString(headers, "Reply-To")).isEqualTo("custom@gerritcodereview.com");
+    ImmutableMap<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
+    assertThat(headerString(headers, "Reply-To")).isEqualTo("custom@example.com");
   }
 
   @Test
@@ -50,7 +51,7 @@
     createChangeWithReview(user);
     // Check that the user's email was added as Reply-To
     assertThat(sender.getMessages()).hasSize(1);
-    Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
+    ImmutableMap<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
     assertThat(headerString(headers, "Reply-To")).contains(user.email());
   }
 
@@ -59,7 +60,7 @@
     String changeId = createChangeWithReview(user);
     // Check that the mail has the expected headers
     assertThat(sender.getMessages()).hasSize(1);
-    Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
+    ImmutableMap<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
     String hostname = URI.create(canonicalWebUrl.get()).getHost();
     String listId = String.format("<gerrit-%s.%s>", project.get(), hostname);
     String unsubscribeLink = String.format("<%ssettings?usp=email>", canonicalWebUrl.get());
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
index 65b1d4f..f17013d 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
@@ -39,7 +39,7 @@
     // Set user preference to receive only plaintext content
     GeneralPreferencesInfo i = new GeneralPreferencesInfo();
     i.emailFormat = EmailFormat.PLAINTEXT;
-    gApi.accounts().id(admin.id().toString()).setPreferences(i);
+    gApi.accounts().id(admin.id().get()).setPreferences(i);
 
     // Create change as admin and review as user
     PushOneCommit.Result r = createChange();
@@ -57,7 +57,7 @@
     // Reset user preference
     requestScopeOperations.setApiUser(admin.id());
     i.emailFormat = EmailFormat.HTML_PLAINTEXT;
-    gApi.accounts().id(admin.id().toString()).setPreferences(i);
+    gApi.accounts().id(admin.id().get()).setPreferences(i);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
index fc746ad..3cc88f9 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
index 7a55ecb..9488c5f 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -53,7 +53,6 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
 import java.util.Collection;
-import java.util.Set;
 import java.util.stream.StreamSupport;
 import javax.inject.Inject;
 import org.eclipse.jgit.lib.Ref;
@@ -152,7 +151,7 @@
                       }
 
                       @Override
-                      public Set<AccountGroup.UUID> intersection(
+                      public ImmutableSet<AccountGroup.UUID> intersection(
                           Iterable<AccountGroup.UUID> groupIds) {
                         return StreamSupport.stream(groupIds.spliterator(), /* parallel= */ false)
                             .filter(g -> contains(g))
@@ -160,7 +159,7 @@
                       }
 
                       @Override
-                      public Set<AccountGroup.UUID> getKnownGroups() {
+                      public ImmutableSet<AccountGroup.UUID> getKnownGroups() {
                         return ImmutableSet.of();
                       }
                     };
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
index 0fb6b9e..2d198b7 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.cache.LoadingCache;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 3eb5bcf..5accd00 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import java.util.EnumSet;
-import java.util.List;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
@@ -90,7 +89,7 @@
             .to("refs/for/master");
     r.assertOkStatus();
 
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactlyElementsIn(watchers.build());
@@ -248,7 +247,7 @@
     r.assertOkStatus();
 
     // assert email notification
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
@@ -280,7 +279,7 @@
     r.assertOkStatus();
 
     // assert email notification for user
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
@@ -329,7 +328,7 @@
     r.assertOkStatus();
 
     // assert email notification for user
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
@@ -393,7 +392,7 @@
     gApi.changes().id(r.getChangeId()).addReviewer(user2.email());
 
     // assert email notification
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertNotifyTo(user2);
@@ -419,7 +418,7 @@
     r.assertOkStatus();
 
     // assert email notification for user
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
@@ -455,7 +454,7 @@
     // change user can see the non-visible account.
     // Even if watching by the non-visible account was not possible, user could just watch all
     // changes that are visible to them and then filter them by the non-visible account locally.
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
@@ -494,7 +493,7 @@
     // is sent to the admin user
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).current().review(reviewInput);
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(admin.getNameEmail());
@@ -531,7 +530,7 @@
     r.assertOkStatus();
 
     // assert email notification
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
@@ -559,7 +558,7 @@
     r.assertOkStatus();
 
     // assert email notification for user
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
@@ -608,7 +607,7 @@
     r.assertOkStatus();
 
     // assert email notification for user
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
@@ -688,7 +687,7 @@
     r.assertOkStatus();
 
     // assert email notification
-    List<Message> messages = sender.getMessages();
+    ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(userThatCanViewPrivateChanges.getNameEmail());
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
index df668a5..9093412 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -144,12 +144,16 @@
         .update();
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.projects().name(project.get()).branch("master").reflog();
+
+    @SuppressWarnings("unused")
+    var unused = gApi.projects().name(project.get()).branch("master").reflog();
   }
 
   @Test
   public void adminUserIsAllowedToGetReflog() throws Exception {
     requestScopeOperations.setApiUser(admin.id());
-    gApi.projects().name(project.get()).branch("master").reflog();
+
+    @SuppressWarnings("unused")
+    var unused = gApi.projects().name(project.get()).branch("master").reflog();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 15cd1a9..0c24b14 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -53,7 +54,6 @@
 import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.util.Map;
 import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
@@ -167,7 +167,7 @@
               /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
               false);
       configSubmitRequirement(project, projectSubmitRequirement);
-      Map<SubmitRequirement, SubmitRequirementResult> results =
+      ImmutableMap<SubmitRequirement, SubmitRequirementResult> results =
           evaluator.evaluateAllRequirements(changeData);
       assertThat(results).hasSize(2);
       assertThat(results.get(globalSubmitRequirement).status())
@@ -198,7 +198,7 @@
               /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
               false);
       configSubmitRequirement(project, projectSubmitRequirement);
-      Map<SubmitRequirement, SubmitRequirementResult> results =
+      ImmutableMap<SubmitRequirement, SubmitRequirementResult> results =
           evaluator.evaluateAllRequirements(changeData);
       assertThat(results).hasSize(1);
       assertThat(results.get(projectSubmitRequirement).status())
@@ -227,7 +227,7 @@
               /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
               false);
       configSubmitRequirement(project, projectSubmitRequirement);
-      Map<SubmitRequirement, SubmitRequirementResult> results =
+      ImmutableMap<SubmitRequirement, SubmitRequirementResult> results =
           evaluator.evaluateAllRequirements(changeData);
       assertThat(results).hasSize(1);
       assertThat(results.get(globalSubmitRequirement).status())
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
index 9170214..e680c8a 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
@@ -505,7 +505,8 @@
 
     private void assertServerUser() {
       try {
-        currentUser.asIdentifiedUser();
+        @SuppressWarnings("unused")
+        var unused = currentUser.asIdentifiedUser();
         throw new IllegalStateException("is an identified user");
       } catch (UnsupportedOperationException e) {
         // as expected.
diff --git a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
index 4ce62d2..58e9fb9 100644
--- a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
@@ -32,7 +32,10 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.approval.ApprovalContext;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
@@ -298,7 +301,9 @@
         changeKindCache.getChangeKind(
             changeNotes.getChange(), changeNotes.getPatchSets().get(newPsId));
     try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo.newObjectReader())) {
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
       return ApprovalContext.create(
           changeNotes,
           psId,
@@ -308,8 +313,7 @@
           changeNotes.getPatchSets().get(newPsId),
           changeKind,
           /* isMerge= */ false,
-          rw,
-          repo.getConfig());
+          new RepoView(repo, rw, ins));
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
index c6b09cc..24767cb 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.server.quota;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
index 4ce2deb..5a6f16a 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.server.rules;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/prolog/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/prolog/PrologRuleEvaluatorIT.java
index 850fe8e..1ab70e9 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/prolog/PrologRuleEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/prolog/PrologRuleEvaluatorIT.java
@@ -45,7 +45,7 @@
     StructureTerm verifiedLabel = makeLabel(LabelId.VERIFIED, "may");
     StructureTerm labels = new StructureTerm("label", verifiedLabel);
 
-    List<Term> terms = ImmutableList.of(makeTerm("ok", labels));
+    ImmutableList<Term> terms = ImmutableList.of(makeTerm("ok", labels));
     SubmitRecord record = evaluator.resultsToSubmitRecord(null, terms);
 
     assertThat(record.status).isEqualTo(SubmitRecord.Status.OK);
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java
index 74bdb56..772812f 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.RefNames;
@@ -31,8 +32,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import java.util.Arrays;
-import java.util.Collection;
-import java.util.Map;
+import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
@@ -171,6 +171,29 @@
     assertThat(statusForRuleAddFile("foo")).isEqualTo(SubmitRecord.Status.RULE_ERROR);
   }
 
+  @Test
+  @GerritConfig(name = "rules.enable", value = "false")
+  public void prologRule_noEffectWhenRulesDisabled() throws Exception {
+    modifySubmitRules("gerrit:commit_message_matches('foo.*')");
+    String changeId = createChange().getChangeId();
+    // Default rules don't allow submission
+    assertThat(gApi.changes().id(changeId).get().submittable).isFalse();
+    // Satisfy default rules
+    approve(changeId);
+
+    assertThat(gApi.changes().id(changeId).get().submittable).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "rules.enable", value = "true")
+  public void prologRule_takesEffectWhenRulesEnabled() throws Exception {
+    modifySubmitRules("gerrit:commit_message_matches('foo.*')");
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+
+    assertThat(gApi.changes().id(changeId).get().submittable).isFalse();
+  }
+
   private SubmitRecord.Status statusForRule() throws Exception {
     String oldHead = projectOperations.project(project).getHead("master").name();
     PushOneCommit.Result result =
@@ -180,7 +203,7 @@
   }
 
   private SubmitRecord.Status statusForRuleAddFile(String... filenames) throws Exception {
-    Map<String, String> fileToContentMap =
+    ImmutableMap<String, String> fileToContentMap =
         Arrays.stream(filenames).collect(ImmutableMap.toImmutableMap(f -> f, f -> "file content"));
     String oldHead = projectOperations.project(project).getHead("master").name();
     PushOneCommit push =
@@ -243,7 +266,7 @@
   private SubmitRecord.Status getStatus(PushOneCommit.Result result) throws Exception {
     ChangeData cd = result.getChange();
 
-    Collection<SubmitRecord> records;
+    List<SubmitRecord> records;
     try (AutoCloseable ignored1 = changeIndexOperations.disableReadsAndWrites();
         AutoCloseable ignored2 = accountIndexOperations.disableReadsAndWrites()) {
       SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
index fdfef87..809cee9 100644
--- a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
@@ -150,7 +150,7 @@
       @Override
       public void configure() {
         // Forwarder.delegate is empty on start to protect test listener from non test tasks
-        // (such as the "Log File Compressor") interference
+        // (such as the "Log File Manager") interference
         forwarder = new ForwardingListener(); // Only gets bound once for all tests
         bind(TaskListener.class).annotatedWith(Exports.named("listener")).toInstance(forwarder);
       }
@@ -161,7 +161,7 @@
   public void setupExecutorAndForwarder() throws InterruptedException {
     executor = workQueue.createQueue(1, "TaskListeners");
 
-    // "Log File Compressor"s are likely running and will interfere with tests
+    // "Log File Manager"s are likely running and will interfere with tests
     while (0 != workQueue.getTasks().size()) {
       for (Task<?> t : workQueue.getTasks()) {
         @SuppressWarnings("unused")
@@ -278,8 +278,8 @@
   private void assertAwaitQueueSize(int size) throws InterruptedException {
     long i = 0;
     do {
-      TimeUnit.NANOSECONDS.sleep(10);
-      assertThat(i++).isLessThan(100);
+      TimeUnit.NANOSECONDS.sleep(100);
+      assertThat(i++).isLessThan(1000);
     } while (size != workQueue.getTasks().size());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
index 5429131..8367e25 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.ssh;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseSsh;
@@ -31,7 +30,7 @@
   @Test
   public void withValidGroupName() throws Exception {
     String newGroupName = "newGroup";
-    adminRestSession.put("/groups/" + newGroupName);
+    adminRestSession.put("/groups/" + newGroupName).assertCreated();
     String newProjectName = "newProject";
     adminSshSession.exec(
         "gerrit create-project --branch master --owner " + newGroupName + " " + newProjectName);
@@ -43,7 +42,7 @@
   @Test
   public void withInvalidGroupName() throws Exception {
     String newGroupName = "newGroup";
-    adminRestSession.put("/groups/" + newGroupName);
+    adminRestSession.put("/groups/" + newGroupName).assertCreated();
     String wrongGroupName = "newG";
     String newProjectName = "newProject";
     adminSshSession.exec(
@@ -56,7 +55,7 @@
   @Test
   public void withDotGit() throws Exception {
     String newGroupName = "newGroup";
-    adminRestSession.put("/groups/" + newGroupName);
+    adminRestSession.put("/groups/" + newGroupName).assertCreated();
     String newProjectName = name("newProject");
     adminSshSession.exec(
         "gerrit create-project --branch master --owner "
@@ -73,7 +72,7 @@
   @Test
   public void withTrailingSlash() throws Exception {
     String newGroupName = "newGroup";
-    adminRestSession.put("/groups/" + newGroupName);
+    adminRestSession.put("/groups/" + newGroupName).assertCreated();
     String newProjectName = name("newProject");
     adminSshSession.exec(
         "gerrit create-project --branch master --owner "
diff --git a/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java
index fee413a..7fead5c 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.project.ProjectIndex;
@@ -34,7 +35,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
-import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
@@ -67,10 +67,11 @@
 class CustomIndexModule extends AbstractIndexModule {
 
   public static CustomIndexModule latestVersion(boolean secondary) {
-    return new CustomIndexModule(null, -1 /* direct executor */, secondary);
+    return new CustomIndexModule(/* singleVersions= */ null, -1 /* direct executor */, secondary);
   }
 
-  private CustomIndexModule(Map<String, Integer> singleVersions, int threads, boolean secondary) {
+  private CustomIndexModule(
+      ImmutableMap<String, Integer> singleVersions, int threads, boolean secondary) {
     super(singleVersions, threads, secondary);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
index 28d2a28..865cf51 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritServerTestRule;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.UseSsh;
@@ -35,8 +36,6 @@
 @Sandboxed
 @RunWith(ConfigSuite.class)
 public class SshDaemonIT extends AbstractDaemonTest {
-  @ConfigSuite.Parameter protected Config config;
-
   @ConfigSuite.Config
   public static Config gracefulConfig() {
     Config config = new Config();
@@ -52,7 +51,7 @@
   @Test
   public void nonGracefulCommandIsStoppedImmediately() throws Exception {
     Future<Integer> future = startCommand(false);
-    restart();
+    ((GerritServerTestRule) server).restartKeepSessionOpen();
     assertThat(future.get()).isEqualTo(-1);
   }
 
@@ -61,7 +60,7 @@
     assume().that(isGracefulStopEnabled()).isTrue();
 
     Future<Integer> future = startCommand(true);
-    restart();
+    ((GerritServerTestRule) server).restartKeepSessionOpen();
     assertThat(future.get()).isEqualTo(0);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
index 84c3936..c4497dc 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
+import java.time.Instant;
 import org.junit.Test;
 
 @UseSsh
@@ -123,8 +124,8 @@
     private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
 
     @Override
-    public void log(String operation, long durationMs, Metadata metadata) {
-      logEntries.add(PerformanceLogEntry.create(operation, metadata));
+    public void log(String operation, long durationMs, Instant endTime, Metadata metadata) {
+      logEntries.add(PerformanceLogEntry.create(operation, endTime, metadata));
     }
 
     ImmutableList<PerformanceLogEntry> logEntries() {
@@ -134,12 +135,14 @@
 
   @AutoValue
   abstract static class PerformanceLogEntry {
-    static PerformanceLogEntry create(String operation, Metadata metadata) {
-      return new AutoValue_SshTraceIT_PerformanceLogEntry(operation, metadata);
+    static PerformanceLogEntry create(String operation, Instant endTime, Metadata metadata) {
+      return new AutoValue_SshTraceIT_PerformanceLogEntry(operation, endTime, metadata);
     }
 
     abstract String operation();
 
+    abstract Instant endTime();
+
     abstract Metadata metadata();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
index 2bfc072..8bc457b 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
@@ -194,7 +194,7 @@
     draftInput.message = reviewMessage;
     draftInput.path = path;
     ChangeApi changeApi = gApi.changes().id(change.getId().get());
-    changeApi.current().createDraft(draftInput).get();
+    changeApi.current().createDraft(draftInput);
   }
 
   private void publishDraftReviews() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 5bdf91f..6204115 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
+import com.google.gerrit.truth.OptionalSubject;
 import com.google.inject.Inject;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
@@ -1629,7 +1630,7 @@
 
     TestHumanComment comment = changeOperations.change(changeId).comment(childCommentUuid).get();
 
-    assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
+    OptionalSubject.assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
   }
 
   @Test
@@ -1640,7 +1641,7 @@
 
     TestHumanComment comment = changeOperations.change(changeId).comment(childCommentUuid).get();
 
-    assertThat(comment.tag()).value().isEqualTo("tag");
+    OptionalSubject.assertThat(comment.tag()).value().isEqualTo("tag");
   }
 
   @Test
@@ -1700,7 +1701,7 @@
     TestHumanComment comment =
         changeOperations.change(changeId).draftComment(childCommentUuid).get();
 
-    assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
+    OptionalSubject.assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
   }
 
   @Test
@@ -1730,7 +1731,7 @@
     TestRobotComment comment =
         changeOperations.change(changeId).robotComment(childCommentUuid).get();
 
-    assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
+    OptionalSubject.assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
   }
 
   private ChangeInfo getChangeFromServer(Change.Id changeId) throws RestApiException {
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
index 473b128..3c58797 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 661802e..8e2dfdd 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -67,8 +67,11 @@
   @Test
   public void defaultName() throws Exception {
     Project.NameKey name = projectOperations.newProject().create();
+
     // check that the project was created (throws exception if not found.)
-    gApi.projects().name(name.get());
+    @SuppressWarnings("unused")
+    var unused = gApi.projects().name(name.get());
+
     Project.NameKey name2 = projectOperations.newProject().create();
     assertThat(name2).isNotEqualTo(name);
   }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
index 4241511..988da60 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
@@ -20,7 +20,6 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
@@ -29,6 +28,7 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.util.RequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -48,8 +48,8 @@
   @Test
   public void setApiUserToExistingUserById() throws Exception {
     fastCheckCurrentUser(admin.id());
-    AcceptanceTestRequestScope.Context oldCtx = requestScopeOperations.setApiUser(user.id());
-    assertThat(oldCtx.getUser().getAccountId()).isEqualTo(admin.id());
+    assertThat(localCtx.getContext().getUser().getAccountId()).isEqualTo(admin.id());
+    requestScopeOperations.setApiUser(user.id());
     checkCurrentUser(user.id());
   }
 
@@ -58,8 +58,8 @@
     fastCheckCurrentUser(admin.id());
     TestAccount testAccount =
         accountOperations.account(accountOperations.newAccount().username("tester").create()).get();
-    AcceptanceTestRequestScope.Context oldCtx = requestScopeOperations.setApiUser(testAccount);
-    assertThat(oldCtx.getUser().getAccountId()).isEqualTo(admin.id());
+    assertThat(localCtx.getContext().getUser().getAccountId()).isEqualTo(admin.id());
+    requestScopeOperations.setApiUser(testAccount);
     checkCurrentUser(testAccount.accountId());
   }
 
@@ -95,7 +95,7 @@
     assertWithMessage("user from GerritApi")
         .that(gApi.accounts().self().get()._accountId)
         .isEqualTo(expected.get());
-    AcceptanceTestRequestScope.Context ctx = atrScope.get();
+    RequestContext ctx = localCtx.getContext();
     assertWithMessage("user from AcceptanceTestRequestScope.Context is an IdentifiedUser")
         .that(ctx.getUser().isIdentifiedUser())
         .isTrue();
@@ -115,7 +115,9 @@
     String changeId = gApi.changes().create(cin).get().changeId;
     assertThat(gApi.changes().id(changeId).get().owner._accountId).isEqualTo(expected.get());
     String queryResults =
-        atrScope.get().getSession().exec("gerrit query owner:self change:" + changeId);
+        server
+            .getOrCreateSshSessionForContext(localCtx.getContext())
+            .exec("gerrit query owner:self change:" + changeId);
     assertWithMessage("Change-Ids in query results:\n%s", queryResults)
         .that(findDistinct(queryResults, "I[0-9a-f]{40}"))
         .containsExactly(changeId);
diff --git a/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
index b646d2b..54a8b26 100644
--- a/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
+++ b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
@@ -374,7 +374,7 @@
     assertThat(p.getParameterNames()).hasSize(2);
     assertThat(p.getParameterNames()).containsExactly("patchSet", "branch");
 
-    Map<String, String> params =
+    ImmutableMap<String, String> params =
         ImmutableMap.of(
             "patchSet", "42",
             "branch", "foo");
@@ -388,7 +388,7 @@
   @Test
   public void replaceSubmitTooltipWithoutVariables() {
     ParameterizedString p = new ParameterizedString("Submit patch set 40 into master");
-    Map<String, String> params =
+    ImmutableMap<String, String> params =
         ImmutableMap.of(
             "patchSet", "42",
             "branch", "foo");
diff --git a/javatests/com/google/gerrit/entities/LabelFunctionTest.java b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
index 80d97db..ffbdaf1 100644
--- a/javatests/com/google/gerrit/entities/LabelFunctionTest.java
+++ b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
@@ -70,7 +70,8 @@
 
   @Test
   public void checkMaxNoBlockIgnoresMin() {
-    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_M2, APPROVAL_2, APPROVAL_M2);
+    ImmutableList<PatchSetApproval> approvals =
+        ImmutableList.of(APPROVAL_M2, APPROVAL_2, APPROVAL_M2);
 
     SubmitRecord.Label myLabel = LabelFunction.MAX_NO_BLOCK.check(VERIFIED_LABEL, approvals);
 
@@ -98,7 +99,8 @@
   }
 
   private static void checkBlockWorks(LabelFunction function) {
-    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_M2, APPROVAL_2);
+    ImmutableList<PatchSetApproval> approvals =
+        ImmutableList.of(APPROVAL_1, APPROVAL_M2, APPROVAL_2);
 
     SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
 
@@ -121,7 +123,7 @@
   }
 
   private static void checkMaxIsEnforced(LabelFunction function) {
-    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_0);
+    ImmutableList<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_0);
 
     SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
 
@@ -129,7 +131,8 @@
   }
 
   private static void checkMaxValidatesTheLabel(LabelFunction function) {
-    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_2, APPROVAL_M1);
+    ImmutableList<PatchSetApproval> approvals =
+        ImmutableList.of(APPROVAL_1, APPROVAL_2, APPROVAL_M1);
 
     SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
 
diff --git a/javatests/com/google/gerrit/entities/PatchSetTest.java b/javatests/com/google/gerrit/entities/PatchSetTest.java
index 7e04fe8..e2718c5 100644
--- a/javatests/com/google/gerrit/entities/PatchSetTest.java
+++ b/javatests/com/google/gerrit/entities/PatchSetTest.java
@@ -65,7 +65,11 @@
 
   @Test
   public void testSplitGroups() {
-    assertRuntimeException(() -> splitGroups(null));
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = splitGroups(null);
+        });
     assertThat(splitGroups("")).containsExactly("");
     assertThat(splitGroups("abcd")).containsExactly("abcd");
     assertThat(splitGroups("ab,cd")).containsExactly("ab", "cd").inOrder();
@@ -76,8 +80,16 @@
 
   @Test
   public void testJoinGroups() {
-    assertRuntimeException(() -> joinGroups(null));
-    assertRuntimeException(() -> joinGroups(ImmutableList.of("a,", "b")));
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = joinGroups(null);
+        });
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = joinGroups(ImmutableList.of("a,", "b"));
+        });
     assertThat(joinGroups(ImmutableList.of(""))).isEqualTo("");
     assertThat(joinGroups(ImmutableList.of("abcd"))).isEqualTo("abcd");
     assertThat(joinGroups(ImmutableList.of("ab", "cd"))).isEqualTo("ab,cd");
@@ -126,7 +138,11 @@
   }
 
   private static void assertInvalidId(String str) {
-    assertRuntimeException(() -> PatchSet.Id.parse(str));
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = PatchSet.Id.parse(str);
+        });
   }
 
   private static void assertRuntimeException(Runnable runnable) {
diff --git a/javatests/com/google/gerrit/entities/SubmitRecordTest.java b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
index 578bc18..7f0f42c 100644
--- a/javatests/com/google/gerrit/entities/SubmitRecordTest.java
+++ b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import java.util.ArrayList;
-import java.util.Collection;
+import java.util.List;
 import org.junit.Test;
 
 public class SubmitRecordTest {
@@ -39,7 +39,7 @@
 
   @Test
   public void okIfAllOkay() {
-    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    List<SubmitRecord> submitRecords = new ArrayList<>();
     submitRecords.add(OK_RECORD);
 
     assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
@@ -47,14 +47,14 @@
 
   @Test
   public void okWhenEmpty() {
-    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    List<SubmitRecord> submitRecords = new ArrayList<>();
 
     assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
   }
 
   @Test
   public void okWhenForced() {
-    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    List<SubmitRecord> submitRecords = new ArrayList<>();
     submitRecords.add(FORCED_RECORD);
 
     assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
@@ -62,7 +62,7 @@
 
   @Test
   public void emptyResultIfInvalid() {
-    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    List<SubmitRecord> submitRecords = new ArrayList<>();
     submitRecords.add(NOT_READY_RECORD);
     submitRecords.add(OK_RECORD);
 
diff --git a/javatests/com/google/gerrit/entities/converter/AccountInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/AccountInputProtoConverterTest.java
new file mode 100644
index 0000000..c14e9261
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/AccountInputProtoConverterTest.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.gerrit.entities.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.Type;
+import java.util.List;
+import java.util.Objects;
+import org.junit.Test;
+
+public class AccountInputProtoConverterTest {
+  private final AccountInputProtoConverter accountInputProtoConverter =
+      AccountInputProtoConverter.INSTANCE;
+
+  private AccountInput createAccountInputInstance() {
+    AccountInput accountInput = new AccountInput();
+    accountInput.username = "test-username";
+    accountInput.name = "test-name";
+    accountInput.displayName = "test-display-name";
+    accountInput.email = "test-email@gmail.com";
+    accountInput.sshKey = "test-ssh-key";
+    accountInput.httpPassword = "test-http-password";
+    accountInput.groups = List.of("group1", "group2");
+    return accountInput;
+  }
+
+  private void assertAccountInputEquals(AccountInput expected, AccountInput actual) {
+    assertThat(
+            Objects.equals(expected.username, actual.username)
+                && Objects.equals(expected.name, actual.name)
+                && Objects.equals(expected.displayName, actual.displayName)
+                && Objects.equals(expected.email, actual.email)
+                && Objects.equals(expected.sshKey, actual.sshKey)
+                && Objects.equals(expected.httpPassword, actual.httpPassword)
+                && Objects.equals(expected.groups, actual.groups))
+        .isTrue();
+  }
+
+  @Test
+  public void allValuesConvertedToProto() {
+
+    Entities.AccountInput proto = accountInputProtoConverter.toProto(createAccountInputInstance());
+
+    Entities.AccountInput expectedProto =
+        Entities.AccountInput.newBuilder()
+            .setUsername("test-username")
+            .setName("test-name")
+            .setDisplayName("test-display-name")
+            .setEmail("test-email@gmail.com")
+            .setSshKey("test-ssh-key")
+            .setHttpPassword("test-http-password")
+            .addAllGroups(ImmutableList.of("group1", "group2"))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    AccountInput accountInput = createAccountInputInstance();
+
+    AccountInput convertedaccountInput =
+        accountInputProtoConverter.fromProto(accountInputProtoConverter.toProto(accountInput));
+
+    assertAccountInputEquals(accountInput, convertedaccountInput);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(AccountInput.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("username", String.class)
+                .put("name", String.class)
+                .put("displayName", String.class)
+                .put("email", String.class)
+                .put("sshKey", String.class)
+                .put("httpPassword", String.class)
+                .put("groups", new TypeLiteral<List<String>>() {}.getType())
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java
new file mode 100644
index 0000000..6c8c85a
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.gerrit.entities.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import java.lang.reflect.Type;
+import java.util.Objects;
+import org.junit.Test;
+
+public class ApplyPatchInputProtoConverterTest {
+  private final ApplyPatchInputProtoConverter applyPatchInputProtoConverter =
+      ApplyPatchInputProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    ApplyPatchInput applyPatchInput = new ApplyPatchInput();
+    applyPatchInput.patch = "test-patch";
+    Entities.ApplyPatchInput proto = applyPatchInputProtoConverter.toProto(applyPatchInput);
+
+    Entities.ApplyPatchInput expectedProto =
+        Entities.ApplyPatchInput.newBuilder().setPatch("test-patch").build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ApplyPatchInput applyPatchInput = new ApplyPatchInput();
+    applyPatchInput.patch = "test-patch";
+
+    ApplyPatchInput convertedApplyPatchInput =
+        applyPatchInputProtoConverter.fromProto(
+            applyPatchInputProtoConverter.toProto(applyPatchInput));
+
+    assertThat(Objects.equals(applyPatchInput.patch, convertedApplyPatchInput.patch)).isTrue();
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(ApplyPatchInput.class)
+        .hasFields(ImmutableMap.<String, Type>builder().put("patch", String.class).build());
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/BUILD b/javatests/com/google/gerrit/entities/converter/BUILD
index 6c4d1e4..0ca9478 100644
--- a/javatests/com/google/gerrit/entities/converter/BUILD
+++ b/javatests/com/google/gerrit/entities/converter/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/server",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java
new file mode 100644
index 0000000..7123d42
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/ChangeInputProtoConverterTest.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.gerrit.entities.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.proto.Entities;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.junit.Test;
+
+public class ChangeInputProtoConverterTest {
+  private final ChangeInputProtoConverter changeInputProtoConverter =
+      ChangeInputProtoConverter.INSTANCE;
+  private final MergeInputProtoConverter mergeInputProtoConverter =
+      MergeInputProtoConverter.INSTANCE;
+  private final AccountInputProtoConverter accountInputProtoConverter =
+      AccountInputProtoConverter.INSTANCE;
+
+  // Helper method that creates a MergeInput with all possible value.
+  private MergeInput createMergeInput() {
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "test-source";
+    mergeInput.sourceBranch = "test-source-branch";
+    mergeInput.strategy = "test-strategy";
+    mergeInput.allowConflicts = true;
+    return mergeInput;
+  }
+
+  // Helper method that creates a AccountInput with all possible value.
+  private AccountInput createAccountInput() {
+    AccountInput accountInput = new AccountInput();
+    accountInput.username = "test-username";
+    accountInput.displayName = "test-displayName";
+    accountInput.name = "test-name";
+    accountInput.email = "test-email";
+    accountInput.sshKey = "test-ssh-key";
+    accountInput.httpPassword = "test-http-password";
+    accountInput.groups = ImmutableList.of("test-group");
+    return accountInput;
+  }
+
+  // Helper method that creates a ChangeInput with all possible value.
+  private ChangeInput createChangeInput() {
+    ChangeInput changeInput = new ChangeInput("test-project", "test-branch", "test-subject");
+    changeInput.topic = "test-topic";
+    changeInput.status = ChangeStatus.NEW;
+    changeInput.isPrivate = true;
+    changeInput.workInProgress = true;
+    changeInput.baseChange = "test-base-change";
+    changeInput.baseCommit = "test-base-commit";
+    changeInput.newBranch = true;
+
+    Map<String, String> validationOptions = new HashMap<>();
+    validationOptions.put("test-key", "test-value");
+    changeInput.validationOptions = validationOptions;
+
+    Map<String, String> customKeyedValues = new HashMap<>();
+    customKeyedValues.put("test-key", "test-value");
+    changeInput.customKeyedValues = customKeyedValues;
+
+    changeInput.merge = createMergeInput();
+
+    ApplyPatchInput applyPatchInput = new ApplyPatchInput();
+    applyPatchInput.patch = "test-patch";
+    changeInput.patch = applyPatchInput;
+
+    changeInput.author = createAccountInput();
+
+    changeInput.responseFormatOptions = new ArrayList<ListChangesOption>();
+    changeInput.responseFormatOptions.addAll(
+        ImmutableList.of(ListChangesOption.LABELS, ListChangesOption.DETAILED_LABELS));
+
+    changeInput.notify = NotifyHandling.OWNER;
+
+    Map<RecipientType, NotifyInfo> notifyDetails = new HashMap<>();
+    NotifyInfo notifyInfo = new NotifyInfo(ImmutableList.of("account1", "account2"));
+    notifyDetails.put(RecipientType.TO, notifyInfo);
+    changeInput.notifyDetails = notifyDetails;
+    return changeInput;
+  }
+
+  private void assertAccountInputEquals(AccountInput expected, AccountInput actual) {
+    assertThat(
+            (expected == null && actual == null)
+                || (Objects.equals(expected.username, actual.username)
+                    && Objects.equals(expected.name, actual.name)
+                    && Objects.equals(expected.displayName, actual.displayName)
+                    && Objects.equals(expected.email, actual.email)
+                    && Objects.equals(expected.sshKey, actual.sshKey)
+                    && Objects.equals(expected.httpPassword, actual.httpPassword)
+                    && Objects.equals(expected.groups, actual.groups)))
+        .isTrue();
+  }
+
+  private void assertMergeInputEquals(MergeInput expected, MergeInput actual) {
+    assertThat(
+            (expected == null && actual == null)
+                || (Objects.equals(expected.source, actual.source)
+                    && Objects.equals(expected.sourceBranch, actual.sourceBranch)
+                    && Objects.equals(expected.strategy, actual.strategy)
+                    && expected.allowConflicts == actual.allowConflicts))
+        .isTrue();
+  }
+
+  private void assertChangeInputEquals(ChangeInput expected, ChangeInput actual) {
+    assertThat(
+            Objects.equals(expected.project, actual.project)
+                && Objects.equals(expected.branch, actual.branch)
+                && Objects.equals(expected.subject, actual.subject)
+                && Objects.equals(expected.topic, actual.topic)
+                && Objects.equals(expected.status, actual.status)
+                && Objects.equals(expected.isPrivate, actual.isPrivate)
+                && Objects.equals(expected.workInProgress, actual.workInProgress)
+                && Objects.equals(expected.baseChange, actual.baseChange)
+                && Objects.equals(expected.baseCommit, actual.baseCommit)
+                && Objects.equals(expected.newBranch, actual.newBranch)
+                && Objects.equals(expected.validationOptions, actual.validationOptions)
+                && Objects.equals(expected.customKeyedValues, actual.customKeyedValues)
+                && Objects.equals(expected.responseFormatOptions, actual.responseFormatOptions)
+                && Objects.equals(expected.notify, actual.notify)
+                && Objects.equals(expected.notifyDetails, actual.notifyDetails))
+        .isTrue();
+    assertThat(
+            (expected.patch == null && actual.patch == null)
+                || Objects.equals(expected.patch.patch, actual.patch.patch))
+        .isTrue();
+    assertAccountInputEquals(expected.author, actual.author);
+    assertMergeInputEquals(expected.merge, actual.merge);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProto() {
+    ChangeInput changeInput = new ChangeInput("test-project", "test-branch", "test-subject");
+
+    Entities.ChangeInput proto = changeInputProtoConverter.toProto(changeInput);
+
+    Entities.ChangeInput expectedProto =
+        Entities.ChangeInput.newBuilder()
+            .setProject("test-project")
+            .setBranch("test-branch")
+            .setSubject("test-subject")
+            .setNotify(Entities.NotifyHandling.ALL)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProto() {
+
+    Entities.ChangeInput proto = changeInputProtoConverter.toProto(createChangeInput());
+
+    Entities.ChangeInput.Builder expectedProto =
+        Entities.ChangeInput.newBuilder()
+            .setProject("test-project")
+            .setBranch("test-branch")
+            .setSubject("test-subject")
+            .setTopic("test-topic")
+            .setStatus(Entities.ChangeStatus.NEW)
+            .setBaseChange("test-base-change")
+            .setBaseCommit("test-base-commit")
+            .setNewBranch(true)
+            .setIsPrivate(true)
+            .setWorkInProgress(true)
+            .setPatch(Entities.ApplyPatchInput.newBuilder().setPatch("test-patch").build());
+
+    Map<String, String> validationOptions = new HashMap<>();
+    validationOptions.put("test-key", "test-value");
+    expectedProto.putAllValidationOptions(validationOptions);
+
+    Map<String, String> customKeyedValues = new HashMap<>();
+    customKeyedValues.put("test-key", "test-value");
+    expectedProto.putAllCustomKeyedValues(customKeyedValues);
+
+    expectedProto.setMerge(mergeInputProtoConverter.toProto(createMergeInput()));
+    expectedProto.setAuthor(accountInputProtoConverter.toProto(createAccountInput()));
+
+    expectedProto.addAllResponseFormatOptions(
+        ImmutableList.of(
+            Entities.ListChangesOption.LABELS, Entities.ListChangesOption.DETAILED_LABELS));
+    expectedProto.setNotify(Entities.NotifyHandling.OWNER);
+    Map<String, Entities.NotifyInfo> notifyDetailsProto = new HashMap<>();
+    Entities.NotifyInfo.Builder notifyInfoBuilder =
+        Entities.NotifyInfo.newBuilder().addAllAccounts(ImmutableList.of("account1", "account2"));
+    notifyDetailsProto.put(RecipientType.TO.name(), notifyInfoBuilder.build());
+    expectedProto.putAllNotifyDetails(notifyDetailsProto);
+
+    assertThat(proto).isEqualTo(expectedProto.build());
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProtoAndBackAgain() {
+    ChangeInput changeInput = new ChangeInput("test-project", "test-branch", "test-subject");
+
+    ChangeInput convertedChangeInput =
+        changeInputProtoConverter.fromProto(changeInputProtoConverter.toProto(changeInput));
+
+    assertChangeInputEquals(changeInput, convertedChangeInput);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ChangeInput changeInput = createChangeInput();
+
+    ChangeInput convertedChangeInput =
+        changeInputProtoConverter.fromProto(changeInputProtoConverter.toProto(changeInput));
+
+    assertChangeInputEquals(changeInput, convertedChangeInput);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(ChangeInput.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("project", String.class)
+                .put("branch", String.class)
+                .put("subject", String.class)
+                .put("topic", String.class)
+                .put("status", ChangeStatus.class)
+                .put("isPrivate", Boolean.class)
+                .put("workInProgress", Boolean.class)
+                .put("baseChange", String.class)
+                .put("baseCommit", String.class)
+                .put("newBranch", Boolean.class)
+                .put("validationOptions", new TypeLiteral<Map<String, String>>() {}.getType())
+                .put("customKeyedValues", new TypeLiteral<Map<String, String>>() {}.getType())
+                .put("merge", MergeInput.class)
+                .put("patch", ApplyPatchInput.class)
+                .put("author", AccountInput.class)
+                .put(
+                    "responseFormatOptions",
+                    new TypeLiteral<List<ListChangesOption>>() {}.getType())
+                .put("notify", NotifyHandling.class)
+                .put(
+                    "notifyDetails", new TypeLiteral<Map<RecipientType, NotifyInfo>>() {}.getType())
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index 812a0df..512aac9 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -78,6 +78,7 @@
             .setWorkInProgress(true)
             .setReviewStarted(true)
             .setRevertOf(Entities.Change_Id.newBuilder().setId(180))
+            .setServerId(TEST_SERVER_ID)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
@@ -91,7 +92,6 @@
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             Instant.ofEpochMilli(987654L));
-    change.setServerId(TEST_SERVER_ID);
 
     Entities.Change proto = changeProtoConverter.toProto(change);
 
@@ -151,6 +151,7 @@
             .setIsPrivate(false)
             .setWorkInProgress(false)
             .setReviewStarted(false)
+            .setServerId(TEST_SERVER_ID)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
@@ -189,12 +190,13 @@
             .setIsPrivate(false)
             .setWorkInProgress(false)
             .setReviewStarted(false)
+            .setServerId(TEST_SERVER_ID)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
 
   @Test
-  public void allValuesConvertedToProtoAndBackAgainExceptServerId() {
+  public void allValuesConvertedToProtoAndBackAgainExceptNullServerId() {
     Change change =
         new Change(
             Change.key("change 1"),
@@ -202,7 +204,7 @@
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             Instant.ofEpochMilli(987654L));
-    change.setServerId(TEST_SERVER_ID);
+    change.setServerId(null);
     change.setLastUpdatedOn(Instant.ofEpochMilli(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
@@ -215,11 +217,6 @@
     change.setRevertOf(Change.id(180));
 
     Change convertedChange = changeProtoConverter.fromProto(changeProtoConverter.toProto(change));
-
-    // Change serverId is not one of the protobuf definitions, hence is not supposed to be converted
-    // from proto
-    assertThat(convertedChange.getServerId()).isNull();
-    change.setServerId(null);
     assertEqualChange(convertedChange, change);
   }
 
diff --git a/javatests/com/google/gerrit/entities/converter/HumanCommentProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/HumanCommentProtoConverterTest.java
new file mode 100644
index 0000000..a6aaf36
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/HumanCommentProtoConverterTest.java
@@ -0,0 +1,130 @@
+// 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.entities.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.HumanComment;
+import java.time.Instant;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class HumanCommentProtoConverterTest {
+  private static final ObjectId VALID_OBJECT_ID =
+      ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+
+  private final HumanCommentProtoConverter converter = HumanCommentProtoConverter.INSTANCE;
+
+  @Test
+  public void fileLevelCommentWithAllOptionalFields() {
+    HumanComment orig =
+        new HumanComment(
+            new Comment.Key("uuid", "a.txt", 42),
+            Account.id(314),
+            Instant.ofEpochMilli(12345),
+            (short) 1,
+            "message",
+            "server",
+            /* unresolved= */ true);
+    orig.tag = "tag";
+    orig.setCommitId(VALID_OBJECT_ID);
+    orig.setRealAuthor(Account.id(271));
+
+    HumanComment res = converter.fromProto(converter.toProto(orig));
+
+    assertThat(res).isEqualTo(orig);
+  }
+
+  @Test
+  public void patchsetLevelComment() {
+    HumanComment orig =
+        new HumanComment(
+            new Comment.Key("uuid", PATCHSET_LEVEL, 42),
+            Account.id(314),
+            Instant.ofEpochMilli(12345),
+            (short) 1,
+            "message",
+            "server",
+            /* unresolved= */ false);
+
+    HumanComment res = converter.fromProto(converter.toProto(orig));
+
+    assertThat(res).isEqualTo(orig);
+  }
+
+  @Test
+  public void lineComment() {
+    HumanComment orig =
+        new HumanComment(
+            new Comment.Key("uuid", "a.txt", 42),
+            Account.id(314),
+            Instant.ofEpochMilli(12345),
+            (short) 1,
+            "message",
+            "server",
+            /* unresolved= */ true);
+    orig.setLineNbrAndRange(7, null);
+
+    HumanComment res = converter.fromProto(converter.toProto(orig));
+
+    assertThat(res).isEqualTo(orig);
+  }
+
+  @Test
+  public void rangeComment() {
+    HumanComment orig =
+        new HumanComment(
+            new Comment.Key("uuid", "a.txt", 42),
+            Account.id(314),
+            Instant.ofEpochMilli(12345),
+            (short) 1,
+            "message",
+            "server",
+            /* unresolved= */ true);
+    orig.setRange(new CommentRange(2, 3, 5, 7));
+
+    HumanComment res = converter.fromProto(converter.toProto(orig));
+
+    assertThat(res).isEqualTo(orig);
+  }
+
+  @Test
+  public void extensionRangeComment() {
+    HumanComment orig =
+        new HumanComment(
+            new Comment.Key("uuid", "a.txt", 42),
+            Account.id(314),
+            Instant.ofEpochMilli(12345),
+            (short) 1,
+            "message",
+            "server",
+            /* unresolved= */ false);
+    com.google.gerrit.extensions.client.Comment.Range range =
+        new com.google.gerrit.extensions.client.Comment.Range();
+    range.startLine = 2;
+    range.startCharacter = 3;
+    range.endLine = 5;
+    range.endCharacter = 7;
+    orig.setLineNbrAndRange(null, range);
+
+    HumanComment res = converter.fromProto(converter.toProto(orig));
+
+    assertThat(res).isEqualTo(orig);
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/MergeInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/MergeInputProtoConverterTest.java
new file mode 100644
index 0000000..d625545
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/MergeInputProtoConverterTest.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.gerrit.entities.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import java.lang.reflect.Type;
+import java.util.Objects;
+import org.junit.Test;
+
+public class MergeInputProtoConverterTest {
+  private final MergeInputProtoConverter mergeInputProtoConverter =
+      MergeInputProtoConverter.INSTANCE;
+
+  // Helper method that creates a MergeInput with all possible value.
+  private MergeInput createMergeInput() {
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "test-source";
+    mergeInput.sourceBranch = "test-source-branch";
+    mergeInput.strategy = "test-strategy";
+    mergeInput.allowConflicts = true;
+    return mergeInput;
+  }
+
+  private void assertMergeInputEquals(MergeInput expected, MergeInput actual) {
+    assertThat(
+            Objects.equals(expected.source, actual.source)
+                && Objects.equals(expected.sourceBranch, actual.sourceBranch)
+                && Objects.equals(expected.strategy, actual.strategy)
+                && expected.allowConflicts == actual.allowConflicts)
+        .isTrue();
+  }
+
+  @Test
+  public void allValuesConvertedToProto() {
+
+    Entities.MergeInput proto = mergeInputProtoConverter.toProto(createMergeInput());
+
+    Entities.MergeInput expectedProto =
+        Entities.MergeInput.newBuilder()
+            .setSource("test-source")
+            .setSourceBranch("test-source-branch")
+            .setStrategy("test-strategy")
+            .setAllowConflicts(true)
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    MergeInput mergeInput = createMergeInput();
+
+    MergeInput convertedMergeInput =
+        mergeInputProtoConverter.fromProto(mergeInputProtoConverter.toProto(mergeInput));
+
+    assertMergeInputEquals(mergeInput, convertedMergeInput);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(MergeInput.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("source", String.class)
+                .put("sourceBranch", String.class)
+                .put("strategy", String.class)
+                .put("allowConflicts", boolean.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/NotifyInfoProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/NotifyInfoProtoConverterTest.java
new file mode 100644
index 0000000..db72350
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/NotifyInfoProtoConverterTest.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.gerrit.entities.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.Type;
+import java.util.List;
+import org.junit.Test;
+
+public class NotifyInfoProtoConverterTest {
+  private final NotifyInfoProtoConverter notifyInfoProtoConverter =
+      NotifyInfoProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    NotifyInfo notifyInfo = new NotifyInfo(ImmutableList.of("account1", "account2"));
+    Entities.NotifyInfo proto = notifyInfoProtoConverter.toProto(notifyInfo);
+
+    Entities.NotifyInfo expectedProto =
+        Entities.NotifyInfo.newBuilder()
+            .addAllAccounts(ImmutableList.of("account1", "account2"))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    NotifyInfo notifyInfo = new NotifyInfo(ImmutableList.of("account1", "account2"));
+
+    NotifyInfo convertedNotifyInfo =
+        notifyInfoProtoConverter.fromProto(notifyInfoProtoConverter.toProto(notifyInfo));
+
+    assertThat(convertedNotifyInfo).isEqualTo(notifyInfo);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void methodsExistAsExpected() {
+    assertThatSerializedClass(NotifyInfo.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("accounts", new TypeLiteral<List<String>>() {}.getType())
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
index 3704969..f8e7d80 100644
--- a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -584,7 +584,8 @@
 
   @Test
   public void getDiff_assertCanConstructAllChangeInfoReferences() throws Exception {
-    buildObjectWithFullFields(ChangeInfo.class);
+    @SuppressWarnings("unused")
+    var unused = buildObjectWithFullFields(ChangeInfo.class);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/git/ObjectIdsTest.java b/javatests/com/google/gerrit/git/ObjectIdsTest.java
index b254d6f..a011bba 100644
--- a/javatests/com/google/gerrit/git/ObjectIdsTest.java
+++ b/javatests/com/google/gerrit/git/ObjectIdsTest.java
@@ -41,7 +41,11 @@
 
   @Test
   public void abbreviateNameDefaultLength() throws Exception {
-    assertRuntimeException(() -> abbreviateName(null));
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = abbreviateName(null);
+        });
     assertThat(abbreviateName(ID)).isEqualTo("0000000");
     assertThat(abbreviateName(AMBIGUOUS_BLOB_ID)).isEqualTo(abbreviateName(ID));
     assertThat(abbreviateName(AMBIGUOUS_TREE_ID)).isEqualTo(abbreviateName(ID));
@@ -49,17 +53,37 @@
 
   @Test
   public void abbreviateNameCustomLength() throws Exception {
-    assertRuntimeException(() -> abbreviateName(null, 1));
-    assertRuntimeException(() -> abbreviateName(ID, -1));
-    assertRuntimeException(() -> abbreviateName(ID, 0));
-    assertRuntimeException(() -> abbreviateName(ID, 41));
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = abbreviateName(null, 1);
+        });
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = abbreviateName(ID, -1);
+        });
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = abbreviateName(ID, 0);
+        });
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = abbreviateName(ID, 41);
+        });
     assertThat(abbreviateName(ID, 5)).isEqualTo("00000");
     assertThat(abbreviateName(ID, 40)).isEqualTo(ID.name());
   }
 
   @Test
   public void abbreviateNameDefaultLengthWithReader() throws Exception {
-    assertRuntimeException(() -> abbreviateName(ID, null));
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = abbreviateName(ID, null);
+        });
 
     ObjectReader reader = newReaderWithAmbiguousIds();
     assertThat(abbreviateName(ID, reader)).isEqualTo("00000000001");
@@ -68,10 +92,26 @@
   @Test
   public void abbreviateNameCustomLengthWithReader() throws Exception {
     ObjectReader reader = newReaderWithAmbiguousIds();
-    assertRuntimeException(() -> abbreviateName(ID, -1, reader));
-    assertRuntimeException(() -> abbreviateName(ID, 0, reader));
-    assertRuntimeException(() -> abbreviateName(ID, 41, reader));
-    assertRuntimeException(() -> abbreviateName(ID, 5, null));
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = abbreviateName(ID, -1, reader);
+        });
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = abbreviateName(ID, 0, reader);
+        });
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = abbreviateName(ID, 41, reader);
+        });
+    assertRuntimeException(
+        () -> {
+          @SuppressWarnings("unused")
+          var unused = abbreviateName(ID, 5, null);
+        });
 
     String shortest = "00000000001";
     assertThat(abbreviateName(ID, 1, reader)).isEqualTo(shortest);
diff --git a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index 05e9808..1f6259f 100644
--- a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -28,6 +28,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterators;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
 import com.google.gerrit.gpg.testing.TestKey;
@@ -112,7 +113,8 @@
         .update("Set Preferred Email", userId, u -> u.setPreferredEmail("user@example.com"));
     user = reloadUser();
 
-    requestContext.setContext(() -> user);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(() -> user);
 
     storeRepo = new InMemoryRepository(new DfsRepositoryDescription("repo"));
     store = new PublicKeyStore(storeRepo);
@@ -130,6 +132,7 @@
     return userFactory.create(id);
   }
 
+  @CanIgnoreReturnValue
   private IdentifiedUser reloadUser() {
     user = userFactory.create(userId);
     return user;
@@ -389,6 +392,7 @@
     accountsUpdateProvider.get().update("Add External IDs", id, u -> u.addExternalIds(newExtIds));
   }
 
+  @CanIgnoreReturnValue
   private TestKey add(TestKey k, IdentifiedUser user) throws Exception {
     add(k.getPublicKeyRing(), user);
     return k;
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
index c360b2f..6faebab 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
@@ -37,6 +37,7 @@
 import static org.bouncycastle.openpgp.PGPSignature.DIRECT_KEY;
 import static org.junit.Assert.assertEquals;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.gpg.testing.TestKey;
 import java.time.Instant;
 import java.time.ZoneId;
@@ -298,6 +299,7 @@
     return new PublicKeyChecker().enableTrust(maxTrustDepth, fps).setStore(store);
   }
 
+  @CanIgnoreReturnValue
   private TestKey add(TestKey k) {
     store.add(k.getPublicKeyRing());
     return k;
diff --git a/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
index 4932248..41b2b9f 100644
--- a/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
+++ b/javatests/com/google/gerrit/httpd/AllRequestFilterFilterProxyTest.java
@@ -21,6 +21,7 @@
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.ReloadableRegistrationHandle;
 import com.google.gerrit.server.plugins.Plugin;
@@ -76,6 +77,7 @@
    * <p>This method adds the given filter to all {@link AllRequestFilter.FilterProxy} instances
    * created by {@link #getFilterProxy()}.
    */
+  @CanIgnoreReturnValue
   private ReloadableRegistrationHandle<AllRequestFilter> addFilter(AllRequestFilter filter) {
     Key<AllRequestFilter> key = Key.get(AllRequestFilter.class);
     return filters.add("gerrit", key, Providers.of(filter));
diff --git a/javatests/com/google/gerrit/httpd/gitweb/GitwebServletTest.java b/javatests/com/google/gerrit/httpd/gitweb/GitwebServletTest.java
index d1598b9..257a5a1 100644
--- a/javatests/com/google/gerrit/httpd/gitweb/GitwebServletTest.java
+++ b/javatests/com/google/gerrit/httpd/gitweb/GitwebServletTest.java
@@ -71,7 +71,7 @@
     gitWebConfig = mock(GitwebConfig.class);
     allProjectsName = new AllProjectsName(AllProjectsNameProvider.DEFAULT);
     // All-Projects must exist prior to calling GitwebServlet ctor
-    repoManager.createRepository(allProjectsName);
+    repoManager.createRepository(allProjectsName).close();
     servlet =
         new GitwebServlet(
             repoManager,
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
index c06d231..6f8e73a 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -116,7 +116,7 @@
 
     assertThat(dynamicTemplateData(gerritApi, "/c/project/+/123"))
         .containsAtLeast(
-            "defaultChangeDetailHex", "9916394",
+            "defaultChangeDetailHex", "9996394",
             "changeRequestsPath", "changes/project~123");
   }
 
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
index 9f8f494..291a064 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.httpd.raw.IndexPreloadingUtil.computeChangeRequestsPath;
 import static com.google.gerrit.httpd.raw.IndexPreloadingUtil.parseRequestedPage;
 
diff --git a/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java b/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java
new file mode 100644
index 0000000..28ec30d
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java
@@ -0,0 +1,33 @@
+// 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.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+
+public class StaticModuleTest {
+
+  @Test
+  public void doNotMatchPolyGerritIndex() {
+    ImmutableList.of(
+            "/c/123456/anyString",
+            "/123456/anyString",
+            "/c/123456/comment/9ab75172_67d798e1",
+            "/123456/comment/9ab75172_67d798e1")
+        .forEach(url -> assertThat(StaticModule.PolyGerritFilter.isPolyGerritIndex(url)).isFalse());
+  }
+}
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeTest.java b/javatests/com/google/gerrit/index/IndexUpgradeTest.java
index 724964b..27abdad 100644
--- a/javatests/com/google/gerrit/index/IndexUpgradeTest.java
+++ b/javatests/com/google/gerrit/index/IndexUpgradeTest.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
-import java.util.Collection;
 import java.util.Map.Entry;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -43,7 +42,7 @@
   @Parameter public SchemaDefinitions<?> schemaDefinitions;
 
   @Parameters(name = "schema: {0}")
-  public static Collection<SchemaDefinitions<?>> indexes() {
+  public static ImmutableList<SchemaDefinitions<?>> indexes() {
     return ImmutableList.of(
         AccountSchemaDefinitions.INSTANCE,
         ChangeSchemaDefinitions.INSTANCE,
diff --git a/javatests/com/google/gerrit/index/SchemaUtilTest.java b/javatests/com/google/gerrit/index/SchemaUtilTest.java
index 4f67f8e..a92c13e 100644
--- a/javatests/com/google/gerrit/index/SchemaUtilTest.java
+++ b/javatests/com/google/gerrit/index/SchemaUtilTest.java
@@ -21,7 +21,7 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
-import java.util.Map;
+import com.google.common.collect.ImmutableMap;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -53,7 +53,8 @@
 
   @Test
   public void schemasFromClassBuildsMap() {
-    Map<Integer, Schema<String>> all = SchemaUtil.schemasFromClass(TestSchemas.class, String.class);
+    ImmutableMap<Integer, Schema<String>> all =
+        SchemaUtil.schemasFromClass(TestSchemas.class, String.class);
     assertThat(all.keySet()).containsExactly(1, 2, 4);
     assertThat(all.get(1)).isEqualTo(TestSchemas.V1);
     assertThat(all.get(2)).isEqualTo(TestSchemas.V2);
diff --git a/javatests/com/google/gerrit/index/query/AndPredicateTest.java b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
index 860a9fd..fc53031 100644
--- a/javatests/com/google/gerrit/index/query/AndPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
@@ -98,8 +98,8 @@
     final TestPredicate<String> a = f("author", "alice");
     final TestPredicate<String> b = f("author", "bob");
     final TestPredicate<String> c = f("author", "charlie");
-    final List<TestPredicate<String>> s2 = ImmutableList.of(a, b);
-    final List<TestPredicate<String>> s3 = ImmutableList.of(a, b, c);
+    final ImmutableList<TestPredicate<String>> s2 = ImmutableList.of(a, b);
+    final ImmutableList<TestPredicate<String>> s3 = ImmutableList.of(a, b, c);
     final Predicate<String> n2 = and(a, b);
 
     assertNotSame(n2, n2.copy(s2));
diff --git a/javatests/com/google/gerrit/index/query/OrPredicateTest.java b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
index e5c9672..4b00a52 100644
--- a/javatests/com/google/gerrit/index/query/OrPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
@@ -98,8 +98,8 @@
     final TestPredicate<String> a = f("author", "alice");
     final TestPredicate<String> b = f("author", "bob");
     final TestPredicate<String> c = f("author", "charlie");
-    final List<TestPredicate<String>> s2 = ImmutableList.of(a, b);
-    final List<TestPredicate<String>> s3 = ImmutableList.of(a, b, c);
+    final ImmutableList<TestPredicate<String>> s2 = ImmutableList.of(a, b);
+    final ImmutableList<TestPredicate<String>> s3 = ImmutableList.of(a, b, c);
     final Predicate<String> n2 = or(a, b);
 
     assertNotSame(n2, n2.copy(s2));
diff --git a/javatests/com/google/gerrit/index/query/QueryBuilderTest.java b/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
index eb3358d..42148da 100644
--- a/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryBuilderTest.java
@@ -114,7 +114,8 @@
 
   private static ThrowableSubject assertThatParseException(String query) {
     try {
-      new TestQueryBuilder().parse(query);
+      @SuppressWarnings("unused")
+      var unused = new TestQueryBuilder().parse(query);
       throw new AssertionError("expected QueryParseException for " + query);
     } catch (QueryParseException e) {
       return assertThat(e);
diff --git a/javatests/com/google/gerrit/index/query/QueryProcessorTest.java b/javatests/com/google/gerrit/index/query/QueryProcessorTest.java
new file mode 100644
index 0000000..38c610a
--- /dev/null
+++ b/javatests/com/google/gerrit/index/query/QueryProcessorTest.java
@@ -0,0 +1,151 @@
+// 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.query;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+
+import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.IndexRewriter;
+import com.google.gerrit.index.SchemaDefinitions;
+import java.util.function.IntSupplier;
+import org.junit.Test;
+import org.mockito.Mock;
+
+public class QueryProcessorTest {
+
+  private boolean noLimit = false;
+
+  private int userQueryLimit = 1000;
+
+  private int userProvidedLimit = 0;
+
+  private int maxLimit = 5000;
+
+  private int defaultLimit = Integer.MAX_VALUE;
+
+  private String limitField = null;
+
+  @Mock private SchemaDefinitions<String> schemaDef;
+
+  @Mock private IndexCollection<?, String, ?> indexes;
+
+  @Mock private IndexRewriter<String> rewriter;
+
+  public QueryProcessor<String> createProcessor() {
+    QueryProcessor.Metrics metrics = mock(QueryProcessor.Metrics.class);
+    IndexConfig indexConfig =
+        IndexConfig.builder().maxLimit(maxLimit).defaultLimit(defaultLimit).build();
+    IntSupplier userQueryLimit =
+        new IntSupplier() {
+          @Override
+          public int getAsInt() {
+            return QueryProcessorTest.this.userQueryLimit;
+          }
+        };
+
+    QueryProcessor<String> processor =
+        new QueryProcessor<>(
+            metrics, schemaDef, indexConfig, indexes, rewriter, limitField, userQueryLimit) {
+          @Override
+          protected Predicate<String> enforceVisibility(Predicate<String> pred) {
+            return Predicate.any();
+          }
+
+          @Override
+          protected String formatForLogging(String o) {
+            return "";
+          }
+        };
+    processor.setNoLimit(noLimit);
+    processor.setUserProvidedLimit(userProvidedLimit, /* applyDefaultLimit */ true);
+    return processor;
+  }
+
+  @Test
+  public void getEffectiveLimit() {
+    assertThat(createProcessor().getEffectiveLimit(Predicate.any())).isEqualTo(1000);
+  }
+
+  @Test
+  public void getEffectiveLimit_UserQueryLimit() {
+    userQueryLimit = 314;
+    assertThat(createProcessor().getEffectiveLimit(Predicate.any())).isEqualTo(314);
+  }
+
+  @Test
+  public void getEffectiveLimit_UserProvidedLimit() {
+    userProvidedLimit = 314;
+    assertThat(createProcessor().getEffectiveLimit(Predicate.any())).isEqualTo(314);
+  }
+
+  @Test
+  public void getEffectiveLimit_MaxLimit() {
+    maxLimit = 314;
+    assertThat(createProcessor().getEffectiveLimit(Predicate.any())).isEqualTo(314);
+  }
+
+  @Test
+  public void getEffectiveLimit_DefaultLimit() {
+    defaultLimit = 314;
+    assertThat(createProcessor().getEffectiveLimit(Predicate.any())).isEqualTo(314);
+
+    // Prefer user provided limit over default limit.
+    userProvidedLimit = 333;
+    assertThat(createProcessor().getEffectiveLimit(Predicate.any())).isEqualTo(333);
+  }
+
+  @Test
+  public void getEffectiveLimit_LimitField() throws QueryParseException {
+    limitField = "limit";
+    assertThat(createProcessor().getEffectiveLimit(new LimitPredicate<>(limitField, 314)))
+        .isEqualTo(314);
+  }
+
+  @Test
+  public void getEffectiveLimit_SmallestWins() throws QueryParseException {
+    limitField = "limit";
+    int[] limits = {271, 314, 499, 666};
+
+    LimitPredicate<String> p = null;
+    for (int i = 0; i < 4; i++) {
+      userProvidedLimit = limits[0];
+      userQueryLimit = limits[1];
+      maxLimit = limits[2];
+      p = new LimitPredicate<>(limitField, limits[3]);
+
+      // "rotate" the array of limits
+      int l = limits[0];
+      for (int j = 0; j < 3; j++) limits[j] = limits[j + 1];
+      limits[3] = l;
+
+      assertThat(createProcessor().getEffectiveLimit(p)).isEqualTo(271);
+    }
+  }
+
+  @Test
+  public void getEffectiveLimit_NoLimit() {
+    noLimit = true;
+    assertThat(createProcessor().getEffectiveLimit(Predicate.any())).isEqualTo(Integer.MAX_VALUE);
+
+    // noLimit has precedence over all other limits
+    userProvidedLimit = 1;
+    userQueryLimit = 1;
+    maxLimit = 1;
+    defaultLimit = 1;
+    assertThat(createProcessor().getEffectiveLimit(Predicate.any())).isEqualTo(Integer.MAX_VALUE);
+  }
+}
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index d40f2a1..f33dc8d 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -127,11 +127,11 @@
           "ssh://%s@" + sshAddress.getHostName() + ":" + sshAddress.getPort() + "/" + project.get();
 
       // Admin user was already created by the base class
-      setUpUserAuthentication(admin.username());
+      setUpUserAuthentication(admin.username(), admin.id().get());
 
       // Create non-admin user
       TestAccount user = accountCreator.user1();
-      setUpUserAuthentication(user.username());
+      setUpUserAuthentication(user.username(), user.id().get());
 
       // Prepare data for new change on master branch
       ChangeInput in = new ChangeInput(project.get(), "master", "Test public change");
@@ -271,7 +271,7 @@
       ctx.getInjector().injectMembers(this);
 
       // Setup admin password
-      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+      gApi.accounts().id(admin.id().get()).setHttpPassword(ADMIN_PASSWORD);
 
       // Get authenticated Git/HTTP URL
       String urlWithCredentials =
@@ -336,7 +336,7 @@
       ctx.getInjector().injectMembers(this);
 
       // Setup admin password
-      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+      gApi.accounts().id(admin.id().get()).setHttpPassword(ADMIN_PASSWORD);
 
       // Get authenticated Git/HTTP URL
       String urlWithCredentials =
@@ -405,7 +405,7 @@
       ctx.getInjector().injectMembers(this);
 
       // Setup admin password
-      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+      gApi.accounts().id(admin.id().get()).setHttpPassword(ADMIN_PASSWORD);
 
       // Get authenticated Git/HTTP URL
       String urlWithCredentials =
@@ -488,7 +488,7 @@
       ctx.getInjector().injectMembers(this);
 
       // Setup admin password
-      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+      gApi.accounts().id(admin.id().get()).setHttpPassword(ADMIN_PASSWORD);
 
       String url = config.getString("gerrit", null, "canonicalweburl");
 
@@ -560,9 +560,9 @@
     }
   }
 
-  private void setUpUserAuthentication(String username) throws Exception {
+  private void setUpUserAuthentication(String username, int accountId) throws Exception {
     // Assign HTTP password to user
-    gApi.accounts().id(username).setHttpPassword(ADMIN_PASSWORD);
+    gApi.accounts().id(accountId).setHttpPassword(ADMIN_PASSWORD);
 
     // Generate private/public key for user
     execute(
@@ -573,7 +573,7 @@
 
     // Read the content of generated public key and add it for the user in Gerrit
     gApi.accounts()
-        .id(username)
+        .id(accountId)
         .addSshKey(
             new String(
                 java.nio.file.Files.readAllBytes(
diff --git a/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
index ad8a486..78fd35f 100644
--- a/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
+++ b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
@@ -65,7 +65,7 @@
     ctx.getInjector().injectMembers(this);
 
     TestAccount user = accountCreator.user1();
-    gApi.accounts().id(user.username()).setHttpPassword(PASSWORD);
+    gApi.accounts().id(user.id().get()).setHttpPassword(PASSWORD);
 
     String canonical = config.getString("gerrit", null, "canonicalweburl");
     repoUrl =
diff --git a/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java b/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java
index c42f00d..6ccc7cd 100644
--- a/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java
+++ b/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java
@@ -55,7 +55,7 @@
       ctx.getInjector().injectMembers(this);
 
       // Setup admin password
-      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+      gApi.accounts().id(admin.id().get()).setHttpPassword(ADMIN_PASSWORD);
 
       // Get authenticated Git/HTTP URL
       String urlWithCredentials =
diff --git a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
index c720905..573c050 100644
--- a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
@@ -110,12 +110,12 @@
         outputPath);
     try (InputStream fi = Files.newInputStream(outputPath);
         InputStream bi = new BufferedInputStream(fi);
-        ArchiveInputStream archive = archiveStreamForFormat(bi, format)) {
+        ArchiveInputStream<?> archive = archiveStreamForFormat(bi, format)) {
       assertEntries(archive);
     }
   }
 
-  private ArchiveInputStream archiveStreamForFormat(InputStream bi, String format)
+  private ArchiveInputStream<?> archiveStreamForFormat(InputStream bi, String format)
       throws IOException {
     switch (format) {
       case "zip":
@@ -154,7 +154,7 @@
             .add(String.format("id_rsa_%s", admin.username()))
             .build());
     gApi.accounts()
-        .id(admin.username())
+        .id(admin.id().get())
         .addSshKey(
             new String(
                 java.nio.file.Files.readAllBytes(
diff --git a/javatests/com/google/gerrit/integration/ssh/NoShellIT.java b/javatests/com/google/gerrit/integration/ssh/NoShellIT.java
index 2bbbf1a..f25db6e 100644
--- a/javatests/com/google/gerrit/integration/ssh/NoShellIT.java
+++ b/javatests/com/google/gerrit/integration/ssh/NoShellIT.java
@@ -66,7 +66,7 @@
             .add(String.format("id_rsa_%s", "admin"))
             .build());
     gApi.accounts()
-        .id("admin")
+        .id(admin.id().get())
         .addSshKey(
             new String(
                 java.nio.file.Files.readAllBytes(
diff --git a/javatests/com/google/gerrit/mail/MailHeaderParserTest.java b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
index 7e3edab..3e1c046 100644
--- a/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
+++ b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
@@ -39,7 +39,7 @@
     b.addAdditionalHeader(
         MailHeader.COMMENT_DATE.fieldWithDelimiter() + "Tue, 25 Oct 2016 02:11:35 -0700");
 
-    Address author = Address.create("Diffy", "test@gerritcodereview.com");
+    Address author = Address.create("Diffy", "test@example.com");
     b.from(author);
 
     MailMetadata meta = MailHeaderParser.parse(b.build());
@@ -72,7 +72,7 @@
         .append("Tue, 25 Oct 2016 02:11:35 -0700\r\n");
     b.textContent(stringBuilder.toString());
 
-    Address author = Address.create("Diffy", "test@gerritcodereview.com");
+    Address author = Address.create("Diffy", "test@example.com");
     b.from(author);
 
     MailMetadata meta = MailHeaderParser.parse(b.build());
@@ -113,7 +113,7 @@
         .append("</div>");
     b.htmlContent(stringBuilder.toString());
 
-    Address author = Address.create("Diffy", "test@gerritcodereview.com");
+    Address author = Address.create("Diffy", "test@example.com");
     b.from(author);
 
     MailMetadata meta = MailHeaderParser.parse(b.build());
diff --git a/javatests/com/google/gerrit/metrics/FieldSanitizeProjectNameTest.java b/javatests/com/google/gerrit/metrics/FieldSanitizeProjectNameTest.java
index f12b1b3..196a1b4 100644
--- a/javatests/com/google/gerrit/metrics/FieldSanitizeProjectNameTest.java
+++ b/javatests/com/google/gerrit/metrics/FieldSanitizeProjectNameTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import java.util.Arrays;
-import java.util.Collection;
+import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
@@ -25,7 +25,7 @@
 @RunWith(Parameterized.class)
 public class FieldSanitizeProjectNameTest {
   @Parameterized.Parameters
-  public static Collection<Object[]> testData() {
+  public static List<Object[]> testData() {
     return Arrays.asList(
         new Object[][] {
           {"repoName", "repoName"},
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
index a50520ac..359f915 100644
--- a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
+++ b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
@@ -85,9 +85,13 @@
   public void shouldRequestForReservoirForNewTimer() throws Exception {
     when(reservoirConfigMock.reservoirType()).thenReturn(ReservoirType.ExponentiallyDecaying);
 
-    metrics.newTimer(
-        "foo",
-        new Description("foo description").setCumulative().setUnit(Description.Units.MILLISECONDS));
+    @SuppressWarnings("unused")
+    var unused =
+        metrics.newTimer(
+            "foo",
+            new Description("foo description")
+                .setCumulative()
+                .setUnit(Description.Units.MILLISECONDS));
 
     verify(reservoirConfigMock).reservoirType();
   }
diff --git a/javatests/com/google/gerrit/pgm/BUILD b/javatests/com/google/gerrit/pgm/BUILD
index 0fe4fad..43b86cb 100644
--- a/javatests/com/google/gerrit/pgm/BUILD
+++ b/javatests/com/google/gerrit/pgm/BUILD
@@ -7,6 +7,7 @@
     deps = [
         "//java/com/google/gerrit/pgm/http/jetty",
         "//java/com/google/gerrit/pgm/init/api",
+        "//java/com/google/gerrit/pgm/util",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/securestore/testing",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/pgm/util/LogFileManagerTest.java b/javatests/com/google/gerrit/pgm/util/LogFileManagerTest.java
new file mode 100644
index 0000000..b3f59cc
--- /dev/null
+++ b/javatests/com/google/gerrit/pgm/util/LogFileManagerTest.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.config.SitePaths;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class LogFileManagerTest {
+
+  @Test
+  public void testLogFilePattern() throws Exception {
+    List<String> filenamesWithDate =
+        List.of(
+            "error_log.2024-01-01",
+            "error_log.2024-01-01.gz",
+            "error_log.json.2024-01-01",
+            "error_log.json.2024-01-01.gz",
+            "sshd_log.2024-01-01",
+            "httpd_log.2024-01-01");
+
+    List<String> filenamesWithoutDate =
+        List.of(
+            "error_log",
+            "error_log.gz",
+            "error_log.json",
+            "error_log.json.gz",
+            "sshd_log",
+            "httpd_log");
+
+    LogFileManager manager = new LogFileManager(new SitePaths(Path.of("/gerrit")), new Config());
+    Instant expected = Instant.parse("2024-01-01T00:00:00.00Z");
+    for (String filename : filenamesWithDate) {
+      assertThat(manager.getDateFromFilename(Path.of(filename)).get()).isEqualTo(expected);
+    }
+
+    for (String filename : filenamesWithoutDate) {
+      assertThat(manager.getDateFromFilename(Path.of(filename)).isEmpty()).isTrue();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 0c84093..fd8ddd2 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -59,6 +59,7 @@
         "//java/com/google/gerrit/server/cache/testing",
         "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/fixes/testing",
+        "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/git/receive:ref_cache",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
@@ -66,6 +67,7 @@
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/schema/testing",
+        "//java/com/google/gerrit/server/util/git",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/sshd",
         "//java/com/google/gerrit/testing:assertable-executor",
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index ce045f7..f726be3 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -103,13 +103,15 @@
     Account account =
         Account.builder(Account.id(1), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
+            .setUniqueTag("1234567812345678123456781234567812345678")
             .build();
     Account.Id ownerId = account.id();
 
     identifiedUser = identifiedUserFactory.create(ownerId);
 
     /* Trigger identifiedUser to load the email addresses from mockRealm */
-    identifiedUser.getEmailAddresses();
+    @SuppressWarnings("unused")
+    var unused = identifiedUser.getEmailAddresses();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/account/AccountCacheTest.java b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
index 6628362..45eff9a 100644
--- a/javatests/com/google/gerrit/server/account/AccountCacheTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
@@ -41,12 +41,15 @@
 
   @Test
   public void account_roundTrip() throws Exception {
+    // The uniqueTag and metaId can be different (in google internal implementation).
+    // This tests ensures that they are serialized/deserialized separately.
     Account account =
         Account.builder(Account.id(1), Instant.EPOCH)
             .setFullName("foo bar")
             .setDisplayName("foo")
             .setActive(false)
             .setMetaId("dead..beef")
+            .setUniqueTag("dead..beef..tag")
             .setStatus("OOO")
             .setPreferredEmail("foo@bar.tld")
             .build();
@@ -63,6 +66,7 @@
                     .setDisplayName("foo")
                     .setInactive(true)
                     .setMetaId("dead..beef")
+                    .setUniqueTag("dead..beef..tag")
                     .setStatus("OOO")
                     .setPreferredEmail("foo@bar.tld"))
             .build();
@@ -71,6 +75,39 @@
   }
 
   @Test
+  public void account_deserializeOldRecordWithoutUniqueTag() throws Exception {
+    Account.Builder builder =
+        Account.builder(Account.id(1), Instant.EPOCH)
+            .setFullName("foo bar")
+            .setDisplayName("foo")
+            .setActive(false)
+            .setMetaId("dead..beef")
+            .setStatus("OOO")
+            .setPreferredEmail("foo@bar.tld");
+    CachedAccountDetails original =
+        CachedAccountDetails.create(builder.build(), ImmutableMap.of(), CachedPreferences.EMPTY);
+    CachedAccountDetails expected =
+        CachedAccountDetails.create(
+            builder.setUniqueTag("dead..beef").build(), ImmutableMap.of(), CachedPreferences.EMPTY);
+    byte[] serialized = SERIALIZER.serialize(original);
+    Cache.AccountDetailsProto expectedProto =
+        Cache.AccountDetailsProto.newBuilder()
+            .setAccount(
+                Cache.AccountProto.newBuilder()
+                    .setId(1)
+                    .setRegisteredOn(0)
+                    .setFullName("foo bar")
+                    .setDisplayName("foo")
+                    .setInactive(true)
+                    .setMetaId("dead..beef")
+                    .setStatus("OOO")
+                    .setPreferredEmail("foo@bar.tld"))
+            .build();
+    ProtoTruth.assertThat(Cache.AccountDetailsProto.parseFrom(serialized)).isEqualTo(expectedProto);
+    Truth.assertThat(SERIALIZER.deserialize(serialized)).isEqualTo(expected);
+  }
+
+  @Test
   public void account_roundTripNullFields() throws Exception {
     CachedAccountDetails original =
         CachedAccountDetails.create(ACCOUNT, ImmutableMap.of(), CachedPreferences.EMPTY);
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
index 34f746a..a53abfa 100644
--- a/javatests/com/google/gerrit/server/account/AccountResolverTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -351,6 +351,7 @@
     return AccountState.forAccount(
         Account.builder(Account.id(id), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
+            .setUniqueTag("1234567812345678123456781234567812345678")
             .build());
   }
 
diff --git a/javatests/com/google/gerrit/server/account/WatchConfigTest.java b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
index 7d36b94..a277c3b 100644
--- a/javatests/com/google/gerrit/server/account/WatchConfigTest.java
+++ b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
@@ -54,7 +55,7 @@
             + "[project \"otherProject\"]\n"
             + "  notify = [NEW_PATCHSETS]\n"
             + "  notify = * [NEW_PATCHSETS, ALL_COMMENTS]\n");
-    Map<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches =
+    ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches =
         ProjectWatches.parse(Account.id(1000000), cfg, this);
 
     assertThat(validationErrors).isEmpty();
@@ -88,7 +89,9 @@
             + "[project \"otherProject\"]\n"
             + "  notify = [NEW_PATCHSETS]\n");
 
-    ProjectWatches.parse(Account.id(1000000), cfg, this);
+    @SuppressWarnings("unused")
+    var unused = ProjectWatches.parse(Account.id(1000000), cfg, this);
+
     assertThat(validationErrors).hasSize(1);
     assertThat(validationErrors.get(0).getMessage())
         .isEqualTo(
@@ -170,7 +173,10 @@
 
   private void assertParseNotifyValueFails(String notifyValue) {
     assertThat(validationErrors).isEmpty();
-    parseNotifyValue(notifyValue);
+
+    @SuppressWarnings("unused")
+    var unused = parseNotifyValue(notifyValue);
+
     assertWithMessage("expected validation error for notifyValue: " + notifyValue)
         .that(validationErrors)
         .isNotEmpty();
diff --git a/javatests/com/google/gerrit/server/cancellation/RequestStateContextTest.java b/javatests/com/google/gerrit/server/cancellation/RequestStateContextTest.java
index 2d3e94a..d695533 100644
--- a/javatests/com/google/gerrit/server/cancellation/RequestStateContextTest.java
+++ b/javatests/com/google/gerrit/server/cancellation/RequestStateContextTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.cancellation;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index 6cbbd26..4c5264f 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -90,7 +90,8 @@
     userId = accountManager.authenticate(authRequestFactory.createForUser("user")).getAccountId();
     user = userFactory.create(userId);
 
-    requestContext.setContext(() -> user);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(() -> user);
 
     configureProject();
     setUpChange();
@@ -131,7 +132,8 @@
     if (lifecycle != null) {
       lifecycle.stop();
     }
-    requestContext.setContext(null);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(null);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/change/WalkSorterTest.java b/javatests/com/google/gerrit/server/change/WalkSorterTest.java
index 2c4c98f..ca2163a 100644
--- a/javatests/com/google/gerrit/server/change/WalkSorterTest.java
+++ b/javatests/com/google/gerrit/server/change/WalkSorterTest.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -58,7 +59,7 @@
     ChangeData cd2 = newChange(p, c2_1);
     ChangeData cd3 = newChange(p, c3_1);
 
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3);
+    ImmutableList<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3);
     WalkSorter sorter = new WalkSorter(repoManager);
 
     assertSorted(
@@ -85,6 +86,114 @@
   }
 
   @Test
+  public void seriesOfMergeChangesInSpecialOrder() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    // For this test, the RevWalk in the sortProject must return commits in the following order:
+    // c3, c1, c2, c4.
+    // To achieve this order, the commit timestamps must be set in a specific order - RevWalk
+    // returns them sorted by timestamp, starting from the newest one.
+
+    // timestamp: base + 3
+    RevCommit c1 = p.commit().tick(3).create();
+    // timestamp: base + 2
+    RevCommit c2 = p.commit().tick(-1).parent(c1).create();
+    // timestamp: base + 4
+    RevCommit c3 = p.commit().tick(2).parent(c1).parent(c2).create();
+    // timestamp: base + 1
+    RevCommit c4 = p.commit().tick(-3).parent(c3).create();
+
+    ChangeData cd1 = newChange(p, c1);
+    ChangeData cd2 = newChange(p, c2);
+    ChangeData cd3 = newChange(p, c3);
+    ChangeData cd4 = newChange(p, c4);
+
+    ImmutableList<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
+    WalkSorter sorter = new WalkSorter(repoManager);
+
+    assertSorted(
+        sorter,
+        changes,
+        ImmutableList.of(
+            patchSetData(cd4, c4),
+            patchSetData(cd3, c3),
+            patchSetData(cd2, c2),
+            patchSetData(cd1, c1)));
+  }
+
+  @Test
+  public void seriesOfMergeChangesWorstCaseForTopoSorting() throws Exception {
+    TestRepository<Repo> p = newRepo("p");
+    // For this test, the RevWalk in the sortProject must return commits in the following order:
+    // c1, c2, c3, c4, c5, c6, c7.
+    // To achieve this order, the commit timestamps must be set in a specific order - RevWalk
+    // returns them sorted by timestamp, starting from the newest one.
+
+    // timestamp: base + 8
+    RevCommit c1 = p.commit().tick(8).create();
+    // timestamp: base + 7
+    RevCommit c2 = p.commit().tick(-1).parent(c1).create();
+    // timestamp: base + 6
+    RevCommit c3 = p.commit().tick(-1).parent(c1).parent(c2).create();
+    // timestamp: base + 5
+    RevCommit c4 = p.commit().tick(-1).parent(c1).parent(c2).parent(c3).create();
+    // timestamp: base + 4
+    RevCommit c5 = p.commit().tick(-1).parent(c1).parent(c2).parent(c3).parent(c4).create();
+    // timestamp: base + 3
+    RevCommit c6 =
+        p.commit().tick(-1).parent(c1).parent(c2).parent(c3).parent(c4).parent(c5).create();
+    // timestamp: base + 2
+    RevCommit c7 =
+        p.commit()
+            .tick(-1)
+            .parent(c1)
+            .parent(c2)
+            .parent(c3)
+            .parent(c4)
+            .parent(c5)
+            .parent(c6)
+            .create();
+    // timestamp: base + 1
+    RevCommit c8 =
+        p.commit()
+            .tick(-1)
+            .parent(c1)
+            .parent(c2)
+            .parent(c3)
+            .parent(c4)
+            .parent(c5)
+            .parent(c6)
+            .parent(c7)
+            .create();
+
+    ChangeData cd1 = newChange(p, c1);
+    ChangeData cd2 = newChange(p, c2);
+    ChangeData cd3 = newChange(p, c3);
+    ChangeData cd4 = newChange(p, c4);
+    ChangeData cd5 = newChange(p, c5);
+    ChangeData cd6 = newChange(p, c6);
+    ChangeData cd7 = newChange(p, c7);
+    ChangeData cd8 = newChange(p, c8);
+
+    ImmutableList<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4, cd5, cd6, cd7, cd8);
+    WalkSorter sorter = new WalkSorter(repoManager);
+
+    // Do not use assertSorted because it tests all permutations. We don't need it for this test
+    // and total number of permutations is quite big.
+    assertThat(sorter.sort(changes))
+        .containsExactlyElementsIn(
+            ImmutableList.of(
+                patchSetData(cd8, c8),
+                patchSetData(cd7, c7),
+                patchSetData(cd6, c6),
+                patchSetData(cd5, c5),
+                patchSetData(cd4, c4),
+                patchSetData(cd3, c3),
+                patchSetData(cd2, c2),
+                patchSetData(cd1, c1)))
+        .inOrder();
+  }
+
+  @Test
   public void subsetOfSeriesOfChanges() throws Exception {
     TestRepository<Repo> p = newRepo("p");
     RevCommit c1_1 = p.commit().create();
@@ -94,7 +203,7 @@
     ChangeData cd1 = newChange(p, c1_1);
     ChangeData cd3 = newChange(p, c3_1);
 
-    List<ChangeData> changes = ImmutableList.of(cd1, cd3);
+    ImmutableList<ChangeData> changes = ImmutableList.of(cd1, cd3);
     WalkSorter sorter = new WalkSorter(repoManager);
 
     assertSorted(
@@ -121,7 +230,7 @@
     ChangeData cd3 = newChange(p, c3);
     ChangeData cd4 = newChange(p, c4);
 
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
+    ImmutableList<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
     WalkSorter sorter = new WalkSorter(repoManager);
 
     assertSorted(
@@ -154,7 +263,7 @@
     ChangeData cd3 = newChange(p, c3);
     ChangeData cd4 = newChange(p, c4);
 
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
+    ImmutableList<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
     WalkSorter sorter = new WalkSorter(repoManager);
 
     assertSorted(
@@ -186,9 +295,9 @@
     ChangeData cd2 = newChange(p, c2);
     ChangeData cd4 = newChange(p, c4);
 
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd4);
+    ImmutableList<ChangeData> changes = ImmutableList.of(cd1, cd2, cd4);
     WalkSorter sorter = new WalkSorter(repoManager);
-    List<PatchSetData> expected =
+    ImmutableList<PatchSetData> expected =
         ImmutableList.of(patchSetData(cd4, c4), patchSetData(cd2, c2), patchSetData(cd1, c1));
 
     for (List<ChangeData> list : permutations(changes)) {
@@ -217,7 +326,7 @@
     ChangeData cd3 = newChange(p, c3);
     ChangeData cd4 = newChange(p, c4);
 
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
+    ImmutableList<ChangeData> changes = ImmutableList.of(cd1, cd2, cd3, cd4);
     WalkSorter sorter = new WalkSorter(repoManager);
 
     assertSorted(
@@ -270,7 +379,7 @@
     addPatchSet(cd1, c1_2);
     addPatchSet(cd2, c2_2);
 
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2);
+    ImmutableList<ChangeData> changes = ImmutableList.of(cd1, cd2);
     WalkSorter sorter = new WalkSorter(repoManager);
 
     assertSorted(
@@ -293,7 +402,7 @@
     ChangeData cd1 = newChange(pa, c1);
     ChangeData cd2 = newChange(pb, c2);
 
-    List<ChangeData> changes = ImmutableList.of(cd1, cd2);
+    ImmutableList<ChangeData> changes = ImmutableList.of(cd1, cd2);
     WalkSorter sorter =
         new WalkSorter(repoManager).includePatchSets(ImmutableSet.of(cd1.currentPatchSet().id()));
 
@@ -306,7 +415,7 @@
     RevCommit c = p.commit().message("message").create();
     ChangeData cd = newChange(p, c);
 
-    List<ChangeData> changes = ImmutableList.of(cd);
+    ImmutableList<ChangeData> changes = ImmutableList.of(cd);
     RevCommit actual =
         new WalkSorter(repoManager).setRetainBody(true).sort(changes).iterator().next().commit();
     assertThat(actual.getRawBuffer()).isNotNull();
@@ -323,7 +432,7 @@
     RevCommit c = p.commit().create();
     ChangeData cd = newChange(p, c);
 
-    List<ChangeData> changes = ImmutableList.of(cd);
+    ImmutableList<ChangeData> changes = ImmutableList.of(cd);
     WalkSorter sorter = new WalkSorter(repoManager);
 
     assertSorted(sorter, changes, ImmutableList.of(patchSetData(cd, c)));
@@ -338,6 +447,7 @@
     return cd;
   }
 
+  @CanIgnoreReturnValue
   private PatchSet addPatchSet(ChangeData cd, ObjectId id) throws Exception {
     TestChanges.incrementPatchSet(cd.change());
     PatchSet ps = TestChanges.newPatchSet(cd.change().currentPatchSetId(), id.name(), userId);
diff --git a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
index 772f4b8..b149d09 100644
--- a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
+++ b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
@@ -130,6 +130,27 @@
   }
 
   @Test
+  public void userPreferencesProto_falseValueReturnsAsNull() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setEditPreferencesInfo(
+                UserPreferences.EditPreferencesInfo.newBuilder()
+                    .setTabSize(17)
+                    .setHideTopMenu(false)
+                    .setHideLineNumbers(false)
+                    .setAutoCloseBrackets(true))
+            .build();
+
+    CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    EditPreferencesInfo edit = CachedPreferences.edit(Optional.empty(), pref);
+
+    assertThat(edit.tabSize).isEqualTo(17);
+    assertThat(edit.hideTopMenu).isNull();
+    assertThat(edit.hideLineNumbers).isNull();
+    assertThat(edit.autoCloseBrackets).isTrue();
+  }
+
+  @Test
   public void defaultPreferences_acceptingGitConfig() throws Exception {
     Config cfg = new Config();
     cfg.fromText("[general]\n\tchangesPerPage = 19");
diff --git a/javatests/com/google/gerrit/server/config/GerritInstanceNameProviderTest.java b/javatests/com/google/gerrit/server/config/GerritInstanceNameProviderTest.java
new file mode 100644
index 0000000..edf9b8b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/GerritInstanceNameProviderTest.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.util.git.DelegateSystemReader;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.util.SystemReader;
+import org.junit.Test;
+
+public class GerritInstanceNameProviderTest {
+
+  @Test
+  public void instanceNameSet_canonicalWebUrlUnset_useInstanceName() {
+    Config cfg = new Config();
+    cfg.setString("gerrit", null, "instanceName", "myName");
+
+    GerritInstanceNameProvider provider = new GerritInstanceNameProvider(cfg, null);
+    assertThat(provider.get()).isEqualTo("myName");
+  }
+
+  @Test
+  public void instanceNameSet_canonicalWebUrlSet_useInstanceName() {
+    Config cfg = new Config();
+    cfg.setString("gerrit", null, "instanceName", "myName");
+
+    GerritInstanceNameProvider provider = new GerritInstanceNameProvider(cfg, "http://myhost");
+    assertThat(provider.get()).isEqualTo("myName");
+  }
+
+  @Test
+  public void instanceNameNotSet_canonicalWebUrlSet_useHostFromCanonicalWebUrl() {
+    Config cfg = new Config();
+    GerritInstanceNameProvider provider = new GerritInstanceNameProvider(cfg, "http://myhost");
+    assertThat(provider.get()).isEqualTo("myhost");
+  }
+
+  @Test
+  public void instanceNameNotSet_canonicalWebUrlNotSet_useSystemHostName() {
+    SystemReader oldSystemReader = SystemReader.getInstance();
+    SystemReader.setInstance(
+        new DelegateSystemReader(oldSystemReader) {
+          @Override
+          public String getHostname() {
+            return "systemHostName";
+          }
+        });
+    Config cfg = new Config();
+    GerritInstanceNameProvider provider = new GerritInstanceNameProvider(cfg, null);
+    assertThat(provider.get()).isEqualTo("systemHostName");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
index d7aae6a0..8d93f66 100644
--- a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
@@ -15,13 +15,11 @@
 package com.google.gerrit.server.config;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.SubmitType;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
@@ -191,7 +189,7 @@
   public void allBasePath() {
     ImmutableList<Path> allBasePaths =
         ImmutableList.of(
-            Paths.get("/someBasePath1"), Paths.get("/someBasePath2"), Paths.get("/someBasePath2"));
+            Path.of("/someBasePath1"), Path.of("/someBasePath2"), Path.of("/someBasePath2"));
 
     configureBasePath("*", allBasePaths.get(0).toString());
     configureBasePath("project/*", allBasePaths.get(1).toString());
diff --git a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
index 55f0374..341872d 100644
--- a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.config;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static java.util.concurrent.TimeUnit.DAYS;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
diff --git a/javatests/com/google/gerrit/server/config/SitePathsTest.java b/javatests/com/google/gerrit/server/config/SitePathsTest.java
index 1e5f41d..1de6c30 100644
--- a/javatests/com/google/gerrit/server/config/SitePathsTest.java
+++ b/javatests/com/google/gerrit/server/config/SitePathsTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.config;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.server.ioutil.HostPlatform;
@@ -23,7 +22,6 @@
 import java.nio.file.Files;
 import java.nio.file.NotDirectoryException;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import org.junit.Test;
 
 public class SitePathsTest {
@@ -92,7 +90,7 @@
 
     final String pfx = HostPlatform.isWin32() ? "C:/" : "/";
     assertThat(site.resolve(pfx + "a")).isNotNull();
-    assertThat(site.resolve(pfx + "a")).isEqualTo(Paths.get(pfx + "a"));
+    assertThat(site.resolve(pfx + "a")).isEqualTo(Path.of(pfx + "a"));
   }
 
   private static Path random() throws IOException {
diff --git a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
index c6ca3e4..8cfcdbd 100644
--- a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
+++ b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
@@ -118,6 +118,51 @@
   }
 
   @Test
+  public void generalPreferencesInfo_toProtoTrimsMyMenuSpaces() {
+    GeneralPreferencesInfo info = new GeneralPreferencesInfo();
+    info.my =
+        ImmutableList.of(
+            new com.google.gerrit.extensions.client.MenuItem(
+                " name1 ", " url1 ", " target1 ", " id1 "),
+            new com.google.gerrit.extensions.client.MenuItem(null, " url2 ", null, null));
+    UserPreferences.GeneralPreferencesInfo resProto = GeneralPreferencesInfoConverter.toProto(info);
+    assertThat(resProto)
+        .isEqualTo(
+            UserPreferences.GeneralPreferencesInfo.newBuilder()
+                .addAllMyMenuItems(
+                    ImmutableList.of(
+                        MenuItem.newBuilder()
+                            .setUrl("url1")
+                            .setName("name1")
+                            .setTarget("target1")
+                            .setId("id1")
+                            .build(),
+                        MenuItem.newBuilder().setUrl("url2").build()))
+                .build());
+  }
+
+  @Test
+  public void generalPreferencesInfo_fromProtoTrimsMyMenuSpaces() {
+    UserPreferences.GeneralPreferencesInfo originalProto =
+        UserPreferences.GeneralPreferencesInfo.newBuilder()
+            .addAllMyMenuItems(
+                ImmutableList.of(
+                    MenuItem.newBuilder()
+                        .setName(" name1 ")
+                        .setUrl(" url1 ")
+                        .setTarget(" target1 ")
+                        .setId(" id1 ")
+                        .build(),
+                    MenuItem.newBuilder().setUrl(" url2 ").build()))
+            .build();
+    GeneralPreferencesInfo info = GeneralPreferencesInfoConverter.fromProto(originalProto);
+    assertThat(info.my)
+        .containsExactly(
+            new com.google.gerrit.extensions.client.MenuItem("name1", "url1", "target1", "id1"),
+            new com.google.gerrit.extensions.client.MenuItem(null, "url2", null, null));
+  }
+
+  @Test
   public void generalPreferencesInfo_emptyJavaToProto() {
     GeneralPreferencesInfo info = new GeneralPreferencesInfo();
     UserPreferences.GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.toProto(info);
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/EmptyContentTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/EmptyContentTest.java
index 51fbc67..1c0ac5e 100644
--- a/javatests/com/google/gerrit/server/fixes/fixCalculator/EmptyContentTest.java
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/EmptyContentTest.java
@@ -27,7 +27,7 @@
     FixResult fixResult =
         FixCalculatorVariousTest.calculateFixSingleReplacement("", 1, 0, 1, 0, "Abc");
     assertThat(fixResult).text().isEqualTo("Abc");
-    assertThat(fixResult).edits().onlyElement();
+    assertThat(fixResult).edits().hasSize(1);
     Edit edit = fixResult.edits.get(0);
     assertThat(edit).isInsert(0, 0, 1);
     assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 3);
@@ -38,7 +38,7 @@
     FixResult fixResult =
         FixCalculatorVariousTest.calculateFixSingleReplacement("", 1, 0, 1, 0, "Abc\n");
     assertThat(fixResult).text().isEqualTo("Abc\n");
-    assertThat(fixResult).edits().onlyElement();
+    assertThat(fixResult).edits().hasSize(1);
     Edit edit = fixResult.edits.get(0);
     assertThat(edit).isInsert(0, 0, 1);
     assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 4);
@@ -49,7 +49,7 @@
     FixResult fixResult =
         FixCalculatorVariousTest.calculateFixSingleReplacement("", 1, 0, 1, 0, "Abc\nDEFGH");
     assertThat(fixResult).text().isEqualTo("Abc\nDEFGH");
-    assertThat(fixResult).edits().onlyElement();
+    assertThat(fixResult).edits().hasSize(1);
     Edit edit = fixResult.edits.get(0);
     assertThat(edit).isInsert(0, 0, 2);
     assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 9);
@@ -60,7 +60,7 @@
     FixResult fixResult =
         FixCalculatorVariousTest.calculateFixSingleReplacement("", 1, 0, 1, 0, "Abc\nDEFGH\n");
     assertThat(fixResult).text().isEqualTo("Abc\nDEFGH\n");
-    assertThat(fixResult).edits().onlyElement();
+    assertThat(fixResult).edits().hasSize(1);
     Edit edit = fixResult.edits.get(0);
     assertThat(edit).isInsert(0, 0, 2);
     assertThat(edit).internalEdits().onlyElement().isInsert(0, 0, 10);
diff --git a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
index 12130ea..71eda9e 100644
--- a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
@@ -199,14 +199,14 @@
 
   @Test
   public void testProjectRecreation() throws Exception {
-    repoManager.createRepository(Project.nameKey("a"));
+    repoManager.createRepository(Project.nameKey("a")).close();
     assertThrows(
         RepositoryExistsException.class, () -> repoManager.createRepository(Project.nameKey("a")));
   }
 
   @Test
   public void testProjectRecreationAfterRestart() throws Exception {
-    repoManager.createRepository(Project.nameKey("a"));
+    repoManager.createRepository(Project.nameKey("a")).close();
     LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
     assertThrows(
         RepositoryExistsException.class,
@@ -226,7 +226,7 @@
   @Test
   public void testGetRepositoryStatusNameCaseMismatch() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    repoManager.createRepository(Project.nameKey("a"));
+    repoManager.createRepository(Project.nameKey("a")).close();
     assertThat(repoManager.getRepositoryStatus(Project.nameKey("A"))).isEqualTo(Status.ACTIVE);
   }
 
@@ -239,7 +239,7 @@
   @Test
   public void testNameCaseMismatch() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
-    repoManager.createRepository(Project.nameKey("a"));
+    repoManager.createRepository(Project.nameKey("a")).close();
     assertThrows(
         RepositoryCaseMismatchException.class,
         () -> repoManager.createRepository(Project.nameKey("A")));
@@ -249,7 +249,7 @@
   public void testNameCaseMismatchWithSymlink() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
     Project.NameKey name = Project.nameKey("a");
-    repoManager.createRepository(name);
+    repoManager.createRepository(name).close();
     createSymLink(name, "b.git");
     assertThrows(
         RepositoryCaseMismatchException.class,
@@ -260,7 +260,7 @@
   public void testNameCaseMismatchAfterRestart() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
     Project.NameKey name = Project.nameKey("a");
-    repoManager.createRepository(name);
+    repoManager.createRepository(name).close();
 
     LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
     assertThrows(
diff --git a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
index 6c771d7..5794149 100644
--- a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.config.SitePaths;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
@@ -62,17 +61,20 @@
   public void defaultRepositoryLocation()
       throws RepositoryCaseMismatchException, RepositoryNotFoundException, IOException {
     Project.NameKey someProjectKey = Project.nameKey("someProject");
-    Repository repo = repoManager.createRepository(someProjectKey);
-    assertThat(repo.getDirectory()).isNotNull();
-    assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent())
-        .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
 
-    repo = repoManager.openRepository(someProjectKey);
-    assertThat(repo.getDirectory()).isNotNull();
-    assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent())
-        .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
+    try (Repository repo = repoManager.createRepository(someProjectKey)) {
+      assertThat(repo.getDirectory()).isNotNull();
+      assertThat(repo.getDirectory().exists()).isTrue();
+      assertThat(repo.getDirectory().getParent())
+          .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
+    }
+
+    try (Repository repo = repoManager.openRepository(someProjectKey)) {
+      assertThat(repo.getDirectory()).isNotNull();
+      assertThat(repo.getDirectory().exists()).isTrue();
+      assertThat(repo.getDirectory().getParent())
+          .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
+    }
 
     assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
         .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
@@ -90,17 +92,19 @@
     when(configMock.getBasePath(someProjectKey)).thenReturn(alternateBasePath);
     when(configMock.getAllBasePaths()).thenReturn(ImmutableList.of(alternateBasePath));
 
-    Repository repo = repoManager.createRepository(someProjectKey);
-    assertThat(repo.getDirectory()).isNotNull();
-    assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent())
-        .isEqualTo(alternateBasePath.toRealPath().toString());
+    try (Repository repo = repoManager.createRepository(someProjectKey)) {
+      assertThat(repo.getDirectory()).isNotNull();
+      assertThat(repo.getDirectory().exists()).isTrue();
+      assertThat(repo.getDirectory().getParent())
+          .isEqualTo(alternateBasePath.toRealPath().toString());
+    }
 
-    repo = repoManager.openRepository(someProjectKey);
-    assertThat(repo.getDirectory()).isNotNull();
-    assertThat(repo.getDirectory().exists()).isTrue();
-    assertThat(repo.getDirectory().getParent())
-        .isEqualTo(alternateBasePath.toRealPath().toString());
+    try (Repository repo = repoManager.openRepository(someProjectKey)) {
+      assertThat(repo.getDirectory()).isNotNull();
+      assertThat(repo.getDirectory().exists()).isTrue();
+      assertThat(repo.getDirectory().getParent())
+          .isEqualTo(alternateBasePath.toRealPath().toString());
+    }
 
     assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
         .isEqualTo(alternateBasePath.toString());
@@ -124,8 +128,8 @@
     when(configMock.getBasePath(misplacedProject2)).thenReturn(alternateBasePath);
     when(configMock.getAllBasePaths()).thenReturn(ImmutableList.of(alternateBasePath));
 
-    repoManager.createRepository(basePathProject);
-    repoManager.createRepository(altPathProject);
+    repoManager.createRepository(basePathProject).close();
+    repoManager.createRepository(altPathProject).close();
     // create the misplaced ones without the repomanager otherwise they would
     // end up at the proper place.
     createRepository(repoManager.getBasePath(basePathProject), misplacedProject2);
@@ -151,7 +155,7 @@
         IllegalStateException.class,
         () -> {
           configMock = mock(RepositoryConfig.class);
-          when(configMock.getAllBasePaths()).thenReturn(ImmutableList.of(Paths.get("repos")));
+          when(configMock.getAllBasePaths()).thenReturn(ImmutableList.of(Path.of("repos")));
           repoManager = new MultiBaseLocalDiskRepositoryManager(site, cfg, configMock);
         });
   }
diff --git a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
index 91d5596..f2e4f82 100644
--- a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Streams;
diff --git a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
index 7eb6bc7..2d7f698 100644
--- a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
+++ b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
-import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectIdRef;
 import org.eclipse.jgit.lib.Ref;
@@ -93,7 +92,7 @@
 
   @Test
   public void advertisedRefs_prefixScansChangeId() throws Exception {
-    Map<String, Ref> refs = setupTwoChanges();
+    ImmutableMap<String, Ref> refs = setupTwoChanges();
     ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
 
     assertThat(cache.byPrefix(RefNames.changeRefPrefix(Change.id(1))))
@@ -102,7 +101,7 @@
 
   @Test
   public void advertisedRefs_exactRef() throws Exception {
-    Map<String, Ref> refs = setupTwoChanges();
+    ImmutableMap<String, Ref> refs = setupTwoChanges();
     ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
 
     assertThat(cache.exactRef("refs/changes/01/1/1")).isEqualTo(refs.get("refs/changes/01/1/1"));
@@ -110,7 +109,7 @@
 
   @Test
   public void advertisedRefs_patchSetIdsFromObjectId() throws Exception {
-    Map<String, Ref> refs = setupTwoChanges();
+    ImmutableMap<String, Ref> refs = setupTwoChanges();
     ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
 
     assertThat(
@@ -123,7 +122,7 @@
     return new ObjectIdRef.Unpeeled(Ref.Storage.NEW, name, ObjectId.fromString(sha1), 1);
   }
 
-  private Map<String, Ref> setupTwoChanges() {
+  private ImmutableMap<String, Ref> setupTwoChanges() {
     Ref ref1 = newRef("refs/changes/01/1/1", "badc0feebadc0feebadc0feebadc0feebadc0fee");
     Ref ref2 = newRef("refs/changes/02/2/1", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
     return ImmutableMap.of(ref1.getName(), ref1, ref2.getName(), ref2);
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index e0f4b63..c96f3a1 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -46,7 +46,7 @@
 public class AbstractGroupTest {
   protected static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
   protected static final String SERVER_ID = "server-id";
-  protected static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
+  protected static final String SERVER_EMAIL = "noreply@example.com";
   protected static final int SERVER_ACCOUNT_NUMBER = 100000;
   protected static final int USER_ACCOUNT_NUMBER = 100001;
 
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index 8c19732..a5fc114 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -18,10 +18,10 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.server.group.testing.InternalGroupSubject.internalGroups;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -1063,7 +1063,7 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, zoneId);
+        new PersonIdent("Jane", "Jane@example.com", committerTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
@@ -1094,8 +1094,7 @@
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
-    PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, zoneId);
+    PersonIdent authorIdent = new PersonIdent("Jane", "Jane@example.com", authorTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
@@ -1154,7 +1153,7 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, zoneId);
+        new PersonIdent("Jane", "Jane@example.com", committerTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
@@ -1180,8 +1179,7 @@
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
-    PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, zoneId);
+    PersonIdent authorIdent = new PersonIdent("Jane", "Jane@example.com", authorTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
@@ -1499,6 +1497,7 @@
         .setId(groupId);
   }
 
+  @CanIgnoreReturnValue
   private Optional<InternalGroup> createGroup(InternalGroupCreation groupCreation)
       throws Exception {
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
@@ -1506,6 +1505,7 @@
     return groupConfig.getLoadedGroup();
   }
 
+  @CanIgnoreReturnValue
   private Optional<InternalGroup> createGroup(
       InternalGroupCreation groupCreation, GroupDelta groupDelta) throws Exception {
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
@@ -1514,11 +1514,13 @@
     return groupConfig.getLoadedGroup();
   }
 
+  @CanIgnoreReturnValue
   private Optional<InternalGroup> updateGroup(AccountGroup.UUID uuid, GroupDelta groupDelta)
       throws Exception {
     return updateGroup(uuid, groupDelta, auditLogFormatter);
   }
 
+  @CanIgnoreReturnValue
   private Optional<InternalGroup> updateGroup(
       AccountGroup.UUID uuid, GroupDelta groupDelta, AuditLogFormatter auditLogFormatter)
       throws Exception {
@@ -1541,7 +1543,7 @@
 
   private MetaDataUpdate createMetaDataUpdate() {
     PersonIdent serverIdent =
-        new PersonIdent("Gerrit Server", "noreply@gerritcodereview.com", TimeUtil.now(), zoneId);
+        new PersonIdent("Gerrit Server", "noreply@example.com", TimeUtil.now(), zoneId);
 
     MetaDataUpdate metaDataUpdate =
         new MetaDataUpdate(
@@ -1596,6 +1598,6 @@
 
   private static OptionalSubject<InternalGroupSubject, InternalGroup> assertThatGroup(
       Optional<InternalGroup> loadedGroup) {
-    return assertThat(loadedGroup, internalGroups());
+    return OptionalSubject.assertThat(loadedGroup, internalGroups());
   }
 }
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index 9d8f260..3658e4b 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -70,7 +70,7 @@
 
 public class GroupNameNotesTest {
   private static final String SERVER_NAME = "Gerrit Server";
-  private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
+  private static final String SERVER_EMAIL = "noreply@example.com";
   private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
 
   private final AccountGroup.UUID groupUuid = AccountGroup.uuid("users-XYZ");
diff --git a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
index 6745b1d..1017ed0 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
@@ -22,14 +22,13 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.server.group.db.testing.GroupTestUtil;
-import java.util.List;
 import org.junit.Test;
 
 public class GroupsNoteDbConsistencyCheckerTest extends AbstractGroupTest {
 
   @Test
   public void groupNamesRefIsMissing() throws Exception {
-    List<ConsistencyProblemInfo> problems =
+    ImmutableList<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
             allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
@@ -39,7 +38,7 @@
   @Test
   public void groupNameNoteIsMissing() throws Exception {
     updateGroupNamesRef("g-2", "[group]\n\tuuid = uuid-2\n\tname = g-2\n");
-    List<ConsistencyProblemInfo> problems =
+    ImmutableList<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
             allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
@@ -49,7 +48,7 @@
   @Test
   public void groupNameNoteIsConsistent() throws Exception {
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-1\n\tname = g-1\n");
-    List<ConsistencyProblemInfo> problems =
+    ImmutableList<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
             allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems).isEmpty();
@@ -58,7 +57,7 @@
   @Test
   public void groupNameNoteHasDifferentUUID() throws Exception {
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-2\n\tname = g-1\n");
-    List<ConsistencyProblemInfo> problems =
+    ImmutableList<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
             allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
@@ -71,7 +70,7 @@
   @Test
   public void groupNameNoteHasDifferentName() throws Exception {
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-1\n\tname = g-2\n");
-    List<ConsistencyProblemInfo> problems =
+    ImmutableList<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
             allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
@@ -81,7 +80,7 @@
   @Test
   public void groupNameNoteHasDifferentNameAndUUID() throws Exception {
     updateGroupNamesRef("g-1", "[group]\n\tuuid = uuid-2\n\tname = g-2\n");
-    List<ConsistencyProblemInfo> problems =
+    ImmutableList<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
             allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
diff --git a/javatests/com/google/gerrit/server/index/IndexedFieldTest.java b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
index 5029334..2c3584b 100644
--- a/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
@@ -113,7 +113,10 @@
     IndexedField<TestIndexedData, StoredValue>.SearchSpec searchSpec = fieldToStoredValue.getKey();
     StoredValue storedValue = new FakeStoredValue(fieldToStoredValue.getValue());
     TestIndexedData testIndexedData = new TestIndexedData();
-    searchSpec.setIfPossible(testIndexedData, storedValue);
+
+    @SuppressWarnings("unused")
+    var unused = searchSpec.setIfPossible(testIndexedData, storedValue);
+
     assertThat(testIndexedData.getTestField()).isEqualTo(docValue);
   }
 
@@ -122,7 +125,10 @@
     Entities.Change changeProto = TestIndexedFields.createChangeProto(12345);
     StoredValue storedValue = new FakeStoredValue(Protos.toByteArray(changeProto));
     TestIndexedData testIndexedData = new TestIndexedData();
-    STORED_PROTO_FIELD_SPEC.setIfPossible(testIndexedData, storedValue);
+
+    @SuppressWarnings("unused")
+    var unused = STORED_PROTO_FIELD_SPEC.setIfPossible(testIndexedData, storedValue);
+
     assertThat(testIndexedData.getTestField()).isEqualTo(changeProto);
   }
 
@@ -137,7 +143,10 @@
                 .map(proto -> Protos.toByteArray(proto))
                 .collect(toImmutableList()));
     TestIndexedData testIndexedData = new TestIndexedData();
-    ITERABLE_STORED_PROTO_FIELD.setIfPossible(testIndexedData, storedValue);
+
+    @SuppressWarnings("unused")
+    var unused = ITERABLE_STORED_PROTO_FIELD.setIfPossible(testIndexedData, storedValue);
+
     assertThat(testIndexedData.getTestField()).isEqualTo(changeProtos);
   }
 
@@ -150,7 +159,10 @@
     IndexedField<TestIndexedData, StoredValue>.SearchSpec searchSpec = fieldToStoredValue.getKey();
     StoredValue storedValue = new FakeStoredValue(fieldToStoredValue.getValue(), /*isProto=*/ true);
     TestIndexedData testIndexedData = new TestIndexedData();
-    searchSpec.setIfPossible(testIndexedData, storedValue);
+
+    @SuppressWarnings("unused")
+    var unused = searchSpec.setIfPossible(testIndexedData, storedValue);
+
     assertThat(testIndexedData.getTestField()).isEqualTo(docValue);
   }
 
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 4ce4262..53431d1 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -260,7 +260,7 @@
   }
 
   private static void assertStoredRecordRoundTrip(SubmitRecord... records) {
-    List<SubmitRecord> recordList = ImmutableList.copyOf(records);
+    ImmutableList<SubmitRecord> recordList = ImmutableList.copyOf(records);
     List<String> stored =
         ChangeField.storedSubmitRecords(recordList).stream()
             .map(s -> new String(s, UTF_8))
diff --git a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index 6f40680..b7e733f 100644
--- a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.util.List;
 import java.util.stream.Stream;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -311,7 +312,7 @@
         .isFalse();
   }
 
-  private static Iterable<byte[]> byteArrays(String... strs) {
+  private static List<byte[]> byteArrays(String... strs) {
     return Stream.of(strs).map(s -> s != null ? s.getBytes(UTF_8) : null).collect(toList());
   }
 }
diff --git a/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java b/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
index 048d59d..6f7832d 100644
--- a/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
+++ b/javatests/com/google/gerrit/server/ioutil/RegexListSearcherTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.ioutil;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableList;
 import java.util.List;
@@ -31,7 +30,7 @@
 
   @Test
   public void anchors() {
-    List<String> list = ImmutableList.of("foo");
+    ImmutableList<String> list = ImmutableList.of("foo");
     assertSearchReturns(list, "^f.*", list);
     assertSearchReturns(list, "^f.*o$", list);
     assertSearchReturns(list, "f.*o$", list);
@@ -41,7 +40,7 @@
 
   @Test
   public void noCommonPrefix() {
-    List<String> list = ImmutableList.of("bar", "foo", "quux");
+    ImmutableList<String> list = ImmutableList.of("bar", "foo", "quux");
     assertSearchReturns(ImmutableList.of("foo"), "f.*", list);
     assertSearchReturns(ImmutableList.of("foo"), ".*o.*", list);
     assertSearchReturns(ImmutableList.of("bar", "foo", "quux"), ".*[aou].*", list);
@@ -49,7 +48,7 @@
 
   @Test
   public void commonPrefix() {
-    List<String> list = ImmutableList.of("bar", "baz", "foo1", "foo2", "foo3", "quux");
+    ImmutableList<String> list = ImmutableList.of("bar", "baz", "foo1", "foo2", "foo3", "quux");
     assertSearchReturns(ImmutableList.of("bar", "baz"), "b.*", list);
     assertSearchReturns(ImmutableList.of("foo1", "foo2"), "foo[12]", list);
     assertSearchReturns(ImmutableList.of("foo1", "foo2", "foo3"), "foo.*", list);
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 1f0da16..c1b9f13 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -24,6 +24,7 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
+import java.time.Instant;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
@@ -51,7 +52,7 @@
     testPerformanceLogger =
         new PerformanceLogger() {
           @Override
-          public void log(String operation, long durationMs, Metadata metadata) {
+          public void log(String operation, long durationMs, Instant endTime, Metadata metadata) {
             // do nothing
           }
         };
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
index 512a1b1..c93061d 100644
--- a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -32,6 +32,7 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
+import java.time.Instant;
 import java.util.concurrent.CopyOnWriteArrayList;
 import org.eclipse.jgit.lib.Config;
 import org.junit.After;
@@ -364,7 +365,7 @@
     private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
 
     @Override
-    public void log(String operation, long durationMs, Metadata metadata) {
+    public void log(String operation, long durationMs, Instant endTime, Metadata metadata) {
       logEntries.add(PerformanceLogEntry.create(operation, metadata));
     }
 
diff --git a/javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java b/javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java
index 46ea8b2..68cb231 100644
--- a/javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PRE_FORMATTED;
 import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.QUOTE;
 
+import com.google.common.collect.ImmutableList;
 import java.util.List;
 import org.junit.Test;
 
@@ -63,7 +64,7 @@
   @Test
   public void parseSimple() {
     String comment = "Para1";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertBlock(result, 0, PARAGRAPH, comment);
@@ -72,7 +73,7 @@
   @Test
   public void parseMultilinePara() {
     String comment = "Para 1\nStill para 1";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertBlock(result, 0, PARAGRAPH, comment);
@@ -81,7 +82,7 @@
   @Test
   public void parseParaBreak() {
     String comment = "Para 1\n\nPara 2\n\nPara 3";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(3);
     assertBlock(result, 0, PARAGRAPH, "Para 1");
@@ -92,7 +93,7 @@
   @Test
   public void parseQuote() {
     String comment = "> Quote text";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertQuoteBlock(result, 0, 1);
@@ -102,7 +103,7 @@
   @Test
   public void parseExcludesEmpty() {
     String comment = "Para 1\n\n\n\nPara 2";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(2);
     assertBlock(result, 0, PARAGRAPH, "Para 1");
@@ -112,7 +113,7 @@
   @Test
   public void parseQuoteLeadSpace() {
     String comment = " > Quote text";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertQuoteBlock(result, 0, 1);
@@ -122,7 +123,7 @@
   @Test
   public void parseMultiLineQuote() {
     String comment = "> Quote line 1\n> Quote line 2\n > Quote line 3\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertQuoteBlock(result, 0, 1);
@@ -133,7 +134,7 @@
   @Test
   public void parsePre() {
     String comment = "    Four space indent.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertBlock(result, 0, PRE_FORMATTED, comment);
@@ -142,7 +143,7 @@
   @Test
   public void parseOneSpacePre() {
     String comment = " One space indent.\n Another line.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertBlock(result, 0, PRE_FORMATTED, comment);
@@ -151,7 +152,7 @@
   @Test
   public void parseTabPre() {
     String comment = "\tOne tab indent.\n\tAnother line.\n  Yet another!";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertBlock(result, 0, PRE_FORMATTED, comment);
@@ -160,7 +161,7 @@
   @Test
   public void parseIntermediateLeadingWhitespacePre() {
     String comment = "No indent.\n\tNonzero indent.\nNo indent again.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertBlock(result, 0, PRE_FORMATTED, comment);
@@ -169,7 +170,7 @@
   @Test
   public void parseStarList() {
     String comment = "* Item 1\n* Item 2\n* Item 3";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertListBlock(result, 0, 0, "Item 1");
@@ -180,7 +181,7 @@
   @Test
   public void parseDashList() {
     String comment = "- Item 1\n- Item 2\n- Item 3";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertListBlock(result, 0, 0, "Item 1");
@@ -191,7 +192,7 @@
   @Test
   public void parseMixedList() {
     String comment = "- Item 1\n* Item 2\n- Item 3\n* Item 4";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertListBlock(result, 0, 0, "Item 1");
@@ -216,7 +217,7 @@
             + "\tPreformatted text."
             + "\n\n"
             + "Parting words.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(7);
     assertBlock(result, 0, PARAGRAPH, "Paragraph\nacross\na\nfew\nlines.");
@@ -235,7 +236,7 @@
   @Test
   public void bulletList1() {
     String comment = "A\n\n* line 1\n* 2nd line";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(2);
     assertBlock(result, 0, PARAGRAPH, "A");
@@ -246,7 +247,7 @@
   @Test
   public void bulletList2() {
     String comment = "A\n\n* line 1\n* 2nd line\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(3);
     assertBlock(result, 0, PARAGRAPH, "A");
@@ -258,7 +259,7 @@
   @Test
   public void bulletList3() {
     String comment = "* line 1\n* 2nd line\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(2);
     assertListBlock(result, 0, 0, "line 1");
@@ -272,7 +273,7 @@
         "To see this bug, you have to:\n" //
             + "* Be on IMAP or EAS (not on POP)\n" //
             + "* Be very unlucky\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(2);
     assertBlock(result, 0, PARAGRAPH, "To see this bug, you have to:");
@@ -287,7 +288,7 @@
             + "you have to:\n" //
             + "* Be on IMAP or EAS (not on POP)\n" //
             + "* Be very unlucky\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(2);
     assertBlock(result, 0, PARAGRAPH, "To see this bug, you have to:");
@@ -298,7 +299,7 @@
   @Test
   public void dashList1() {
     String comment = "A\n\n- line 1\n- 2nd line";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(2);
     assertBlock(result, 0, PARAGRAPH, "A");
@@ -309,7 +310,7 @@
   @Test
   public void dashList2() {
     String comment = "A\n\n- line 1\n- 2nd line\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(3);
     assertBlock(result, 0, PARAGRAPH, "A");
@@ -321,7 +322,7 @@
   @Test
   public void dashList3() {
     String comment = "- line 1\n- 2nd line\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(2);
     assertListBlock(result, 0, 0, "line 1");
@@ -332,7 +333,7 @@
   @Test
   public void preformat1() {
     String comment = "A\n\n  This is pre\n  formatted";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(2);
     assertBlock(result, 0, PARAGRAPH, "A");
@@ -342,7 +343,7 @@
   @Test
   public void preformat2() {
     String comment = "A\n\n  This is pre\n  formatted\n\nbut this is not";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(3);
     assertBlock(result, 0, PARAGRAPH, "A");
@@ -353,7 +354,7 @@
   @Test
   public void preformat3() {
     String comment = "A\n\n  Q\n    <R>\n  S\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(3);
     assertBlock(result, 0, PARAGRAPH, "A");
@@ -364,7 +365,7 @@
   @Test
   public void preformat4() {
     String comment = "  Q\n    <R>\n  S\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(2);
     assertBlock(result, 0, PRE_FORMATTED, "  Q\n    <R>\n  S");
@@ -374,7 +375,7 @@
   @Test
   public void quote1() {
     String comment = "> I'm happy\n > with quotes!\n\nSee above.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(2);
     assertQuoteBlock(result, 0, 1);
@@ -385,7 +386,7 @@
   @Test
   public void quote2() {
     String comment = "See this said:\n\n > a quoted\n > string block\n\nOK?";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(3);
     assertBlock(result, 0, PARAGRAPH, "See this said:");
@@ -397,7 +398,7 @@
   @Test
   public void nestedQuotes1() {
     String comment = " > > prior\n > \n > next\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(1);
     assertQuoteBlock(result, 0, 2);
@@ -428,7 +429,7 @@
             + "> Paragraph 6.\n"
             + "\n"
             + "Paragraph 7.\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+    ImmutableList<CommentFormatter.Block> result = CommentFormatter.parse(comment);
 
     assertThat(result).hasSize(2);
     assertQuoteBlock(result, 0, 2);
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
index 7f893f1..412bd0f 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
-import java.nio.file.Paths;
+import java.nio.file.Path;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -31,7 +31,7 @@
 
   @Before
   public void setUp() throws Exception {
-    sitePaths = new SitePaths(Paths.get("."));
+    sitePaths = new SitePaths(Path.of("."));
     set = new DynamicSet<>();
   }
 
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
index 5a6db42..ed179a7 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
@@ -33,7 +33,7 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Names;
 import com.google.template.soy.jbcsrc.api.SoySauce;
-import java.nio.file.Paths;
+import java.nio.file.Path;
 import javax.inject.Provider;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
@@ -41,7 +41,7 @@
 public class MailSoySauceModuleTest {
   @Test
   public void soySauceProviderReturnsCachedValue() throws Exception {
-    SitePaths sitePaths = new SitePaths(Paths.get("."));
+    SitePaths sitePaths = new SitePaths(Path.of("."));
     Injector injector =
         Guice.createInjector(
             new MailSoySauceModule(),
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 96a485a..6118d2e 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -57,6 +57,8 @@
 import com.google.gerrit.server.config.GerritImportedServerIds;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -186,6 +188,8 @@
             install(new GitModule());
 
             install(new DefaultUrlFormatterModule());
+            install(new NoteDbDraftCommentsModule());
+            install(new NoteDbStarredChangesModule());
             install(NoteDbModule.forTest());
             install(new DefaultRefLogIdentityProvider.Module());
             bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
@@ -223,6 +227,7 @@
             bind(PatchSetApprovalUuidGenerator.class).to(TestPatchSetApprovalUuidGenerator.class);
             bind(ChangeDraftUpdate.ChangeDraftUpdateFactory.class)
                 .to(ChangeDraftNotesUpdate.Factory.class);
+            bind(ExperimentFeatures.class).to(ConfigExperimentFeatures.class);
           }
         });
   }
@@ -231,7 +236,7 @@
       throws RepositoryCaseMismatchException, RepositoryNotFoundException {
     AllUsersName allUsersName = injector.getInstance(AllUsersName.class);
 
-    repoManager.createRepository(allUsersName);
+    repoManager.createRepository(allUsersName).close();
 
     IdentifiedUser.GenericFactory identifiedUserFactory =
         injector.getInstance(IdentifiedUser.GenericFactory.class);
@@ -247,7 +252,11 @@
   @After
   public void resetTime() {
     TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
+    if (systemTimeZone != null) {
+      System.setProperty("user.timezone", systemTimeZone);
+    } else {
+      System.clearProperty("user.timezone");
+    }
   }
 
   protected Change newChange(boolean workInProgress) throws Exception {
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
index 24e28f3..591ed96 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.gson.Gson;
 import com.google.inject.TypeLiteral;
@@ -33,6 +32,18 @@
     Optional<Child> optionalChild;
   }
 
+  static class ParentWithoutField {}
+
+  static class ChildWithField extends ParentWithoutField {
+    String field;
+  }
+
+  static class ParentWithField {
+    String field;
+  }
+
+  static class ChildWithoutField extends ParentWithField {}
+
   @Test
   public void shouldSerializeAndDeserializeEmptyOptional() {
     // given
@@ -124,4 +135,39 @@
     assertThat(result.optionalChild).isPresent();
     assertThat(result.optionalChild.get().optionalValue).isEmpty();
   }
+
+  @Test
+  public void ignoresUnknownField() {
+    Child fooChild = new Child();
+    fooChild.optionalValue = Optional.empty();
+    Parent parent = new Parent();
+    parent.optionalChild = Optional.of(fooChild);
+
+    String jsonWithUnknownField =
+        "{\n"
+            + "  \"unknown-field\": \"unknown-value\","
+            + "  \"optionalChild\": {\n"
+            + "    \"value\": {\n"
+            + "      \"optionalValue\": {}\n"
+            + "    }\n"
+            + "  }\n"
+            + "}";
+
+    Parent result = gson.fromJson(jsonWithUnknownField, new TypeLiteral<Parent>() {}.getType());
+
+    assertThat(result.optionalChild).isPresent();
+    assertThat(result.optionalChild.get().optionalValue).isEmpty();
+  }
+
+  @Test
+  public void fieldCanBeMovedFromChildToParentWithoutChangingSerializedRepresentation() {
+    ChildWithField c = new ChildWithField();
+    c.field = "test";
+    ChildWithoutField c2 = gson.fromJson(gson.toJson(c), ChildWithoutField.class);
+    assertThat(c2.field).isEqualTo("test");
+
+    String serialized = "" + "{\n" + "  \"field\": \"test\"\n" + "}";
+    assertThat(gson.toJson(c)).isEqualTo(serialized);
+    assertThat(gson.toJson(c2)).isEqualTo(serialized);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
index 84d2d5d..caf2023 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
@@ -56,7 +56,9 @@
                 + FQ_USER_IDENT
                 + "\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
 
-    newParser(commit).parseAll();
+    @SuppressWarnings("unused")
+    var unused = newParser(commit).parseAll();
+
     assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(true);
   }
 
@@ -73,7 +75,9 @@
                 + FQ_USER_IDENT
                 + "\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
 
-    newParser(commit).parseAll();
+    @SuppressWarnings("unused")
+    var unused = newParser(commit).parseAll();
+
     assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(false);
   }
 
@@ -82,7 +86,9 @@
       throws Exception {
     RevCommit commit = writeCommit("Update patch set 1\n" + "\n" + "Patch-set: 1\n");
 
-    newParser(commit).parseAll();
+    @SuppressWarnings("unused")
+    var unused = newParser(commit).parseAll();
+
     assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(false)).isEqualTo(false);
   }
 
@@ -97,7 +103,9 @@
                 + FQ_USER_IDENT
                 + "\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}");
 
-    newParser(commit).parseAll();
+    @SuppressWarnings("unused")
+    var unused = newParser(commit).parseAll();
+
     assertThat(((ChangeNotesCommit) commit).isAttentionSetCommitOnly(true)).isEqualTo(false);
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 23c5704..8717d99 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -662,7 +663,10 @@
                 + " by Administrator using the hovercard menu\"}",
             false);
     ChangeNotesParser changeNotesParser = newParser(commit);
-    changeNotesParser.parseAll();
+
+    @SuppressWarnings("unused")
+    var unused = changeNotesParser.parseAll();
+
     final boolean hasChangeMessage = false;
     assertThat(
             changeNotesParser.countTowardsMaxUpdatesLimit(
@@ -684,7 +688,10 @@
                 + " by Administrator using the hovercard menu\"}",
             false);
     ChangeNotesParser changeNotesParser = newParser(commit);
-    changeNotesParser.parseAll();
+
+    @SuppressWarnings("unused")
+    var unused = changeNotesParser.parseAll();
+
     final boolean hasChangeMessage = false;
     assertThat(
             changeNotesParser.countTowardsMaxUpdatesLimit(
@@ -696,7 +703,10 @@
   public void changeWithoutAttentionSetShouldCountTowardsMaxUpdatesLimit() throws Exception {
     RevCommit commit = writeCommit("Update WIP change\n" + "\n" + "Patch-set: 1\n", true);
     ChangeNotesParser changeNotesParser = newParser(commit);
-    changeNotesParser.parseAll();
+
+    @SuppressWarnings("unused")
+    var unused = changeNotesParser.parseAll();
+
     final boolean hasChangeMessage = false;
     assertThat(
             changeNotesParser.countTowardsMaxUpdatesLimit(
@@ -717,7 +727,10 @@
                 + " by Administrator using the hovercard menu\"}",
             false);
     ChangeNotesParser changeNotesParser = newParser(commit);
-    changeNotesParser.parseAll();
+
+    @SuppressWarnings("unused")
+    var unused = changeNotesParser.parseAll();
+
     final boolean hasChangeMessage = true;
     assertThat(
             changeNotesParser.countTowardsMaxUpdatesLimit(
@@ -783,10 +796,12 @@
     }
   }
 
+  @CanIgnoreReturnValue
   private ChangeNotesState assertParseSucceeds(String body) throws Exception {
     return assertParseSucceeds(writeCommit(body));
   }
 
+  @CanIgnoreReturnValue
   private ChangeNotesState assertParseSucceeds(RevCommit commit) throws Exception {
     return newParser(commit).parseAll();
   }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 8efc5bd..008a74b 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -24,12 +24,14 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LegacySubmitRequirement;
@@ -658,12 +660,12 @@
         newBuilder()
             .reviewerUpdates(
                 ImmutableList.of(
-                    ReviewerStatusUpdate.create(
+                    ReviewerStatusUpdate.createForReviewer(
                         Instant.ofEpochMilli(1212L),
                         Account.id(1000),
                         Account.id(2002),
                         ReviewerStateInternal.CC),
-                    ReviewerStatusUpdate.create(
+                    ReviewerStatusUpdate.createForReviewer(
                         Instant.ofEpochMilli(3434L),
                         Account.id(1000),
                         Account.id(2001),
@@ -677,18 +679,58 @@
                 ReviewerStatusUpdateProto.newBuilder()
                     .setTimestampMillis(1212L)
                     .setUpdatedBy(1000)
+                    .setHasReviewer(true)
                     .setReviewer(2002)
                     .setState("CC"))
             .addReviewerUpdate(
                 ReviewerStatusUpdateProto.newBuilder()
                     .setTimestampMillis(3434L)
                     .setUpdatedBy(1000)
+                    .setHasReviewer(true)
                     .setReviewer(2001)
                     .setState("REVIEWER"))
             .build());
   }
 
   @Test
+  public void serializeReviewerByEmailUpdates() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .reviewerUpdates(
+                ImmutableList.of(
+                    ReviewerStatusUpdate.createForReviewerByEmail(
+                        Instant.ofEpochMilli(1212L),
+                        Account.id(1000),
+                        Address.parse("email1@example.com"),
+                        ReviewerStateInternal.CC),
+                    ReviewerStatusUpdate.createForReviewerByEmail(
+                        Instant.ofEpochMilli(3434L),
+                        Account.id(1000),
+                        Address.parse("email2@example.com"),
+                        ReviewerStateInternal.REVIEWER)))
+            .build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setColumns(colsProto)
+            .addReviewerUpdate(
+                ReviewerStatusUpdateProto.newBuilder()
+                    .setTimestampMillis(1212L)
+                    .setUpdatedBy(1000)
+                    .setHasReviewerByEmail(true)
+                    .setReviewerByEmail("email1@example.com")
+                    .setState("CC"))
+            .addReviewerUpdate(
+                ReviewerStatusUpdateProto.newBuilder()
+                    .setTimestampMillis(3434L)
+                    .setUpdatedBy(1000)
+                    .setHasReviewerByEmail(true)
+                    .setReviewerByEmail("email2@example.com")
+                    .setState("REVIEWER"))
+            .build());
+  }
+
+  @Test
   public void serializeAttentionSetUpdates() throws Exception {
     assertRoundTrip(
         newBuilder()
@@ -1069,7 +1111,8 @@
             ImmutableMap.of(
                 "date", Instant.class,
                 "updatedBy", Account.Id.class,
-                "reviewer", Account.Id.class,
+                "reviewer", new TypeLiteral<Optional<Account.Id>>() {}.getType(),
+                "reviewerByEmail", new TypeLiteral<Optional<Address>>() {}.getType(),
                 "state", ReviewerStateInternal.class));
   }
 
@@ -1161,6 +1204,7 @@
                 .put("revId", String.class)
                 .put("serverId", String.class)
                 .put("unresolved", boolean.class)
+                .put("fixSuggestions", new TypeLiteral<List<FixSuggestion>>() {}.getType())
                 .build());
   }
 
@@ -1181,6 +1225,7 @@
     return ChangeNotesStateProto.parseFrom(Serializer.INSTANCE.serialize(state));
   }
 
+  @CanIgnoreReturnValue
   private static ChangeNotesState assertRoundTrip(
       ChangeNotesState state, ChangeNotesStateProto expectedProto) throws Exception {
     ChangeNotesStateProto actualProto = toProto(state);
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index e025ee2..3c1abbd 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -18,7 +18,6 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static com.google.gerrit.entities.RefNames.refsDraftComments;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
@@ -26,20 +25,19 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
-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;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
@@ -56,6 +54,7 @@
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.ChangeDraftUpdate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.IdentifiedUser;
@@ -70,7 +69,6 @@
 import java.time.Instant;
 import java.util.LinkedHashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Optional;
 import java.util.TreeMap;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -292,7 +290,7 @@
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
+    ImmutableList<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
@@ -325,7 +323,7 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals().all();
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals().all();
     assertThat(psas).hasSize(2);
 
     PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
@@ -383,7 +381,7 @@
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
+    ImmutableList<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
@@ -606,7 +604,7 @@
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> patchSetApprovals =
+    ImmutableList<PatchSetApproval> patchSetApprovals =
         notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(patchSetApprovals).hasSize(2);
     assertThat(
@@ -631,7 +629,7 @@
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> patchSetApprovals =
+    ImmutableList<PatchSetApproval> patchSetApprovals =
         notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(patchSetApprovals).hasSize(2);
     assertThat(
@@ -1220,7 +1218,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
+    ImmutableList<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
     assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().id());
@@ -1258,7 +1256,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<SubmitRecord> recs = notes.getSubmitRecords();
+    ImmutableList<SubmitRecord> recs = notes.getSubmitRecords();
     assertThat(recs).hasSize(2);
     assertThat(recs.get(0))
         .isEqualTo(
@@ -2009,9 +2007,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    readNote(notes, commit);
-
-    Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
+    ImmutableMap<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
     assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
     assertThat(notes.getHumanComments()).isEmpty();
@@ -2068,7 +2064,7 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
+    ImmutableList<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
@@ -2320,7 +2316,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    List<ChangeMessage> cm = notes.getChangeMessages();
+    ImmutableList<ChangeMessage> cm = notes.getChangeMessages();
     assertThat(cm).hasSize(2);
     assertThat(cm.get(0).getMessage()).isEqualTo("First change message.\n");
     assertThat(cm.get(0).getAuthor()).isEqualTo(changeOwner.getAccount().id());
@@ -3472,13 +3468,11 @@
     // Re-add draft version of comment2 back to draft ref without updating
     // change ref. Simulates the case where deleting the draft failed
     // non-atomically after adding the published comment succeeded.
-    Optional<ChangeDraftNotesUpdate> draftUpdate =
-        ChangeDraftNotesUpdate.asChangeDraftNotesUpdate(
-            newUpdate(c, otherUser).createDraftUpdateIfNull());
-    if (draftUpdate.isPresent()) {
-      draftUpdate.get().putDraftComment(comment2);
+    ChangeDraftUpdate draftUpdate = newUpdate(c, otherUser).createDraftUpdateIfNull();
+    if (draftUpdate != null) {
+      draftUpdate.putDraftComment(comment2);
       try (NoteDbUpdateManager manager = updateManagerFactory.create(c.getProject(), otherUser)) {
-        manager.add(draftUpdate.get());
+        manager.add(draftUpdate);
         testRefAction(() -> manager.execute());
       }
     }
@@ -3548,7 +3542,7 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<HumanComment> comments = notes.getHumanComments().get(commitId);
+    ImmutableList<HumanComment> comments = notes.getHumanComments().get(commitId);
     assertThat(comments).hasSize(2);
     assertThat(comments.get(0).message).isEqualTo("comment 1");
     assertThat(comments.get(1).message).isEqualTo("comment 2");
@@ -3680,7 +3674,7 @@
 
   @Test
   public void putReviewerByEmail() throws Exception {
-    Address adr = Address.create("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adr = Address.create("Foo Bar", "foo.bar@example.com");
 
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -3693,7 +3687,7 @@
 
   @Test
   public void putAndRemoveReviewerByEmail() throws Exception {
-    Address adr = Address.create("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adr = Address.create("Foo Bar", "foo.bar@example.com");
 
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -3710,7 +3704,7 @@
 
   @Test
   public void putRemoveAndAddBackReviewerByEmail() throws Exception {
-    Address adr = Address.create("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adr = Address.create("Foo Bar", "foo.bar@example.com");
 
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -3731,8 +3725,8 @@
 
   @Test
   public void putReviewerByEmailAndCcByEmail() throws Exception {
-    Address adrReviewer = Address.create("Foo Bar", "foo.bar@gerritcodereview.com");
-    Address adrCc = Address.create("Foo Bor", "foo.bar.2@gerritcodereview.com");
+    Address adrReviewer = Address.create("Foo Bar", "foo.bar@example.com");
+    Address adrCc = Address.create("Foo Bor", "foo.bar.2@example.com");
 
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -3753,7 +3747,7 @@
 
   @Test
   public void putReviewerByEmailAndChangeToCc() throws Exception {
-    Address adr = Address.create("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adr = Address.create("Foo Bar", "foo.bar@example.com");
 
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -3809,8 +3803,8 @@
 
   @Test
   public void pendingReviewers() throws Exception {
-    Address adr1 = Address.create("Foo Bar1", "foo.bar1@gerritcodereview.com");
-    Address adr2 = Address.create("Foo Bar2", "foo.bar2@gerritcodereview.com");
+    Address adr1 = Address.create("Foo Bar1", "foo.bar1@example.com");
+    Address adr2 = Address.create("Foo Bar2", "foo.bar2@example.com");
     Account.Id ownerId = changeOwner.getAccount().id();
     Account.Id otherUserId = otherUser.getAccount().id();
 
@@ -3979,11 +3973,6 @@
     assertThat(newNotes(c).getChange().currentPatchSetId().get()).isEqualTo(2);
   }
 
-  private String readNote(ChangeNotes notes, ObjectId noteId) throws Exception {
-    ObjectId dataId = notes.revisionNoteMap.noteMap.getNote(noteId).getData();
-    return new String(rw.getObjectReader().open(dataId, OBJ_BLOB).getCachedBytes(), UTF_8);
-  }
-
   @Nullable
   private ObjectId exactRefAllUsers(String refName) throws Exception {
     try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
@@ -4014,10 +4003,12 @@
     TestChanges.incrementPatchSet(c);
   }
 
+  @CanIgnoreReturnValue
   private RevCommit incrementPatchSet(Change c) throws Exception {
     return incrementPatchSet(c, userFactory.create(c.getOwner()));
   }
 
+  @CanIgnoreReturnValue
   private RevCommit incrementPatchSet(Change c, IdentifiedUser user) throws Exception {
     incrementCurrentPatchSetFieldOnly(c);
     RevCommit commit = tr.commit().message("PS" + c.currentPatchSetId().get()).create();
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 25f2f98..1f22fc1d 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -390,12 +390,11 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putReviewerByEmail(
-        Address.create("John Doe", "j.doe@gerritcodereview.com"), ReviewerStateInternal.REVIEWER);
+        Address.create("John Doe", "j.doe@example.com"), ReviewerStateInternal.REVIEWER);
     update.commit();
 
     assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\n"
-            + "Reviewer-email: John Doe <j.doe@gerritcodereview.com>\n",
+        "Update patch set 1\n\nPatch-set: 1\n" + "Reviewer-email: John Doe <j.doe@example.com>\n",
         update.getResult());
   }
 
@@ -403,13 +402,11 @@
   public void ccByEmail() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putReviewerByEmail(
-        Address.create("j.doe@gerritcodereview.com"), ReviewerStateInternal.CC);
+    update.putReviewerByEmail(Address.create("j.doe@example.com"), ReviewerStateInternal.CC);
     update.commit();
 
     assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\nCC-email: j.doe@gerritcodereview.com\n",
-        update.getResult());
+        "Update patch set 1\n\nPatch-set: 1\nCC-email: j.doe@example.com\n", update.getResult());
   }
 
   private RevCommit parseCommit(ObjectId id) throws Exception {
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 064cd89..1e45af2 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -27,6 +27,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
@@ -53,7 +54,6 @@
 import java.time.Instant;
 import java.util.Arrays;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.stream.IntStream;
@@ -179,7 +179,7 @@
 
     assertFixedCommits(ImmutableList.of(commitToFix), backfillResult, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(backfillResult, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(backfillResult, c.getId());
     assertThat(commitHistoryDiff).containsExactly("");
     BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
     assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
@@ -384,7 +384,7 @@
     assertThat(invalidUpdateCommit.getCommitterIdent())
         .isEqualTo(fixedUpdateCommit.getCommitterIdent());
     assertThat(fixedUpdateCommit.getFullMessage()).doesNotContain(changeOwner.getName());
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff).hasSize(1);
     assertThat(commitHistoryDiff.get(0)).contains("-author Change Owner <1@gerrit>");
     assertThat(commitHistoryDiff.get(0)).contains("+author Gerrit User 1 <1@gerrit>");
@@ -447,7 +447,7 @@
         commitsBeforeRewrite, commitsAfterRewrite, ImmutableList.of(invalidCommitIndex));
     assertFixedCommits(ImmutableList.of(invalidUpdateCommit), result, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -9 +9 @@\n"
@@ -508,10 +508,10 @@
     Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
-            ReviewerStatusUpdate.create(
+            ReviewerStatusUpdate.createForReviewer(
                 updateTimestamp, changeOwner.getAccountId(), otherUserId, REVIEWER),
-            ReviewerStatusUpdate.create(updateTimestamp, otherUserId, otherUserId, CC),
-            ReviewerStatusUpdate.create(
+            ReviewerStatusUpdate.createForReviewer(updateTimestamp, otherUserId, otherUserId, CC),
+            ReviewerStatusUpdate.createForReviewer(
                 updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED));
     ChangeNotes notesAfterRewrite = newNotes(c);
 
@@ -525,7 +525,7 @@
     assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
     assertFixedCommits(commitsToFix, result, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -9 +9 @@\n"
@@ -592,13 +592,13 @@
     Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
-            ReviewerStatusUpdate.create(
+            ReviewerStatusUpdate.createForReviewer(
                 addReviewerUpdate.when, changeOwner.getAccountId(), otherUserId, REVIEWER),
-            ReviewerStatusUpdate.create(
+            ReviewerStatusUpdate.createForReviewer(
                 updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED),
-            ReviewerStatusUpdate.create(
+            ReviewerStatusUpdate.createForReviewer(
                 addCcUpdate.when, changeOwner.getAccountId(), otherUserId, CC),
-            ReviewerStatusUpdate.create(
+            ReviewerStatusUpdate.createForReviewer(
                 updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED));
     ChangeNotes notesAfterRewrite = newNotes(c);
 
@@ -617,7 +617,7 @@
     assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
     assertFixedCommits(commitsToFix.build(), result, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -6 +6 @@\n" + "-Removed reviewer Other Account.\n" + "+Removed reviewer\n",
@@ -655,7 +655,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -6 +6 @@\n" + "-Removed reviewer Other Account.\n" + "+Removed reviewer\n",
@@ -782,7 +782,7 @@
     assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
     assertFixedCommits(commitsToFix, result, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -7,2 +7,2 @@\n"
@@ -907,7 +907,7 @@
     assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
     assertFixedCommits(commitsToFix, result, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -6 +6 @@\n"
@@ -946,7 +946,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     // Other Account does not applier in any change updates, replaced with default
     assertThat(commitHistoryDiff)
         .containsExactly(
@@ -990,7 +990,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -6 +6 @@\n"
@@ -1024,7 +1024,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -6 +6 @@\n"
@@ -1053,7 +1053,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -6 +6 @@\n"
@@ -1100,7 +1100,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -6 +6 @@\n"
@@ -1160,7 +1160,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -7 +7 @@\n"
@@ -1257,7 +1257,6 @@
     options.dryRun = false;
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
-    notesBeforeRewrite.getAttentionSetUpdates();
     Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<AttentionSetUpdate> attentionSetUpdatesBeforeRewrite =
         ImmutableList.of(
@@ -1359,7 +1358,7 @@
     assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
     assertFixedCommits(commitsToFix.build(), result, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff).hasSize(4);
     assertThat(commitHistoryDiff.get(0))
         .isEqualTo(
@@ -1571,7 +1570,7 @@
     assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
     assertFixedCommits(commitsToFix.build(), result, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -6 +6 @@\n"
@@ -1621,7 +1620,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -1 +1 @@\n"
@@ -1702,7 +1701,7 @@
         commitsBeforeRewrite, commitsAfterRewrite, ImmutableList.of(invalidCommitIndex));
     assertFixedCommits(ImmutableList.of(invalidUpdateCommit.getId()), result, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -10 +10 @@\n"
@@ -1777,7 +1776,7 @@
     assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
     assertFixedCommits(commitsToFix.build(), result, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -6 +6 @@\n"
@@ -1890,7 +1889,7 @@
     assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
     assertFixedCommits(commitsToFix.build(), result, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -6 +6 @@\n"
@@ -2065,7 +2064,7 @@
     assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
     assertFixedCommits(commitsToFix.build(), result, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -8 +8 @@\n"
@@ -2172,7 +2171,7 @@
     String expectedFixedIdent = getValidIdentAsString(changeOwner.getAccount());
     assertThat(fixedUpdateCommit.getFullMessage()).contains(expectedFixedIdent);
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -9 +9 @@\n"
@@ -2255,7 +2254,7 @@
     assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
     assertFixedCommits(commitsToFix, result, c.getId());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -6 +6 @@\n"
@@ -2319,7 +2318,7 @@
     options.dryRun = false;
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff)
         .containsExactly(
             "@@ -6 +6 @@\n"
@@ -2384,7 +2383,7 @@
     assertThat(fixedUpdateCommit.getFullMessage()).doesNotContain(changeOwner.getName());
     assertThat(fixedUpdateCommit.getFullMessage()).doesNotContain(otherUser.getName());
 
-    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    ImmutableList<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
     assertThat(commitHistoryDiff).hasSize(1);
     assertThat(commitHistoryDiff.get(0)).contains("-author Change Owner <1@gerrit>");
     assertThat(commitHistoryDiff.get(0)).contains("+author Gerrit User 1 <1@gerrit>");
@@ -2401,6 +2400,7 @@
     assertThat(secondRunResult.refsFailedToFix).isEmpty();
   }
 
+  @CanIgnoreReturnValue
   private RevCommit writeUpdate(String metaRef, String body, PersonIdent author) throws Exception {
     return tr.branch(metaRef).commit().message(body).author(author).committer(serverIdent).create();
   }
diff --git a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
index 333c229..f335201 100644
--- a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
+++ b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -52,7 +52,7 @@
 
   @Test
   public void parseNoRef() throws Exception {
-    assertThat(IntBlob.parse(repo, "refs/nothing")).isEmpty();
+    OptionalSubject.assertThat(IntBlob.parse(repo, "refs/nothing")).isEmpty();
   }
 
   @Test
@@ -66,14 +66,18 @@
   public void parseValid() throws Exception {
     String refName = "refs/foo";
     ObjectId id = tr.update(refName, tr.blob("123"));
-    assertThat(IntBlob.parse(repo, refName)).value().isEqualTo(IntBlob.create(id, 123));
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(id, 123));
   }
 
   @Test
   public void parseWithWhitespace() throws Exception {
     String refName = "refs/foo";
     ObjectId id = tr.update(refName, tr.blob(" 123 "));
-    assertThat(IntBlob.parse(repo, refName)).value().isEqualTo(IntBlob.create(id, 123));
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(id, 123));
   }
 
   @Test
@@ -92,7 +96,7 @@
         IntBlob.tryStore(repo, rw, projectName, refName, null, 123, GitReferenceUpdated.DISABLED);
     assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.NEW);
     assertThat(ru.getName()).isEqualTo(refName);
-    assertThat(IntBlob.parse(repo, refName))
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
         .value()
         .isEqualTo(IntBlob.create(ru.getNewObjectId(), 123));
   }
@@ -105,7 +109,7 @@
             repo, rw, projectName, refName, ObjectId.zeroId(), 123, GitReferenceUpdated.DISABLED);
     assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.NEW);
     assertThat(ru.getName()).isEqualTo(refName);
-    assertThat(IntBlob.parse(repo, refName))
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
         .value()
         .isEqualTo(IntBlob.create(ru.getNewObjectId(), 123));
   }
@@ -118,7 +122,7 @@
         IntBlob.tryStore(repo, rw, projectName, refName, id, 456, GitReferenceUpdated.DISABLED);
     assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.FORCED);
     assertThat(ru.getName()).isEqualTo(refName);
-    assertThat(IntBlob.parse(repo, refName))
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
         .value()
         .isEqualTo(IntBlob.create(ru.getNewObjectId(), 456));
   }
@@ -137,14 +141,14 @@
             GitReferenceUpdated.DISABLED);
     assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.LOCK_FAILURE);
     assertThat(ru.getName()).isEqualTo(refName);
-    assertThat(IntBlob.parse(repo, refName)).isEmpty();
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName)).isEmpty();
   }
 
   @Test
   public void storeNoOldId() throws Exception {
     String refName = "refs/foo";
     IntBlob.store(repo, rw, projectName, refName, null, 123, GitReferenceUpdated.DISABLED);
-    assertThat(IntBlob.parse(repo, refName))
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
         .value()
         .isEqualTo(IntBlob.create(getRef(refName), 123));
   }
@@ -154,7 +158,7 @@
     String refName = "refs/foo";
     IntBlob.store(
         repo, rw, projectName, refName, ObjectId.zeroId(), 123, GitReferenceUpdated.DISABLED);
-    assertThat(IntBlob.parse(repo, refName))
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
         .value()
         .isEqualTo(IntBlob.create(getRef(refName), 123));
   }
@@ -164,7 +168,7 @@
     String refName = "refs/foo";
     ObjectId id = tr.update(refName, tr.blob("123"));
     IntBlob.store(repo, rw, projectName, refName, id, 456, GitReferenceUpdated.DISABLED);
-    assertThat(IntBlob.parse(repo, refName))
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName))
         .value()
         .isEqualTo(IntBlob.create(getRef(refName), 456));
   }
@@ -185,7 +189,7 @@
                     123,
                     GitReferenceUpdated.DISABLED));
     assertThat(thrown.getFailedRefs()).containsExactly("refs/foo");
-    assertThat(IntBlob.parse(repo, refName)).isEmpty();
+    OptionalSubject.assertThat(IntBlob.parse(repo, refName)).isEmpty();
   }
 
   private ObjectId getRef(String refName) throws IOException {
diff --git a/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
index 323aee9..b74089f 100644
--- a/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
+++ b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
@@ -20,7 +20,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ListMultimap;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
@@ -55,7 +54,7 @@
       ChangeUpdate update = newUpdate(c, changeOwner);
       update.setStatus(Change.Status.NEW);
 
-      ListMultimap<String, ChangeUpdate> changeUpdates =
+      ImmutableListMultimap<String, ChangeUpdate> changeUpdates =
           new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
 
       assertThrows(
@@ -73,7 +72,7 @@
 
       addToAttentionSet(update);
 
-      ListMultimap<String, ChangeUpdate> changeUpdates =
+      ImmutableListMultimap<String, ChangeUpdate> changeUpdates =
           new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
 
       openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS);
@@ -96,7 +95,7 @@
                   submitLabel("Verified", "OK", changeOwner.getAccountId()),
                   submitLabel("Alternative-Code-Review", "NEED", null))));
 
-      ListMultimap<String, ChangeUpdate> changeUpdates =
+      ImmutableListMultimap<String, ChangeUpdate> changeUpdates =
           new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
 
       openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS);
@@ -112,7 +111,7 @@
       ChangeUpdate update = newUpdate(c, changeOwner);
       update.setStatus(Change.Status.ABANDONED);
 
-      ListMultimap<String, ChangeUpdate> changeUpdates =
+      ImmutableListMultimap<String, ChangeUpdate> changeUpdates =
           new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("one", update).build();
 
       openRepo.addUpdates(changeUpdates, NO_UPDATES_AT_ALL, MAX_PATCH_SETS);
@@ -132,7 +131,7 @@
       ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
       update2.setStatus(Change.Status.NEW);
 
-      ListMultimap<String, ChangeUpdate> changeUpdates =
+      ImmutableListMultimap<String, ChangeUpdate> changeUpdates =
           new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
 
       openRepo.addUpdates(changeUpdates, ONLY_TWO_UPDATES, MAX_PATCH_SETS);
@@ -153,7 +152,7 @@
       ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
       update2.setStatus(Change.Status.NEW);
 
-      ListMultimap<String, ChangeUpdate> changeUpdates =
+      ImmutableListMultimap<String, ChangeUpdate> changeUpdates =
           new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
 
       assertThrows(
@@ -176,7 +175,7 @@
       ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
       update2.setStatus(Change.Status.NEW);
 
-      ListMultimap<String, ChangeUpdate> changeUpdates =
+      ImmutableListMultimap<String, ChangeUpdate> changeUpdates =
           new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
 
       assertThrows(
@@ -197,7 +196,7 @@
       ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
       update2.setStatus(Change.Status.NEW);
 
-      ListMultimap<String, ChangeUpdate> changeUpdates =
+      ImmutableListMultimap<String, ChangeUpdate> changeUpdates =
           new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
 
       assertThrows(
@@ -218,7 +217,7 @@
       ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
       update2.setStatus(Change.Status.NEW);
 
-      ListMultimap<String, ChangeUpdate> changeUpdates =
+      ImmutableListMultimap<String, ChangeUpdate> changeUpdates =
           new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
 
       assertThrows(
@@ -235,7 +234,7 @@
       ChangeUpdate update2 = newUpdateForNewChange(c1, changeOwner);
       update2.setStatus(Change.Status.NEW);
 
-      ListMultimap<String, ChangeUpdate> changeUpdates =
+      ImmutableListMultimap<String, ChangeUpdate> changeUpdates =
           new ImmutableListMultimap.Builder<String, ChangeUpdate>().put("two", update2).build();
 
       assertThrows(
diff --git a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
index 1b2d906..df7922d 100644
--- a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -28,6 +28,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.truth.Expect;
 import com.google.common.util.concurrent.Runnables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -64,7 +65,7 @@
   public void setUp() throws Exception {
     repoManager = new InMemoryRepositoryManager();
     project = Project.nameKey("project");
-    repoManager.createRepository(project);
+    repoManager.createRepository(project).close();
   }
 
   @Test
@@ -378,6 +379,7 @@
         retryer);
   }
 
+  @CanIgnoreReturnValue
   private ObjectId writeBlob(String sequenceName, String value) {
     String refName = RefNames.REFS_SEQUENCES + sequenceName;
     try (Repository repo = repoManager.openRepository(project);
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index 1c28690..f3fe144 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -24,8 +24,11 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffOperationsTest.FileEntity.FileType;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
@@ -52,6 +55,7 @@
 public class DiffOperationsTest {
   @Inject private GitRepositoryManager repoManager;
   @Inject private DiffOperations diffOperations;
+  @Inject private ModifiedFilesCacheImpl modifiedFilesCacheImpl;
 
   private static final Project.NameKey testProjectName = Project.nameKey("test-project");
   private Repository repo;
@@ -120,7 +124,7 @@
   }
 
   @Test
-  public void loadModifiedFiles() throws Exception {
+  public void loadModifiedFilesIfNecessary() throws Exception {
     ImmutableList<FileEntity> oldFiles =
         ImmutableList.of(
             new FileEntity(fileName1, fileContent1), new FileEntity(fileName2, fileContent2));
@@ -132,28 +136,133 @@
             new FileEntity(fileName2, fileContent2 + "\nnew line here"));
     ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
 
-    Repository repository = repoManager.openRepository(testProjectName);
-    ObjectReader objectReader = repository.newObjectReader();
-    RevWalk rw = new RevWalk(objectReader);
-    StoredConfig repoConfig = repository.getConfig();
+    try (Repository repository = repoManager.openRepository(testProjectName);
+        ObjectReader objectReader = repository.newObjectReader();
+        RevWalk rw = new RevWalk(objectReader)) {
+      StoredConfig repoConfig = repository.getConfig();
+      ModifiedFilesCacheKey cacheKey =
+          ModifiedFilesCacheKey.builder()
+              .project(testProjectName)
+              .aCommit(oldCommitId)
+              .bCommit(newCommitId)
+              .disableRenameDetection()
+              .build();
+      assertThat(modifiedFilesCacheImpl.getIfPresent(cacheKey)).isEmpty();
 
-    // This call loads modified files directly without going through the diff cache.
-    Map<String, ModifiedFile> modifiedFiles =
-        diffOperations.loadModifiedFiles(
-            testProjectName, newCommitId, oldCommitId, DiffOptions.DEFAULTS, rw, repoConfig);
+      // This call loads modified files directly without going through the diff cache.
+      Map<String, ModifiedFile> modifiedFiles =
+          diffOperations.loadModifiedFilesIfNecessary(
+              testProjectName,
+              oldCommitId,
+              newCommitId,
+              rw,
+              repoConfig,
+              /* enableRenameDetection= */ false);
 
-    assertThat(modifiedFiles)
-        .containsExactly(
-            fileName2,
-            ModifiedFile.builder()
-                .changeType(ChangeType.MODIFIED)
-                .oldPath(Optional.of(fileName2))
-                .newPath(Optional.of(fileName2))
-                .build());
+      ModifiedFile expectedModifiedFile =
+          ModifiedFile.builder()
+              .changeType(ChangeType.MODIFIED)
+              .oldPath(Optional.of(fileName2))
+              .newPath(Optional.of(fileName2))
+              .build();
+      assertThat(modifiedFiles).containsExactly(fileName2, expectedModifiedFile);
+      assertThat(modifiedFilesCacheImpl.getIfPresent(cacheKey))
+          .hasValue(ImmutableList.of(expectedModifiedFile));
+
+      // Check that calling loadModifiedFilesIfNecessary again retrieves the modified files from the
+      // cache, rather than loading them again.
+      Map<String, ModifiedFile> cachedModifiedFiles =
+          diffOperations.loadModifiedFilesIfNecessary(
+              testProjectName,
+              oldCommitId,
+              newCommitId,
+              /* revWalk= */ null, // makes the loading fail if attempted
+              repoConfig,
+              /* enableRenameDetection= */ false);
+      assertThat(cachedModifiedFiles).isEqualTo(modifiedFiles);
+    }
   }
 
   @Test
-  public void loadModifiedFiles_withSymlinkConvertedToRegularFile() throws Exception {
+  public void loadModifiedFilesIfNecessary_withRename() throws Exception {
+    ImmutableList<FileEntity> oldFiles = ImmutableList.of(new FileEntity(fileName1, fileContent1));
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    ImmutableList<FileEntity> newFiles = ImmutableList.of(new FileEntity(fileName2, fileContent1));
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    try (Repository repository = repoManager.openRepository(testProjectName);
+        ObjectReader objectReader = repository.newObjectReader();
+        RevWalk rw = new RevWalk(objectReader)) {
+      StoredConfig repoConfig = repository.getConfig();
+
+      // load modified files without rename detection
+      ModifiedFilesCacheKey cacheKey =
+          ModifiedFilesCacheKey.builder()
+              .project(testProjectName)
+              .aCommit(oldCommitId)
+              .bCommit(newCommitId)
+              .disableRenameDetection()
+              .build();
+      assertThat(modifiedFilesCacheImpl.getIfPresent(cacheKey)).isEmpty();
+
+      Map<String, ModifiedFile> modifiedFiles =
+          diffOperations.loadModifiedFilesIfNecessary(
+              testProjectName,
+              oldCommitId,
+              newCommitId,
+              rw,
+              repoConfig,
+              /* enableRenameDetection= */ false);
+
+      ModifiedFile expectedDeletedFile =
+          ModifiedFile.builder()
+              .changeType(ChangeType.DELETED)
+              .oldPath(Optional.of(fileName1))
+              .build();
+      ModifiedFile expectedAddedFile =
+          ModifiedFile.builder()
+              .changeType(ChangeType.ADDED)
+              .newPath(Optional.of(fileName2))
+              .build();
+      assertThat(modifiedFiles)
+          .containsExactly(fileName1, expectedDeletedFile, fileName2, expectedAddedFile);
+      assertThat(modifiedFilesCacheImpl.getIfPresent(cacheKey))
+          .hasValue(ImmutableList.of(expectedDeletedFile, expectedAddedFile));
+
+      // load modified files with rename detection
+      cacheKey =
+          ModifiedFilesCacheKey.builder()
+              .project(testProjectName)
+              .aCommit(oldCommitId)
+              .bCommit(newCommitId)
+              .renameScore(DiffOperationsImpl.RENAME_SCORE)
+              .build();
+      assertThat(modifiedFilesCacheImpl.getIfPresent(cacheKey)).isEmpty();
+
+      modifiedFiles =
+          diffOperations.loadModifiedFilesIfNecessary(
+              testProjectName,
+              oldCommitId,
+              newCommitId,
+              rw,
+              repoConfig,
+              /* enableRenameDetection= */ true);
+
+      ModifiedFile expectedRenamedFile =
+          ModifiedFile.builder()
+              .changeType(ChangeType.RENAMED)
+              .oldPath(Optional.of(fileName1))
+              .newPath(Optional.of(fileName2))
+              .build();
+      assertThat(modifiedFiles).containsExactly(fileName2, expectedRenamedFile);
+      assertThat(modifiedFilesCacheImpl.getIfPresent(cacheKey))
+          .hasValue(ImmutableList.of(expectedRenamedFile));
+    }
+  }
+
+  @Test
+  public void loadModifiedFilesIfNecessary_withSymlinkConvertedToRegularFile() throws Exception {
     // Commit 1: Create a regular fileName1 with fileContent1
     ImmutableList<FileEntity> oldFiles = ImmutableList.of(new FileEntity(fileName1, fileContent1));
     ObjectId oldCommitId = createCommit(repo, null, oldFiles);
@@ -163,30 +272,32 @@
         ImmutableList.of(new FileEntity(fileName1, "target", FileType.SYMLINK));
     ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
 
-    Repository repository = repoManager.openRepository(testProjectName);
-    ObjectReader objectReader = repository.newObjectReader();
+    try (Repository repository = repoManager.openRepository(testProjectName);
+        ObjectReader objectReader = repository.newObjectReader();
+        RevWalk rw = new RevWalk(objectReader)) {
 
-    Map<String, ModifiedFile> modifiedFiles =
-        diffOperations.loadModifiedFiles(
-            testProjectName,
-            newCommitId,
-            oldCommitId,
-            DiffOptions.DEFAULTS,
-            new RevWalk(objectReader),
-            repository.getConfig());
+      Map<String, ModifiedFile> modifiedFiles =
+          diffOperations.loadModifiedFilesIfNecessary(
+              testProjectName,
+              newCommitId,
+              oldCommitId,
+              rw,
+              repository.getConfig(),
+              /* enableRenameDetection= */ false);
 
-    assertThat(modifiedFiles)
-        .containsExactly(
-            fileName1,
-            ModifiedFile.builder()
-                .changeType(ChangeType.REWRITE)
-                .oldPath(Optional.empty())
-                .newPath(Optional.of(fileName1))
-                .build());
+      assertThat(modifiedFiles)
+          .containsExactly(
+              fileName1,
+              ModifiedFile.builder()
+                  .changeType(ChangeType.REWRITE)
+                  .oldPath(Optional.empty())
+                  .newPath(Optional.of(fileName1))
+                  .build());
+    }
   }
 
   @Test
-  public void loadModifiedFilesAgainstParent() throws Exception {
+  public void loadModifiedFilesAgainstParentIfNecessary() throws Exception {
     ImmutableList<FileEntity> oldFiles =
         ImmutableList.of(
             new FileEntity(fileName1, fileContent1), new FileEntity(fileName2, fileContent2));
@@ -198,24 +309,116 @@
             new FileEntity(fileName2, fileContent2 + "\nnew line here"));
     ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
 
-    Repository repository = repoManager.openRepository(testProjectName);
-    ObjectReader objectReader = repository.newObjectReader();
-    RevWalk rw = new RevWalk(objectReader);
-    StoredConfig repoConfig = repository.getConfig();
+    try (Repository repository = repoManager.openRepository(testProjectName);
+        ObjectInserter ins = repository.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      ModifiedFilesCacheKey cacheKey =
+          ModifiedFilesCacheKey.builder()
+              .project(testProjectName)
+              .aCommit(oldCommitId)
+              .bCommit(newCommitId)
+              .disableRenameDetection()
+              .build();
+      assertThat(modifiedFilesCacheImpl.getIfPresent(cacheKey)).isEmpty();
 
-    // This call loads modified files directly without going through the diff cache.
-    Map<String, ModifiedFile> modifiedFiles =
-        diffOperations.loadModifiedFilesAgainstParent(
-            testProjectName, newCommitId, /* parentNum=*/ 0, DiffOptions.DEFAULTS, rw, repoConfig);
+      // This call loads modified files directly without going through the diff cache.
+      Map<String, ModifiedFile> modifiedFiles =
+          diffOperations.loadModifiedFilesAgainstParentIfNecessary(
+              testProjectName,
+              newCommitId,
+              /* parentNum=*/ 0,
+              new RepoView(repository, rw, ins),
+              ins,
+              /* enableRenameDetection= */ false);
 
-    assertThat(modifiedFiles)
-        .containsExactly(
-            fileName2,
-            ModifiedFile.builder()
-                .changeType(ChangeType.MODIFIED)
-                .oldPath(Optional.of(fileName2))
-                .newPath(Optional.of(fileName2))
-                .build());
+      ModifiedFile expectedModifiedFile =
+          ModifiedFile.builder()
+              .changeType(ChangeType.MODIFIED)
+              .oldPath(Optional.of(fileName2))
+              .newPath(Optional.of(fileName2))
+              .build();
+      assertThat(modifiedFiles).containsExactly(fileName2, expectedModifiedFile);
+      assertThat(modifiedFilesCacheImpl.getIfPresent(cacheKey))
+          .hasValue(ImmutableList.of(expectedModifiedFile));
+    }
+  }
+
+  @Test
+  public void loadModifiedFilesAgainstParentIfNecessary_withRename() throws Exception {
+    ImmutableList<FileEntity> oldFiles = ImmutableList.of(new FileEntity(fileName1, fileContent1));
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    ImmutableList<FileEntity> newFiles = ImmutableList.of(new FileEntity(fileName2, fileContent1));
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    try (Repository repository = repoManager.openRepository(testProjectName);
+        ObjectInserter ins = repository.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      // load modified files without rename detection
+      ModifiedFilesCacheKey cacheKey =
+          ModifiedFilesCacheKey.builder()
+              .project(testProjectName)
+              .aCommit(oldCommitId)
+              .bCommit(newCommitId)
+              .disableRenameDetection()
+              .build();
+      assertThat(modifiedFilesCacheImpl.getIfPresent(cacheKey)).isEmpty();
+
+      Map<String, ModifiedFile> modifiedFiles =
+          diffOperations.loadModifiedFilesAgainstParentIfNecessary(
+              testProjectName,
+              newCommitId,
+              /* parentNum=*/ 0,
+              new RepoView(repository, rw, ins),
+              ins,
+              /* enableRenameDetection= */ false);
+
+      ModifiedFile expectedDeletedFile =
+          ModifiedFile.builder()
+              .changeType(ChangeType.DELETED)
+              .oldPath(Optional.of(fileName1))
+              .build();
+      ModifiedFile expectedAddedFile =
+          ModifiedFile.builder()
+              .changeType(ChangeType.ADDED)
+              .newPath(Optional.of(fileName2))
+              .build();
+      assertThat(modifiedFiles)
+          .containsExactly(fileName1, expectedDeletedFile, fileName2, expectedAddedFile);
+      assertThat(modifiedFilesCacheImpl.getIfPresent(cacheKey))
+          .hasValue(ImmutableList.of(expectedDeletedFile, expectedAddedFile));
+
+      // load modified files with rename detection
+      cacheKey =
+          ModifiedFilesCacheKey.builder()
+              .project(testProjectName)
+              .aCommit(oldCommitId)
+              .bCommit(newCommitId)
+              .renameScore(DiffOperationsImpl.RENAME_SCORE)
+              .build();
+      assertThat(modifiedFilesCacheImpl.getIfPresent(cacheKey)).isEmpty();
+
+      modifiedFiles =
+          diffOperations.loadModifiedFilesAgainstParentIfNecessary(
+              testProjectName,
+              newCommitId,
+              /* parentNum=*/ 0,
+              new RepoView(repository, rw, ins),
+              ins,
+              /* enableRenameDetection= */ true);
+
+      ModifiedFile expectedRenamedFile =
+          ModifiedFile.builder()
+              .changeType(ChangeType.RENAMED)
+              .oldPath(Optional.of(fileName1))
+              .newPath(Optional.of(fileName2))
+              .build();
+      assertThat(modifiedFiles).containsExactly(fileName2, expectedRenamedFile);
+      assertThat(modifiedFilesCacheImpl.getIfPresent(cacheKey))
+          .hasValue(ImmutableList.of(expectedRenamedFile));
+    }
   }
 
   static class FileEntity {
diff --git a/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
index 52a81ad..cc8685d 100644
--- a/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
+++ b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
@@ -163,7 +163,7 @@
         IntraLineLoader.compute(aText, bText, ImmutableList.of(lines), ImmutableSet.of());
 
     assertThat(diff.getStatus()).isEqualTo(IntraLineDiff.Status.EDIT_LIST);
-    List<Edit> actualEdits = diff.getEdits();
+    ImmutableList<Edit> actualEdits = diff.getEdits();
     assertThat(actualEdits).hasSize(1);
     Edit actualEdit = actualEdits.get(0);
     assertThat(actualEdit.getBeginA()).isEqualTo(lines.getBeginA());
diff --git a/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
index 6c5eb7a..bd97c1b 100644
--- a/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
+++ b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.refPermission;
 
 import com.google.gerrit.entities.Permission;
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 43b0eba..33698fe 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -248,12 +248,14 @@
       newLocal.commit(md);
     }
 
-    requestContext.setContext(() -> null);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(() -> null);
   }
 
   @After
   public void tearDown() throws Exception {
-    requestContext.setContext(null);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(null);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 3b7ad1e..2917c13 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -16,11 +16,11 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.entities.BooleanProjectConfig.REQUIRE_CHANGE_ID;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.RuntimeVersion;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
@@ -1012,6 +1012,7 @@
             });
   }
 
+  @CanIgnoreReturnValue
   private Path writeDefaultAllProjectsConfig(String... lines) throws IOException {
     Path dir = sitePaths.etc_dir.resolve(ALL_PROJECTS.get());
     Files.createDirectories(dir);
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
index 180dd92..0f4bd2e 100644
--- a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
@@ -98,7 +98,7 @@
                 createLabel("Code-Review", Label.Status.OK),
                 createLabel("Verified", Label.Status.OK)));
 
-    List<SubmitRequirementResult> requirements =
+    ImmutableList<SubmitRequirementResult> requirements =
         SubmitRequirementsAdapter.createResult(
             submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
@@ -127,7 +127,7 @@
                 createLabel("Code-Review", Label.Status.NEED),
                 createLabel("Verified", Label.Status.NEED)));
 
-    List<SubmitRequirementResult> requirements =
+    ImmutableList<SubmitRequirementResult> requirements =
         SubmitRequirementsAdapter.createResult(
             submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
@@ -159,7 +159,7 @@
     // to indicate that all other records were forced, that's why we explicitly pass isForced=true
     // to the "submit requirements adapter". The resulting submit requirement result has a
     // status=FORCED.
-    List<SubmitRequirementResult> requirements =
+    ImmutableList<SubmitRequirementResult> requirements =
         SubmitRequirementsAdapter.createResult(
             submitRecord, labelTypes, psCommitId, /* isForced= */ true);
 
@@ -180,7 +180,7 @@
             Status.NOT_READY,
             Arrays.asList(createLabel("ISA-Label", Label.Status.NEED)));
 
-    List<SubmitRequirementResult> requirements =
+    ImmutableList<SubmitRequirementResult> requirements =
         SubmitRequirementsAdapter.createResult(
             submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
@@ -201,7 +201,7 @@
             Status.OK,
             Arrays.asList(createLabel("ISA-Label", Label.Status.OK)));
 
-    List<SubmitRequirementResult> requirements =
+    ImmutableList<SubmitRequirementResult> requirements =
         SubmitRequirementsAdapter.createResult(
             submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
@@ -222,7 +222,7 @@
             Status.OK,
             Arrays.asList(createLabel("Non-Existing", Label.Status.OK)));
 
-    List<SubmitRequirementResult> requirements =
+    ImmutableList<SubmitRequirementResult> requirements =
         SubmitRequirementsAdapter.createResult(
             submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
@@ -239,7 +239,7 @@
                 createLabel("Non-Existing", Label.Status.OK),
                 createLabel("Code-Review", Label.Status.OK)));
 
-    List<SubmitRequirementResult> requirements =
+    ImmutableList<SubmitRequirementResult> requirements =
         SubmitRequirementsAdapter.createResult(
             submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
@@ -258,7 +258,7 @@
     SubmitRecord submitRecord =
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, Arrays.asList());
 
-    List<SubmitRequirementResult> requirements =
+    ImmutableList<SubmitRequirementResult> requirements =
         SubmitRequirementsAdapter.createResult(
             submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
@@ -276,7 +276,7 @@
     SubmitRecord submitRecord =
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, /* labels= */ null);
 
-    List<SubmitRequirementResult> requirements =
+    ImmutableList<SubmitRequirementResult> requirements =
         SubmitRequirementsAdapter.createResult(
             submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
@@ -294,7 +294,7 @@
     SubmitRecord submitRecord =
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.NOT_READY, Arrays.asList());
 
-    List<SubmitRequirementResult> requirements =
+    ImmutableList<SubmitRequirementResult> requirements =
         SubmitRequirementsAdapter.createResult(
             submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
@@ -317,7 +317,7 @@
                 createLabel("custom-label-1", Label.Status.NEED),
                 createLabel("custom-label-2", Label.Status.REJECT)));
 
-    List<SubmitRequirementResult> requirements =
+    ImmutableList<SubmitRequirementResult> requirements =
         SubmitRequirementsAdapter.createResult(
             submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
@@ -346,7 +346,7 @@
                 createLabel("custom-label-1", Label.Status.OK),
                 createLabel("custom-label-2", Label.Status.REJECT)));
 
-    List<SubmitRequirementResult> requirements =
+    ImmutableList<SubmitRequirementResult> requirements =
         SubmitRequirementsAdapter.createResult(
             submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index c530c79..0075121 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
@@ -25,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
@@ -179,7 +179,7 @@
 
     Account.Id adminId = createAccount("admin", "Administrator", "admin@example.com", true);
     admin = userFactory.create(adminId);
-    requestContext.setContext(newRequestContext(adminId));
+    setRequestContextForUser(adminId);
     currentUserInfo = gApi.accounts().id(adminId.get()).get();
   }
 
@@ -191,7 +191,8 @@
   }
 
   protected void setAnonymous() {
-    requestContext.setContext(anonymousUser::get);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(anonymousUser::get);
   }
 
   @After
@@ -199,7 +200,8 @@
     if (lifecycle != null) {
       lifecycle.stop();
     }
-    requestContext.setContext(null);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(null);
   }
 
   @Test
@@ -271,7 +273,7 @@
     addEmails(user1, secondaryEmail);
 
     AccountInfo user2 = newAccount("user");
-    requestContext.setContext(newRequestContext(Account.id(user2._accountId)));
+    setRequestContextForUser(Account.id(user2._accountId));
 
     assertQuery(preferredEmail, user1);
     assertQuery(secondaryEmail);
@@ -347,7 +349,7 @@
     AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
 
     AccountInfo user3 = newAccount("user");
-    requestContext.setContext(newRequestContext(Account.id(user3._accountId)));
+    setRequestContextForUser(Account.id(user3._accountId));
 
     assertQuery("notexisting");
     assertQuery("Not Existing");
@@ -403,7 +405,7 @@
     Project.NameKey p = createProject(name("p"));
 
     // Create the change as User1
-    requestContext.setContext(newRequestContext(Account.id(user1._accountId)));
+    setRequestContextForUser(Account.id(user1._accountId));
     ChangeInfo c = createPrivateChange(p);
     assertThat(c.owner).isEqualTo(user1);
 
@@ -412,19 +414,19 @@
     addReviewer(c.changeId, user3.email, ReviewerState.CC);
 
     // Request as the owner
-    requestContext.setContext(newRequestContext(Account.id(user1._accountId)));
+    setRequestContextForUser(Account.id(user1._accountId));
     assertQuery("cansee:" + c.changeId, user1, user2, user3);
 
     // Request as the reviewer
-    requestContext.setContext(newRequestContext(Account.id(user2._accountId)));
+    setRequestContextForUser(Account.id(user2._accountId));
     assertQuery("cansee:" + c.changeId, user1, user2, user3);
 
     // Request as the CC
-    requestContext.setContext(newRequestContext(Account.id(user3._accountId)));
+    setRequestContextForUser(Account.id(user3._accountId));
     assertQuery("cansee:" + c.changeId, user1, user2, user3);
 
     // Request as an account not in {owner, reviewer, CC}
-    requestContext.setContext(newRequestContext(Account.id(user4._accountId)));
+    setRequestContextForUser(Account.id(user4._accountId));
     BadRequestException exception =
         assertThrows(BadRequestException.class, () -> newQuery("cansee:" + c.changeId).get());
     assertThat(exception)
@@ -626,7 +628,7 @@
     String[] secondaryEmails = new String[] {"dfg@example.com", "hij@example.com"};
     addEmails(otherUser, secondaryEmails);
 
-    requestContext.setContext(newRequestContext(Account.id(user._accountId)));
+    setRequestContextForUser(Account.id(user._accountId));
 
     List<AccountInfo> result = newQuery(getDefaultSearch(otherUser)).withSuggest(true).get();
     assertThat(result.get(0).secondaryEmails).isNull();
@@ -667,8 +669,8 @@
     // we load AccountStates from the cache after reading documents from the index
     // which means we always read fresh data when matching.
     //
-    // Reindex document
-    gApi.accounts().id(user1.username).index();
+    // Reindex account document
+    gApi.accounts().id(user1._accountId).index();
     assertQuery("name:" + quote(user1.name));
     assertQuery("name:" + quote(newName), user1);
   }
@@ -870,6 +872,11 @@
     }
   }
 
+  private void setRequestContextForUser(Account.Id userId) {
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(newRequestContext(userId));
+  }
+
   private void addEmails(AccountInfo account, String... emails) throws Exception {
     Account.Id id = Account.id(account._accountId);
     for (String email : emails) {
@@ -882,19 +889,22 @@
     return gApi.accounts().query(query.toString());
   }
 
+  @CanIgnoreReturnValue
   protected List<AccountInfo> assertQuery(Object query, AccountInfo... accounts) throws Exception {
     return assertQuery(newQuery(query), accounts);
   }
 
+  @CanIgnoreReturnValue
   protected List<AccountInfo> assertQuery(QueryRequest query, AccountInfo... accounts)
       throws Exception {
     return assertQuery(query, Arrays.asList(accounts));
   }
 
+  @CanIgnoreReturnValue
   protected List<AccountInfo> assertQuery(QueryRequest query, List<AccountInfo> accounts)
       throws Exception {
     List<AccountInfo> result = query.get();
-    Iterable<Integer> ids = ids(result);
+    List<Integer> ids = ids(result);
     assertWithMessage(format(query, result, accounts))
         .that(ids)
         .containsExactlyElementsIn(ids(accounts))
@@ -944,11 +954,11 @@
     return b.toString();
   }
 
-  protected static Iterable<Integer> ids(AccountInfo... accounts) {
+  protected static List<Integer> ids(AccountInfo... accounts) {
     return ids(Arrays.asList(accounts));
   }
 
-  protected static Iterable<Integer> ids(List<AccountInfo> accounts) {
+  protected static List<Integer> ids(List<AccountInfo> accounts) {
     return accounts.stream().map(a -> a._accountId).collect(toList());
   }
 
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index 2c31aef..fb0e739 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -24,6 +24,7 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/guice",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
@@ -41,6 +42,7 @@
         ":abstract_query_tests",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib:jgit",
         "//lib/guice",
     ],
@@ -57,6 +59,7 @@
         ":abstract_query_tests",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib:jgit",
         "//lib/guice",
     ],
diff --git a/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
index 31d256e..69ed948 100644
--- a/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.account;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -21,7 +22,6 @@
 import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
@@ -34,7 +34,7 @@
   @ConfigSuite.Configs
   public static Map<String, Config> againstPreviousIndexVersion() {
     // the current schema version is already tested by the inherited default config suite
-    List<Integer> schemaVersions =
+    ImmutableList<Integer> schemaVersions =
         IndexVersions.getWithoutLatest(AccountSchemaDefinitions.INSTANCE);
     return IndexVersions.asConfigMap(
         AccountSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
diff --git a/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
index e36b79e..424b5c4 100644
--- a/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.account;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -21,7 +22,6 @@
 import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
@@ -34,7 +34,7 @@
   @ConfigSuite.Configs
   public static Map<String, Config> againstPreviousIndexVersion() {
     // the current schema version is already tested by the inherited default config suite
-    List<Integer> schemaVersions =
+    ImmutableList<Integer> schemaVersions =
         IndexVersions.getWithoutLatest(AccountSchemaDefinitions.INSTANCE);
     return IndexVersions.asConfigMap(
         AccountSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 28c7be9..a0aad96 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -41,6 +41,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
@@ -186,7 +187,7 @@
   @Inject protected BatchUpdate.Factory updateFactory;
   @Inject protected AllProjectsName allProjectsName;
   @Inject protected ChangeInserter.Factory changeFactory;
-  @Inject protected ChangeQueryBuilder queryBuilder;
+  @Inject protected Provider<ChangeQueryBuilder> queryBuilderProvider;
   @Inject protected GerritApi gApi;
   @Inject protected IdentifiedUser.GenericFactory userFactory;
   @Inject protected ChangeIndexCollection indexes;
@@ -279,7 +280,7 @@
   protected void resetUser() throws ConfigInvalidException, IOException {
     user = userFactory.create(userId);
     userAccount = accounts.get(userId).get().account();
-    requestContext.setContext(newRequestContext(userId));
+    setRequestContextForUser(userId);
   }
 
   @After
@@ -287,7 +288,8 @@
     if (lifecycle != null) {
       lifecycle.stop();
     }
-    requestContext.setContext(null);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(null);
   }
 
   @Before
@@ -314,9 +316,10 @@
 
   @Test
   public void byId() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
 
     assertQuery("12345");
     assertQuery(change1.getId().get(), change1);
@@ -325,8 +328,9 @@
 
   @Test
   public void byKey() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChange(repo));
     String key = change.getKey().get();
 
     assertQuery("I0000000000000000000000000000000000000000");
@@ -338,8 +342,9 @@
 
   @Test
   public void byTriplet() throws Exception {
-    repo = createAndOpenProject("iabcde");
-    Change change = insert("iabcde", newChangeForBranch(repo, "branch"));
+    Project.NameKey project = Project.nameKey("iabcde");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChangeForBranch(repo, "branch"));
     String k = change.getKey().get();
 
     assertQuery("iabcde~branch~" + k, change);
@@ -361,11 +366,12 @@
 
   @Test
   public void byStatus() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert("repo", ins1);
+    Change change1 = insert(project, ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change2 = insert("repo", ins2);
+    Change change2 = insert(project, ins2);
 
     assertQuery("status:new", change1);
     assertQuery("status:NEW", change1);
@@ -380,11 +386,12 @@
 
   @Test
   public void byStatusOr() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert("repo", ins1);
+    Change change1 = insert(project, ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change2 = insert("repo", ins2);
+    Change change2 = insert(project, ins2);
 
     assertQuery("status:new OR status:merged", change2, change1);
     assertQuery("status:new or status:merged", change2, change1);
@@ -392,10 +399,11 @@
 
   @Test
   public void byStatusOpen() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert("repo", ins1);
-    insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change1 = insert(project, ins1);
+    insert(project, newChangeWithStatus(repo, Change.Status.MERGED));
 
     Change[] expected = new Change[] {change1};
     assertQuery("status:open", expected);
@@ -414,12 +422,13 @@
 
   @Test
   public void byStatusClosed() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change1 = insert("repo", ins1);
+    Change change1 = insert(project, ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
-    Change change2 = insert("repo", ins2);
-    insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+    Change change2 = insert(project, ins2);
+    insert(project, newChangeWithStatus(repo, Change.Status.NEW));
 
     Change[] expected = new Change[] {change2, change1};
     assertQuery("status:closed", expected);
@@ -435,12 +444,13 @@
 
   @Test
   public void byStatusAbandoned() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
-    insert("repo", ins1);
+    insert(project, ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
-    Change change1 = insert("repo", ins2);
-    insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+    Change change1 = insert(project, ins2);
+    insert(project, newChangeWithStatus(repo, Change.Status.NEW));
 
     assertQuery("status:abandoned", change1);
     assertQuery("status:ABANDONED", change1);
@@ -449,10 +459,11 @@
 
   @Test
   public void byStatusPrefix() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert("repo", ins1);
-    Change change2 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change1 = insert(project, ins1);
+    Change change2 = insert(project, newChangeWithStatus(repo, Change.Status.MERGED));
 
     assertQuery("status:n", change1);
     assertQuery("status:ne", change1);
@@ -469,11 +480,12 @@
 
   @Test
   public void byPrivate() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo), userId);
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert("repo", newChange(repo), user2);
+    Change change2 = insert(project, newChange(repo), user2);
 
     // No private changes.
     assertQuery("is:open", change2, change1);
@@ -486,15 +498,16 @@
     assertQuery("is:private", change1);
 
     // Switch request context to user2.
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
     assertQuery("is:open", change2);
     assertQuery("is:private");
   }
 
   @Test
   public void byWip() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo), userId);
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo), userId);
 
     assertQuery("is:open", change1);
     assertQuery("is:wip");
@@ -511,8 +524,9 @@
   @Test
   public void excludeWipChangeFromReviewersDashboards() throws Exception {
     Account.Id user1 = createAccount("user1");
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChangeWorkInProgress(repo), userId);
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChangeWorkInProgress(repo), userId);
 
     assertQuery("is:wip", change1);
     assertQuery("reviewer:" + user1);
@@ -528,8 +542,9 @@
 
   @Test
   public void byStarted() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChangeWorkInProgress(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChangeWorkInProgress(repo));
 
     assertQuery("is:started");
 
@@ -564,11 +579,11 @@
   @Test
   public void restorePendingReviewers() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    repo = createAndOpenProject(project.get());
+    repo = createAndOpenProject(project);
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
-    Change change1 = insert("repo", newChangeWorkInProgress(repo));
+    Change change1 = insert(project, newChangeWorkInProgress(repo));
     Account.Id user1 = createAccount("user1");
     Account.Id user2 = createAccount("user2");
     String email1 = "email1@example.com";
@@ -621,9 +636,10 @@
 
   @Test
   public void byCommit() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins = newChange(repo);
-    Change change = insert("repo", ins);
+    Change change = insert(project, ins);
     String sha = ins.getCommitId().name();
 
     assertQuery("0000000000000000000000000000000000000000");
@@ -637,11 +653,12 @@
 
   @Test
   public void byOwner() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo), userId);
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert("repo", newChange(repo), user2);
+    Change change2 = insert(project, newChange(repo), user2);
 
     assertQuery("is:owner", change1);
     assertQuery("owner:" + userId.get(), change1);
@@ -655,20 +672,21 @@
   public void byUploader() throws Exception {
     assume().that(getSchema().hasField(ChangeField.UPLOADER_SPEC)).isTrue();
 
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo), userId);
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo), userId);
     assertQuery("is:uploader", change1);
     assertQuery("uploader:" + userId.get(), change1);
 
     Account.Id user2 = createAccount("anotheruser");
     CurrentUser user2CurrentUser = userFactory.create(user2);
 
-    change1 = newPatchSet("repo", change1, user2CurrentUser, /* message= */ Optional.empty());
+    change1 = newPatchSet(project, change1, user2CurrentUser, /* message= */ Optional.empty());
     // Uploader has changed
     assertQuery("uploader:" + userId.get());
     assertQuery("uploader:" + user2.get(), change1);
 
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
     assertQuery("is:uploader", change1); // self (user2)
 
     String nameEmail = user2CurrentUser.asIdentifiedUser().getNameEmail();
@@ -706,7 +724,8 @@
   }
 
   private void byAuthorOrCommitterExact(String searchOperator) throws Exception {
-    createProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    createProject(project);
     PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
     PersonIdent john = new PersonIdent("John", "john@example.com");
     PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
@@ -714,10 +733,10 @@
     PersonIdent myself = new PersonIdent("I Am", ua.preferredEmail());
     PersonIdent selfName = new PersonIdent("My Self", "my.self@example.com");
 
-    Change change1 = createChange("repo", johnDoe);
-    Change change2 = createChange("repo", john);
-    Change change3 = createChange("repo", doeSmith);
-    createChange("repo", selfName);
+    Change change1 = createChange(project, johnDoe);
+    Change change2 = createChange(project, john);
+    Change change3 = createChange(project, doeSmith);
+    createChange(project, selfName);
 
     // Only email address.
     assertQuery(searchOperator + "john.doe@example.com", change1);
@@ -742,19 +761,20 @@
     assertQuery(searchOperator + "self");
 
     // ':self' matches a change created with the current user's email address
-    Change change5 = createChange("repo", myself);
+    Change change5 = createChange(project, myself);
     assertQuery(searchOperator + "me", change5);
     assertQuery(searchOperator + "self", change5);
   }
 
   private void byAuthorOrCommitterFullText(String searchOperator) throws Exception {
-    createProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    createProject(project);
     PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
     PersonIdent john = new PersonIdent("John", "john@example.com");
     PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
-    Change change1 = createChange("repo", johnDoe);
-    Change change2 = createChange("repo", john);
-    Change change3 = createChange("repo", doeSmith);
+    Change change1 = createChange(project, johnDoe);
+    Change change2 = createChange(project, john);
+    Change change3 = createChange(project, doeSmith);
 
     // By exact name.
     assertQuery(searchOperator + "\"John Doe\"", change1);
@@ -776,24 +796,25 @@
   }
 
   @CanIgnoreReturnValue
-  protected Change createChange(String repoName, PersonIdent person) throws Exception {
+  protected Change createChange(Project.NameKey project, PersonIdent person) throws Exception {
     try (TestRepository<Repository> repo =
-        new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+        new TestRepository<>(repoManager.openRepository(project))) {
       RevCommit commit =
           repo.parseBody(
               repo.commit().message("message").author(person).committer(person).create());
-      return insert("repo", newChangeForCommit(repo, commit), null);
+      return insert(project, newChangeForCommit(repo, commit), null);
     }
   }
 
   @Test
   public void byOwnerIn() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo), userId);
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert("repo", newChange(repo), user2);
-    Change change3 = insert("repo", newChange(repo), user2);
+    Change change2 = insert(project, newChange(repo), user2);
+    Change change3 = insert(project, newChange(repo), user2);
     getChangeApi(change3).current().review(ReviewInput.approve());
     getChangeApi(change3).current().submit();
 
@@ -841,14 +862,15 @@
   @Test
   public void byUploaderIn() throws Exception {
     assume().that(getSchema().hasField(ChangeField.UPLOADER_SPEC)).isTrue();
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo), userId);
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo), userId);
 
     assertQuery("uploaderin:Administrators", change1);
 
     Account.Id user2 = createAccount("anotheruser");
     CurrentUser user2CurrentUser = userFactory.create(user2);
-    change1 = newPatchSet("repo", change1, user2CurrentUser, /* message= */ Optional.empty());
+    change1 = newPatchSet(project, change1, user2CurrentUser, /* message= */ Optional.empty());
 
     assertQuery("uploaderin:Administrators");
     assertQuery("uploaderin:\"Registered Users\"", change1);
@@ -887,10 +909,12 @@
 
   @Test
   public void byProject() throws Exception {
-    createProject("repo1");
-    createProject("repo2");
-    Change change1 = insert("repo1", newChange("repo1"));
-    Change change2 = insert("repo2", newChange("repo2"));
+    Project.NameKey project1 = Project.nameKey("repo1");
+    repo = createAndOpenProject(project1);
+    Project.NameKey project2 = Project.nameKey("repo2");
+    repo = createAndOpenProject(project2);
+    Change change1 = insert(project1, newChange(project1));
+    Change change2 = insert(project2, newChange(project2));
 
     assertQuery("project:foo");
     assertQuery("project:repo");
@@ -900,16 +924,18 @@
 
   @Test
   public void byProjectWithHidden() throws Exception {
-    createProject("hiddenProject");
-    insert("hiddenProject", newChange("hiddenProject"));
+    Project.NameKey hiddenProject = Project.nameKey("hiddenProject");
+    createProject(hiddenProject);
+    insert(hiddenProject, newChange(hiddenProject));
     projectOperations
-        .project(Project.nameKey("hiddenProject"))
+        .project(hiddenProject)
         .forUpdate()
         .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
-    createProject("visibleProject");
-    Change visibleChange = insert("visibleProject", newChange("visibleProject"));
+    Project.NameKey visibleProject = Project.nameKey("visibleProject");
+    createProject(visibleProject);
+    Change visibleChange = insert(visibleProject, newChange(visibleProject));
     assertQuery("project:visibleProject", visibleChange);
     assertQuery("project:hiddenProject");
     assertQuery("project:visibleProject OR project:hiddenProject", visibleChange);
@@ -917,13 +943,14 @@
 
   @Test
   public void byParentOf() throws Exception {
-    repo = createAndOpenProject("repo1");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 = repo.parseBody(repo.commit().message("message").create());
-    Change change1 = insert("repo1", newChangeForCommit(repo, commit1));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit(commit1));
-    Change change2 = insert("repo1", newChangeForCommit(repo, commit2));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit(commit1, commit2));
-    Change change3 = insert("repo1", newChangeForCommit(repo, commit3));
+    Change change3 = insert(project, newChangeForCommit(repo, commit3));
 
     assertQuery("parentof:" + change1.getId().get());
     assertQuery("parentof:" + change1.getKey().get());
@@ -935,10 +962,12 @@
 
   @Test
   public void byParentProject() throws Exception {
-    createProject("repo1");
-    createProject("repo2", "repo1");
-    Change change1 = insert("repo1", newChange("repo1"));
-    Change change2 = insert("repo2", newChange("repo2"));
+    Project.NameKey project1 = Project.nameKey("repo1");
+    createProject(project1);
+    Project.NameKey project2 = Project.nameKey("repo2");
+    createProject(project2, project1);
+    Change change1 = insert(project1, newChange(project1));
+    Change change2 = insert(project2, newChange(project2));
 
     assertQuery("parentproject:repo1", change2, change1);
     assertQuery("parentproject:repo2", change2);
@@ -946,10 +975,12 @@
 
   @Test
   public void byProjectPrefix() throws Exception {
-    createProject("repo1");
-    createProject("repo2");
-    Change change1 = insert("repo1", newChange("repo1"));
-    Change change2 = insert("repo2", newChange("repo2"));
+    Project.NameKey project1 = Project.nameKey("repo1");
+    Project.NameKey project2 = Project.nameKey("repo2");
+    createProject(project1);
+    createProject(project2);
+    Change change1 = insert(project1, newChange(project1));
+    Change change2 = insert(project2, newChange(project2));
 
     assertQuery("projects:foo");
     assertQuery("projects:repo1", change1);
@@ -959,10 +990,12 @@
 
   @Test
   public void byRepository() throws Exception {
-    createProject("repo1");
-    createProject("repo2");
-    Change change1 = insert("repo1", newChange("repo1"));
-    Change change2 = insert("repo2", newChange("repo2"));
+    Project.NameKey project1 = Project.nameKey("repo1");
+    createProject(project1);
+    Project.NameKey project2 = Project.nameKey("repo2");
+    createProject(project2);
+    Change change1 = insert(project1, newChange(project1));
+    Change change2 = insert(project2, newChange(project2));
 
     assertQuery("repository:foo");
     assertQuery("repository:repo");
@@ -972,10 +1005,12 @@
 
   @Test
   public void byParentRepository() throws Exception {
-    createProject("repo1");
-    createProject("repo2", "repo1");
-    Change change1 = insert("repo1", newChange("repo1"));
-    Change change2 = insert("repo2", newChange("repo2"));
+    Project.NameKey project1 = Project.nameKey("repo1");
+    createProject(project1);
+    Project.NameKey project2 = Project.nameKey("repo2");
+    createProject(project2, project1);
+    Change change1 = insert(project1, newChange(project1));
+    Change change2 = insert(project2, newChange(project2));
 
     assertQuery("parentrepository:repo1", change2, change1);
     assertQuery("parentrepository:repo2", change2);
@@ -983,10 +1018,12 @@
 
   @Test
   public void byRepositoryPrefix() throws Exception {
-    createProject("repo1");
-    createProject("repo2");
-    Change change1 = insert("repo1", newChange("repo1"));
-    Change change2 = insert("repo2", newChange("repo2"));
+    Project.NameKey project1 = Project.nameKey("repo1");
+    createProject(project1);
+    Project.NameKey project2 = Project.nameKey("repo2");
+    createProject(project2);
+    Change change1 = insert(project1, newChange(project1));
+    Change change2 = insert(project2, newChange(project2));
 
     assertQuery("repositories:foo");
     assertQuery("repositories:repo1", change1);
@@ -996,10 +1033,12 @@
 
   @Test
   public void byRepo() throws Exception {
-    createProject("repo1");
-    createProject("repo2");
-    Change change1 = insert("repo1", newChange("repo1"));
-    Change change2 = insert("repo2", newChange("repo2"));
+    Project.NameKey project1 = Project.nameKey("repo1");
+    createProject(project1);
+    Project.NameKey project2 = Project.nameKey("repo2");
+    createProject(project2);
+    Change change1 = insert(project1, newChange(project1));
+    Change change2 = insert(project2, newChange(project2));
 
     assertQuery("repo:foo");
     assertQuery("repo:repo");
@@ -1009,10 +1048,12 @@
 
   @Test
   public void byParentRepo() throws Exception {
-    createProject("repo1");
-    createProject("repo2", "repo1");
-    Change change1 = insert("repo1", newChange("repo1"));
-    Change change2 = insert("repo2", newChange("repo2"));
+    Project.NameKey project1 = Project.nameKey("repo1");
+    createProject(project1);
+    Project.NameKey project2 = Project.nameKey("repo2");
+    createProject(project2, project1);
+    Change change1 = insert(project1, newChange(project1));
+    Change change2 = insert(project2, newChange(project2));
 
     assertQuery("parentrepo:repo1", change2, change1);
     assertQuery("parentrepo:repo2", change2);
@@ -1020,10 +1061,12 @@
 
   @Test
   public void byRepoPrefix() throws Exception {
-    createProject("repo1");
-    createProject("repo2");
-    Change change1 = insert("repo1", newChange("repo1"));
-    Change change2 = insert("repo2", newChange("repo2"));
+    Project.NameKey project1 = Project.nameKey("repo1");
+    createProject(project1);
+    Project.NameKey project2 = Project.nameKey("repo2");
+    createProject(project2);
+    Change change1 = insert(project1, newChange(project1));
+    Change change2 = insert(project2, newChange(project2));
 
     assertQuery("repos:foo");
     assertQuery("repos:repo1", change1);
@@ -1033,9 +1076,10 @@
 
   @Test
   public void byBranchAndRef() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChangeForBranch(repo, "master"));
-    Change change2 = insert("repo", newChangeForBranch(repo, "branch"));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChangeForBranch(repo, "master"));
+    Change change2 = insert(project, newChangeForBranch(repo, "branch"));
 
     assertQuery("branch:foo");
     assertQuery("branch:master", change1);
@@ -1051,27 +1095,27 @@
 
   @Test
   public void byTopic() throws Exception {
-
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
-    Change change1 = insert("repo", ins1);
+    Change change1 = insert(project, ins1);
 
     ChangeInserter ins2 = newChangeWithTopic(repo, "feature2");
-    Change change2 = insert("repo", ins2);
+    Change change2 = insert(project, ins2);
 
     ChangeInserter ins3 = newChangeWithTopic(repo, "Cherrypick-feature2");
-    Change change3 = insert("repo", ins3);
+    Change change3 = insert(project, ins3);
 
     ChangeInserter ins4 = newChangeWithTopic(repo, "feature2-fixup");
-    Change change4 = insert("repo", ins4);
+    Change change4 = insert(project, ins4);
 
     ChangeInserter ins5 = newChangeWithTopic(repo, "https://gerrit.local");
-    Change change5 = insert("repo", ins5);
+    Change change5 = insert(project, ins5);
 
     ChangeInserter ins6 = newChangeWithTopic(repo, "git_gerrit_training");
-    Change change6 = insert("repo", ins6);
+    Change change6 = insert(project, ins6);
 
-    Change changeNoTopic = insert("repo", newChange(repo));
+    Change changeNoTopic = insert(project, newChange(repo));
 
     assertQuery("intopic:foo");
     assertQuery("intopic:feature1", change1);
@@ -1091,16 +1135,17 @@
 
   @Test
   public void byTopicRegex() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
 
     ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
-    Change change1 = insert("repo", ins1);
+    Change change1 = insert(project, ins1);
 
     ChangeInserter ins2 = newChangeWithTopic(repo, "Cherrypick-feature1");
-    Change change2 = insert("repo", ins2);
+    Change change2 = insert(project, ins2);
 
     ChangeInserter ins3 = newChangeWithTopic(repo, "feature1-fixup");
-    Change change3 = insert("repo", ins3);
+    Change change3 = insert(project, ins3);
 
     assertQuery("intopic:^feature1.*", change3, change1);
     assertQuery("intopic:{^.*feature1$}", change2, change1);
@@ -1108,25 +1153,25 @@
 
   @Test
   public void byMessageExact_byAlias_d() throws Exception {
-    byMessageExact("d:", "d_repo");
+    byMessageExact("d:", Project.nameKey("d_repo"));
   }
 
   @Test
   public void byMessageExact_byAlias_description() throws Exception {
-    byMessageExact("description:", "description_repo");
+    byMessageExact("description:", Project.nameKey("description_repo"));
   }
 
   @Test
   public void byMessageExact_byAlias_m() throws Exception {
-    byMessageExact("m:", "m_repo");
+    byMessageExact("m:", Project.nameKey("m_repo"));
   }
 
   @Test
   public void byMessageExact_byMainOperator() throws Exception {
-    byMessageExact("message:", "message_repo");
+    byMessageExact("message:", Project.nameKey("message_repo"));
   }
 
-  private void byMessageExact(String searchOperator, String projectName) throws Exception {
+  private void byMessageExact(String searchOperator, Project.NameKey projectName) throws Exception {
     repo = createAndOpenProject(projectName);
     RevCommit commit1 = repo.parseBody(repo.commit().message("one").create());
     Change change1 = insert(projectName, newChangeForCommit(repo, commit1));
@@ -1143,25 +1188,25 @@
 
   @Test
   public void byMessageRegEx_byAlias_d() throws Exception {
-    byMessageRegEx("d:", "d_repo");
+    byMessageRegEx("d:", Project.nameKey("d_repo"));
   }
 
   @Test
   public void byMessageRegEx_byAlias_description() throws Exception {
-    byMessageRegEx("description:", "description_repo");
+    byMessageRegEx("description:", Project.nameKey("description_repo"));
   }
 
   @Test
   public void byMessageRegEx_byAlias_m() throws Exception {
-    byMessageRegEx("m:", "m_repo");
+    byMessageRegEx("m:", Project.nameKey("m_repo"));
   }
 
   @Test
   public void byMessageRegEx_byMainOperator() throws Exception {
-    byMessageRegEx("message:", "message_repo");
+    byMessageRegEx("message:", Project.nameKey("message_repo"));
   }
 
-  private void byMessageRegEx(String searchOperator, String projectName) throws Exception {
+  private void byMessageRegEx(String searchOperator, Project.NameKey projectName) throws Exception {
     assume().that(getSchema().hasField(ChangeField.COMMIT_MESSAGE_EXACT)).isTrue();
     repo = createAndOpenProject(projectName);
     RevCommit commit1 = repo.parseBody(repo.commit().message("aaaabcc").create());
@@ -1186,7 +1231,8 @@
   @Test
   public void bySubject() throws Exception {
     assume().that(getSchema().hasField(ChangeField.SUBJECT_SPEC)).isTrue();
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 =
         repo.parseBody(
             repo.commit()
@@ -1195,7 +1241,7 @@
                         + "Message body\n\n"
                         + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b920")
                 .create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
     RevCommit commit2 =
         repo.parseBody(
             repo.commit()
@@ -1204,7 +1250,7 @@
                         + "Message body for another commit\n\n"
                         + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
                 .create());
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
     RevCommit commit3 =
         repo.parseBody(
             repo.commit()
@@ -1213,7 +1259,7 @@
                         + "Last message body\n\n"
                         + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
                 .create());
-    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+    Change change3 = insert(project, newChangeForCommit(repo, commit3));
 
     assertQuery("subject:First", change1);
     assertQuery("subject:Second", change2);
@@ -1223,7 +1269,7 @@
     assertQuery("subject:body");
     change1 =
         newPatchSet(
-            "repo",
+            project,
             change1,
             user,
             Optional.of("Rework of commit with test subject\n\n" + "Message body\n\n"));
@@ -1235,7 +1281,8 @@
   @Test
   public void bySubjectPrefix() throws Exception {
     assume().that(getSchema().hasField(ChangeField.PREFIX_SUBJECT_SPEC)).isTrue();
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 =
         repo.parseBody(
             repo.commit()
@@ -1244,7 +1291,7 @@
                         + "Message body\n\n"
                         + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b920")
                 .create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
     RevCommit commit2 =
         repo.parseBody(
             repo.commit()
@@ -1253,7 +1300,7 @@
                         + "Message body for another commit\n\n"
                         + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
                 .create());
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
     RevCommit commit3 =
         repo.parseBody(
             repo.commit()
@@ -1262,7 +1309,7 @@
                         + "Last message body\n\n"
                         + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
                 .create());
-    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+    Change change3 = insert(project, newChangeForCommit(repo, commit3));
 
     assertQuery("prefixsubject:\"[FOO\"", change3, change1);
     assertQuery("prefixsubject:\"[BAR\"", change2);
@@ -1272,7 +1319,7 @@
     assertQuery("prefixsubject:FOO");
     change1 =
         newPatchSet(
-            "repo",
+            project,
             change1,
             user,
             Optional.of("[BAR123] Rework of commit with test subject\n\n" + "Message body\n\n"));
@@ -1282,11 +1329,12 @@
 
   @Test
   public void fullTextWithNumbers() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 = repo.parseBody(repo.commit().message("12345 67890").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("12346 67891").create());
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
 
     assertQuery("message:1234");
     assertQuery("message:12345", change1);
@@ -1295,13 +1343,14 @@
 
   @Test
   public void fullTextMultipleTerms() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 = repo.parseBody(repo.commit().message("Signed-off: owner").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Signed by owner").create());
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("This change is off").create());
-    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+    Change change3 = insert(project, newChangeForCommit(repo, commit3));
 
     assertQuery("message:\"Signed-off: owner\"", change1);
     assertQuery("message:\"Signed\"", change2, change1);
@@ -1310,11 +1359,12 @@
 
   @Test
   public void byMessageMixedCase() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 = repo.parseBody(repo.commit().message("Hello gerrit").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Hello Gerrit").create());
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
 
     assertQuery("message:gerrit", change2, change1);
     assertQuery("message:Gerrit", change2, change1);
@@ -1322,16 +1372,18 @@
 
   @Test
   public void byMessageSubstring() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 = repo.parseBody(repo.commit().message("https://gerrit.local").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
     assertQuery("message:gerrit", change1);
   }
 
   @Test
   public void byLabel() throws Exception {
     Account.Id anotherUser = createAccount("anotheruser");
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins = newChange(repo);
     ChangeInserter ins2 = newChange(repo);
     ChangeInserter ins3 = newChange(repo);
@@ -1339,24 +1391,24 @@
     ChangeInserter ins5 = newChange(repo);
     ChangeInserter ins6 = newChange(repo);
 
-    Change reviewMinus2Change = insert("repo", ins);
+    Change reviewMinus2Change = insert(project, ins);
     getChangeApi(reviewMinus2Change).current().review(ReviewInput.reject());
 
-    Change reviewMinus1Change = insert("repo", ins2);
+    Change reviewMinus1Change = insert(project, ins2);
     getChangeApi(reviewMinus1Change).current().review(ReviewInput.dislike());
 
-    Change noLabelChange = insert("repo", ins3);
+    Change noLabelChange = insert(project, ins3);
 
-    Change reviewPlus1Change = insert("repo", ins4);
+    Change reviewPlus1Change = insert(project, ins4);
     getChangeApi(reviewPlus1Change).current().review(ReviewInput.recommend());
 
-    Change reviewTwoPlus1Change = insert("repo", ins5);
+    Change reviewTwoPlus1Change = insert(project, ins5);
     getChangeApi(reviewTwoPlus1Change).current().review(ReviewInput.recommend());
-    requestContext.setContext(newRequestContext(createAccount("user1")));
+    setRequestContextForUser(createAccount("user1"));
     getChangeApi(reviewTwoPlus1Change).current().review(ReviewInput.recommend());
-    requestContext.setContext(newRequestContext(userId));
+    setRequestContextForUser(userId);
 
-    Change reviewPlus2Change = insert("repo", ins6);
+    Change reviewPlus2Change = insert(project, ins6);
     getChangeApi(reviewPlus2Change).current().review(ReviewInput.approve());
 
     Map<String, Short> m =
@@ -1364,7 +1416,7 @@
     assertThat(m).hasSize(1);
     assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 1));
 
-    Multimap<Integer, Change> changes =
+    ListMultimap<Integer, Change> changes =
         Multimaps.newListMultimap(Maps.newLinkedHashMap(), () -> Lists.newArrayList());
     changes.put(2, reviewPlus2Change);
     changes.put(1, reviewTwoPlus1Change);
@@ -1502,7 +1554,7 @@
   @Test
   public void byLabelMulti() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    repo = createAndOpenProject(project.get());
+    repo = createAndOpenProject(project);
 
     LabelType verified =
         label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
@@ -1529,25 +1581,25 @@
     ChangeInserter ins5 = newChange(repo);
 
     // CR+1
-    Change reviewCRplus1 = insert(project.get(), ins);
+    Change reviewCRplus1 = insert(project, ins);
     getChangeApi(reviewCRplus1).current().review(ReviewInput.recommend());
 
     // CR+2
-    Change reviewCRplus2 = insert(project.get(), ins2);
+    Change reviewCRplus2 = insert(project, ins2);
     getChangeApi(reviewCRplus2).current().review(ReviewInput.approve());
 
     // CR+1 VR+1
-    Change reviewCRplus1VRplus1 = insert(project.get(), ins3);
+    Change reviewCRplus1VRplus1 = insert(project, ins3);
     getChangeApi(reviewCRplus1VRplus1).current().review(ReviewInput.recommend());
     getChangeApi(reviewCRplus1VRplus1).current().review(reviewVerified);
 
     // CR+2 VR+1
-    Change reviewCRplus2VRplus1 = insert(project.get(), ins4);
+    Change reviewCRplus2VRplus1 = insert(project, ins4);
     getChangeApi(reviewCRplus2VRplus1).current().review(ReviewInput.approve());
     getChangeApi(reviewCRplus2VRplus1).current().review(reviewVerified);
 
     // VR+1
-    Change reviewVRplus1 = insert(project.get(), ins5);
+    Change reviewVRplus1 = insert(project, ins5);
     getChangeApi(reviewVRplus1).current().review(reviewVerified);
 
     assertQuery("label:Code-Review=+1", reviewCRplus1VRplus1, reviewCRplus1);
@@ -1566,14 +1618,15 @@
 
   @Test
   public void byLabelNotOwner() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins = newChange(repo);
     Account.Id user1 = createAccount("user1");
 
-    Change reviewPlus1Change = insert("repo", ins);
+    Change reviewPlus1Change = insert(project, ins);
 
     // post a review with user1
-    requestContext.setContext(newRequestContext(user1));
+    setRequestContextForUser(user1);
     getChangeApi(reviewPlus1Change).current().review(ReviewInput.recommend());
 
     assertQuery("label:Code-Review=+1,user=" + user1, reviewPlus1Change);
@@ -1582,19 +1635,20 @@
 
   @Test
   public void byLabelNonUploader() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins = newChange(repo);
     Account.Id user1 = createAccount("user1");
 
     // create a change with "user"
-    Change reviewPlus1Change = insert("repo", ins);
+    Change reviewPlus1Change = insert(project, ins);
 
     // add a +1 vote with "user". Query doesn't match since voter is the uploader.
     getChangeApi(reviewPlus1Change).current().review(ReviewInput.recommend());
     assertQuery("label:Code-Review=+1,user=non_uploader");
 
     // add a +1 vote with "user1". Query will match since voter is a non-uploader.
-    requestContext.setContext(newRequestContext(user1));
+    setRequestContextForUser(user1);
     getChangeApi(reviewPlus1Change).current().review(ReviewInput.recommend());
     assertQuery("label:Code-Review=+1,user=non_uploader", reviewPlus1Change);
     assertQuery("label:Code-Review=+1,non_uploader", reviewPlus1Change);
@@ -1611,6 +1665,7 @@
     return range.toArray(new Change[0]);
   }
 
+  @CanIgnoreReturnValue
   private String createGroup(String name, String owner) throws Exception {
     GroupInput in = new GroupInput();
     in.name = name;
@@ -1627,7 +1682,8 @@
   public void byLabelGroup() throws Exception {
     Account.Id user1 = createAccount("user1");
     Account.Id user2 = createAccount("user2");
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
 
     // create group and add users
     String g1 = createGroup("group1", "Administrators");
@@ -1636,14 +1692,14 @@
     gApi.groups().id(g2).addMembers("user2");
 
     // create a change
-    Change change1 = insert("repo", newChange(repo), user1);
+    Change change1 = insert(project, newChange(repo), user1);
 
     // post a review with user1
-    requestContext.setContext(newRequestContext(user1));
+    setRequestContextForUser(user1);
     getChangeApi(change1).current().review(new ReviewInput().label("Code-Review", 1));
 
     // verify that query with user1 will return results.
-    requestContext.setContext(newRequestContext(userId));
+    setRequestContextForUser(userId);
     assertQuery("label:Code-Review=+1,group1", change1);
     assertQuery("label:Code-Review=+1,group=group1", change1);
     assertQuery("label:Code-Review=+1,user=" + user1, change1);
@@ -1655,7 +1711,8 @@
   public void byLabelExternalGroup() throws Exception {
     Account.Id user1 = createAccount("user1");
     Account.Id user2 = createAccount("user2");
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
 
     // create group and add users
     AccountGroup.UUID external_group1 = AccountGroup.uuid("testbackend:group1");
@@ -1680,13 +1737,13 @@
         .addSubgroup(uuidOfGroupThatContainsExternalGroupAsSubgroup)
         .create();
 
-    Change change1 = insert("repo", newChange(repo), user1);
-    Change change2 = insert("repo", newChange(repo), user1);
+    Change change1 = insert(project, newChange(repo), user1);
+    Change change2 = insert(project, newChange(repo), user1);
 
     // post a review with user1 and other_user
-    requestContext.setContext(newRequestContext(user1));
+    setRequestContextForUser(user1);
     getChangeApi(change1).current().review(new ReviewInput().label("Code-Review", 1));
-    requestContext.setContext(newRequestContext(userId));
+    setRequestContextForUser(userId);
     getChangeApi(change2).current().review(new ReviewInput().label("Code-Review", 1));
 
     assertQuery("label:Code-Review=+1," + external_group1.get(), change1);
@@ -1714,11 +1771,12 @@
 
   @Test
   public void limit() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     Change last = null;
     int n = 5;
     for (int i = 0; i < n; i++) {
-      last = insert("repo", newChange(repo));
+      last = insert(project, newChange(repo));
     }
 
     for (int i = 1; i <= n + 2; i++) {
@@ -1743,10 +1801,11 @@
 
   @Test
   public void start() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 2; i++) {
-      changes.add(insert("repo", newChange(repo)));
+      changes.add(insert(project, newChange(repo)));
     }
 
     assertQuery("status:new", changes.get(1), changes.get(0));
@@ -1763,10 +1822,11 @@
 
   @Test
   public void startWithLimit() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 3; i++) {
-      changes.add(insert("repo", newChange(repo)));
+      changes.add(insert(project, newChange(repo)));
     }
 
     assertQuery("status:new limit:2", changes.get(2), changes.get(1));
@@ -1777,8 +1837,9 @@
 
   @Test
   public void maxPages() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChange(repo));
 
     QueryRequest query = newQuery("status:new").withLimit(10);
     assertQuery(query, change);
@@ -1793,12 +1854,13 @@
   @Test
   public void updateOrder() throws Exception {
     resetTimeWithClockStep(2, MINUTES);
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     List<ChangeInserter> inserters = new ArrayList<>();
     List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 5; i++) {
       inserters.add(newChange(repo));
-      changes.add(insert("repo", inserters.get(i)));
+      changes.add(insert(project, inserters.get(i)));
     }
 
     for (int i : ImmutableList.of(2, 0, 1, 4, 3)) {
@@ -1817,10 +1879,11 @@
   @Test
   public void updatedOrder() throws Exception {
     resetTimeWithClockStep(1, SECONDS);
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins1 = newChange(repo);
-    Change change1 = insert("repo", ins1);
-    Change change2 = insert("repo", newChange(repo));
+    Change change1 = insert(project, ins1);
+    Change change2 = insert(project, newChange(repo));
 
     assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
     assertQuery("status:new", change2, change1);
@@ -1838,12 +1901,13 @@
 
   @Test
   public void filterOutMoreThanOnePageOfResults() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change = insert("repo", newChange(repo), userId);
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
-      insert("repo", newChange(repo), user2);
+      insert(project, newChange(repo), user2);
     }
 
     assertQuery("status:new ownerin:Administrators", change);
@@ -1852,11 +1916,12 @@
 
   @Test
   public void filterOutAllResults() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
-      insert("repo", newChange(repo), user2);
+      insert(project, newChange(repo), user2);
     }
 
     assertQuery("status:new ownerin:Administrators");
@@ -1865,8 +1930,9 @@
 
   @Test
   public void byFileExact() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("file:file");
     assertQuery("file:dir", change);
@@ -1878,8 +1944,9 @@
 
   @Test
   public void byFileRegex() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("file:.*file.*");
     assertQuery("file:^file.*"); // Whole path only.
@@ -1888,8 +1955,9 @@
 
   @Test
   public void byPathExact() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("path:file");
     assertQuery("path:dir");
@@ -1901,8 +1969,9 @@
 
   @Test
   public void byPathRegex() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("path:.*file.*");
     assertQuery("path:^dir.file.*", change);
@@ -1910,12 +1979,13 @@
 
   @Test
   public void byExtension() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChangeWithFiles(repo, "foo.h", "foo.cc"));
-    Change change2 = insert("repo", newChangeWithFiles(repo, "bar.H", "bar.CC"));
-    Change change3 = insert("repo", newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
-    Change change4 = insert("repo", newChangeWithFiles(repo, "Quux.java", "foo"));
-    Change change5 = insert("repo", newChangeWithFiles(repo, "foo"));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChangeWithFiles(repo, "foo.h", "foo.cc"));
+    Change change2 = insert(project, newChangeWithFiles(repo, "bar.H", "bar.CC"));
+    Change change3 = insert(project, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+    Change change4 = insert(project, newChangeWithFiles(repo, "Quux.java", "foo"));
+    Change change5 = insert(project, newChangeWithFiles(repo, "foo"));
 
     assertQuery("extension:java", change4);
     assertQuery("ext:java", change4);
@@ -1931,14 +2001,15 @@
 
   @Test
   public void byOnlyExtensions() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
-    Change change2 = insert("repo", newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
-    Change change3 = insert("repo", newChangeWithFiles(repo, "foo.CC", "bar.cc"));
-    Change change4 = insert("repo", newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
-    Change change5 = insert("repo", newChangeWithFiles(repo, "Quux.java"));
-    Change change6 = insert("repo", newChangeWithFiles(repo, "foo.txt", "foo"));
-    Change change7 = insert("repo", newChangeWithFiles(repo, "foo"));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
+    Change change2 = insert(project, newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
+    Change change3 = insert(project, newChangeWithFiles(repo, "foo.CC", "bar.cc"));
+    Change change4 = insert(project, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+    Change change5 = insert(project, newChangeWithFiles(repo, "Quux.java"));
+    Change change6 = insert(project, newChangeWithFiles(repo, "foo.txt", "foo"));
+    Change change7 = insert(project, newChangeWithFiles(repo, "foo"));
 
     // case doesn't matter
     assertQuery("onlyextensions:cc,h", change4, change2, change1);
@@ -1978,23 +2049,24 @@
 
   @Test
   public void byFooter() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nfoo: baz").create());
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar\nfoo:baz").create());
-    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+    Change change3 = insert(project, newChangeForCommit(repo, commit3));
     RevCommit commit4 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar=baz").create());
-    Change change4 = insert("repo", newChangeForCommit(repo, commit4));
+    Change change4 = insert(project, newChangeForCommit(repo, commit4));
 
     // create a changes with lines that look like footers, but which are not
     RevCommit commit5 =
         repo.parseBody(
             repo.commit().message("Test\n\nfoo: bar\n\nfoo=bar").insertChangeId().create());
-    Change change5 = insert("repo", newChangeForCommit(repo, commit5));
+    Change change5 = insert(project, newChangeForCommit(repo, commit5));
     RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
-    insert("repo", newChangeForCommit(repo, commit6));
+    insert(project, newChangeForCommit(repo, commit6));
 
     // matching by 'key=value' works
     assertQuery("footer:foo=bar", change3, change1);
@@ -2028,15 +2100,16 @@
   @Test
   public void byFooterName() throws Exception {
     assume().that(getSchema().hasField(ChangeField.FOOTER_NAME)).isTrue();
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nBaR: baz").create());
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
 
     // create a changes with lines that look like footers, but which are not
     RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
-    insert("repo", newChangeForCommit(repo, commit6));
+    insert(project, newChangeForCommit(repo, commit6));
 
     // matching by 'key=value' works
     assertQuery("hasfooter:foo", change1);
@@ -2048,14 +2121,16 @@
 
   @Test
   public void byDirectory() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
-    Change change2 = insert("repo", newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
+    Change change2 =
+        insert(project, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
     Change change3 =
-        insert("repo", newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
-    Change change4 = insert("repo", newChangeWithFiles(repo, "a.txt"));
-    Change change5 = insert("repo", newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
-    Change change6 = insert("repo", newChangeWithFiles(repo, "all/caps/DIRECTORY/file.txt"));
+        insert(project, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+    Change change4 = insert(project, newChangeWithFiles(repo, "a.txt"));
+    Change change5 = insert(project, newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
+    Change change6 = insert(project, newChangeWithFiles(repo, "all/caps/DIRECTORY/file.txt"));
 
     // matching by directory prefix works
     assertQuery("directory:src", change2, change1);
@@ -2116,10 +2191,12 @@
 
   @Test
   public void byDirectoryRegex() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 =
+        insert(project, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
     Change change2 =
-        insert("repo", newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+        insert(project, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
 
     // match by regexp
     assertQuery("directory:^.*va.*", change1);
@@ -2129,9 +2206,10 @@
 
   @Test
   public void byComment() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ChangeInserter ins = newChange(repo);
-    Change change = insert("repo", ins);
+    Change change = insert(project, ins);
 
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
@@ -2157,11 +2235,12 @@
   public void byAge() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change1 = insert(project, newChange(repo), null, Instant.ofEpochMilli(startMs));
     Change change2 =
-        insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
+        insert(project, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
 
     // Stop time so age queries use the same endpoint.
     TestTimeUtil.setClockStep(0, MILLISECONDS);
@@ -2198,11 +2277,12 @@
   public void byBeforeUntil() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change1 = insert(project, newChange(repo), null, Instant.ofEpochMilli(startMs));
     Change change2 =
-        insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
+        insert(project, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     // Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -2250,11 +2330,12 @@
   public void byAfterSince() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change1 = insert(project, newChange(repo), null, Instant.ofEpochMilli(startMs));
     Change change2 =
-        insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
+        insert(project, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     // Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -2294,12 +2375,13 @@
 
     // Stop the clock, will set time to specific test values.
     resetTimeWithClockStep(0, MILLISECONDS);
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     long startMs = TestTimeUtil.START.toEpochMilli();
     TestTimeUtil.setClock(new Timestamp(startMs));
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
-    Change change3 = insert("repo", newChange(repo));
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    Change change3 = insert(project, newChange(repo));
 
     TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
     submit(change3);
@@ -2354,12 +2436,13 @@
 
     // Stop the clock, will set time to specific test values.
     resetTimeWithClockStep(0, MILLISECONDS);
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     long startMs = TestTimeUtil.START.toEpochMilli();
     TestTimeUtil.setClock(new Timestamp(startMs));
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
-    Change change3 = insert("repo", newChange(repo));
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    Change change3 = insert(project, newChange(repo));
     assertThat(TimeUtil.nowMs()).isEqualTo(startMs);
 
     TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
@@ -2424,13 +2507,14 @@
 
     // Stop the clock, will set time to specific test values.
     resetTimeWithClockStep(0, MILLISECONDS);
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     long startMs = TestTimeUtil.START.toEpochMilli();
     TestTimeUtil.setClock(new Timestamp(startMs));
 
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
-    Change change3 = insert("repo", newChange(repo));
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    Change change3 = insert(project, newChange(repo));
 
     TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
     submit(change2);
@@ -2457,15 +2541,16 @@
 
   @Test
   public void bySize() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
 
     // added = 3, deleted = 0, delta = 3
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "foo\n\foo\nfoo").create());
     // added = 0, deleted = 2, delta = 2
     RevCommit commit2 = repo.parseBody(repo.commit().parent(commit1).add("file1", "foo").create());
 
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
 
     assertQuery("added:>4");
     assertQuery("-added:<=4");
@@ -2512,10 +2597,11 @@
     }
   }
 
-  private List<Change> setUpHashtagChanges() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
+  private ImmutableList<Change> setUpHashtagChanges() throws Exception {
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
 
     addHashtags(change1, "foo", "aaa-bbb-ccc");
     addHashtags(change2, "foo", "bar", "a tag", "ACamelCaseTag");
@@ -2530,7 +2616,7 @@
 
   @Test
   public void byHashtag() throws Exception {
-    List<Change> changes = setUpHashtagChanges();
+    ImmutableList<Change> changes = setUpHashtagChanges();
     assertQuery("hashtag:foo", changes.get(1), changes.get(0));
     assertQuery("hashtag:bar", changes.get(1));
     assertQuery("hashtag:\"a tag\"", changes.get(1));
@@ -2545,7 +2631,7 @@
   @Test
   public void byHashtagFullText() throws Exception {
     assume().that(getSchema().hasField(ChangeField.FUZZY_HASHTAG)).isTrue();
-    List<Change> changes = setUpHashtagChanges();
+    ImmutableList<Change> changes = setUpHashtagChanges();
     assertQuery("inhashtag:foo", changes.get(1), changes.get(0));
     assertQuery("inhashtag:bbb", changes.get(0));
     assertQuery("inhashtag:tag", changes.get(1));
@@ -2554,7 +2640,7 @@
   @Test
   public void byHashtagPrefix() throws Exception {
     assume().that(getSchema().hasField(ChangeField.PREFIX_HASHTAG)).isTrue();
-    List<Change> changes = setUpHashtagChanges();
+    ImmutableList<Change> changes = setUpHashtagChanges();
     assertQuery("prefixhashtag:a", changes.get(1), changes.get(0));
     assertQuery("prefixhashtag:aa", changes.get(0));
     assertQuery("prefixhashtag:bar", changes.get(1));
@@ -2562,10 +2648,11 @@
 
   @Test
   public void byHashtagRegex() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
-    Change change3 = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    Change change3 = insert(project, newChange(repo));
     addHashtags(change1, "feature1");
     addHashtags(change1, "trending");
     addHashtags(change2, "Cherrypick-feature1");
@@ -2578,27 +2665,28 @@
 
   @Test
   public void byDefault() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
 
-    Change change1 = insert("repo", newChange(repo));
+    Change change1 = insert(project, newChange(repo));
 
     RevCommit commit2 = repo.parseBody(repo.commit().message("foosubject").create());
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
 
     RevCommit commit3 = repo.parseBody(repo.commit().add("Foo.java", "foo contents").create());
-    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+    Change change3 = insert(project, newChangeForCommit(repo, commit3));
 
     ChangeInserter ins4 = newChange(repo);
-    Change change4 = insert("repo", ins4);
+    Change change4 = insert(project, ins4);
     ReviewInput ri4 = new ReviewInput();
     ri4.message = "toplevel";
     ri4.labels = ImmutableMap.of("Code-Review", (short) 1);
     getChangeApi(change4).current().review(ri4);
 
     ChangeInserter ins5 = newChangeWithTopic(repo, "feature5");
-    Change change5 = insert("repo", ins5);
+    Change change5 = insert(project, ins5);
 
-    Change change6 = insert("repo", newChangeForBranch(repo, "branch6"));
+    Change change6 = insert(project, newChangeForBranch(repo, "branch6"));
 
     assertQuery(change1.getId().get(), change1);
     assertQuery(ChangeTriplet.format(change1), change1);
@@ -2619,23 +2707,27 @@
 
   @Test
   public void byDefaultWithCommitPrefix() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit = repo.parseBody(repo.commit().message("message").create());
-    Change change = insert("repo", newChangeForCommit(repo, commit));
+    Change change = insert(project, newChangeForCommit(repo, commit));
 
     assertQuery(commit.getId().getName().substring(0, 6), change);
   }
 
   @Test
   public void visible() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChangePrivate(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChangePrivate(repo));
 
     String q = "project:repo";
 
     // Bad request for query with non-existent user
-    assertThatQueryException(q + " visibleto:notexisting");
+    assertThatQueryException(q + " visibleto:notexisting")
+        .hasMessageThat()
+        .isEqualTo("No user or group matches \"notexisting\".");
 
     // Current user can see all changes
     assertQuery(q, change2, change1);
@@ -2667,7 +2759,7 @@
       assertQuery(q, change2, change1);
     }
 
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
     assertQuery("is:visible", change1);
 
     Account.Id user3 = createAccount("user3");
@@ -2683,9 +2775,9 @@
     accountManager.authenticate(authRequest);
 
     // Switch to user3
-    requestContext.setContext(newRequestContext(user3));
-    Change change3 = insert("repo", newChange(repo), user3);
-    Change change4 = insert("repo", newChangePrivate(repo), user3);
+    setRequestContextForUser(user3);
+    Change change3 = insert(project, newChange(repo), user3);
+    Change change4 = insert(project, newChangePrivate(repo), user3);
 
     // User3 can see both their changes and the first user's change
     assertQuery(q + " visibleto:" + user3.get(), change4, change3, change1);
@@ -2725,9 +2817,10 @@
 
   @Test
   public void visibleToSelf() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
 
     getChangeApi(change2).setPrivate(true, "private");
 
@@ -2736,17 +2829,19 @@
     assertQuery(q + " visibleto:me", change2, change1);
 
     // Anonymous user cannot see first user's private change.
-    requestContext.setContext(anonymousUserProvider::get);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(anonymousUserProvider::get);
+
     assertQuery(q + " visibleto:self", change1);
     assertQuery(q + " visibleto:me", change1);
   }
 
   @Test
   public void byCommentBy() throws Exception {
-
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
 
     Account.Id user2 = createAccount("anotheruser");
     ReviewInput input = new ReviewInput();
@@ -2769,8 +2864,9 @@
   public void bySubmitRuleResult() throws Exception {
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
-      repo = createAndOpenProject("repo");
-      Change change = insert("repo", newChange(repo));
+      Project.NameKey project = Project.nameKey("repo");
+      repo = createAndOpenProject(project);
+      Change change = insert(project, newChange(repo));
       // The fake submit rule exports its ruleName as "FakeSubmitRule"
       assertQuery("rule:FakeSubmitRule");
 
@@ -2789,17 +2885,19 @@
   public void byNonExistingSubmitRule_returnsEmpty() throws Exception {
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
-      repo = createAndOpenProject("repo");
-      insert("repo", newChange(repo));
+      Project.NameKey project = Project.nameKey("repo");
+      repo = createAndOpenProject(project);
+      insert(project, newChange(repo));
       assertQuery("rule:non-existent-rule");
     }
   }
 
   @Test
   public void byHasDraft() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
 
     assertQuery("has:draft");
 
@@ -2820,7 +2918,7 @@
 
     assertQuery("has:draft", change2, change1);
 
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
     assertQuery("has:draft");
   }
 
@@ -2831,8 +2929,8 @@
    */
   public void byHasDraftExcludesZombieDrafts() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    repo = createAndOpenProject(project.get());
-    Change change = insert("repo", newChange(repo));
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChange(repo));
     Change.Id id = change.getId();
 
     DraftInput in = new DraftInput();
@@ -2868,15 +2966,16 @@
 
   @Test
   public void byHasDraftWithManyDrafts() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     Change[] changesWithDrafts = new Change[30];
 
     // unrelated change not shown in the result.
-    insert("repo", newChange(repo));
+    insert(project, newChange(repo));
 
     for (int i = 0; i < changesWithDrafts.length; i++) {
       // put the changes in reverse order since this is the order we receive them from the index.
-      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert("repo", newChange(repo));
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(project, newChange(repo));
       DraftInput in = new DraftInput();
       in.line = 1;
       in.message = "nit: trailing whitespace";
@@ -2887,16 +2986,17 @@
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
     assertQuery("has:draft");
   }
 
   @Test
   public void byStarredBy() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
-    insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    insert(project, newChange(repo));
 
     gApi.accounts().self().starChange(change1.getId().toString());
     gApi.accounts().self().starChange(change2.getId().toString());
@@ -2906,19 +3006,20 @@
 
     assertQuery("has:star", change2, change1);
 
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
     assertQuery("has:star");
   }
 
   @Test
   public void byStar_withStarOptionSet() throws Exception {
     // When star option is set, the 'starred' field is set in the change infos in response.
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChangeWithStatus(repo, Change.Status.MERGED));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
 
     gApi.accounts().self().starChange(change1.getId().toString());
 
@@ -2935,12 +3036,13 @@
   @Test
   public void byStar_withStarOptionNotSet() throws Exception {
     // When star option is not set, the 'starred' field is not set in the change infos in response.
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChangeWithStatus(repo, Change.Status.MERGED));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
 
     gApi.accounts().self().starChange(change1.getId().toString());
 
@@ -2956,15 +3058,19 @@
   @Test
   public void byStar_withStarOptionSet_notPopulatedForAnonymousUsers() throws Exception {
     // Create a random change and star it as some user
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChangeWithStatus(repo, Change.Status.NEW));
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
+
     gApi.accounts().self().starChange(change1.getId().toString());
 
     // Request a change query for all open changes. The star field is not set on the single change.
-    requestContext.setContext(anonymousUserProvider::get);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(anonymousUserProvider::get);
+
     List<ChangeInfo> changeInfos =
         gApi.changes().query("is:open").withOptions(ListChangesOption.STAR).get();
     assertThat(changeInfos.get(0)._number).isEqualTo(change1.getId().get());
@@ -2973,11 +3079,12 @@
 
   @Test
   public void byStarWithManyStars() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     Change[] changesWithDrafts = new Change[30];
     for (int i = 0; i < changesWithDrafts.length; i++) {
       // put the changes in reverse order since this is the order we receive them from the index.
-      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert("repo", newChange(repo));
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(project, newChange(repo));
 
       // star the change
       gApi.accounts()
@@ -2991,12 +3098,13 @@
 
   @Test
   public void byFrom() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert("repo", newChange(repo), user2);
+    Change change2 = insert(project, newChange(repo), user2);
 
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
@@ -3012,7 +3120,8 @@
 
   @Test
   public void conflicts() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 =
         repo.parseBody(
             repo.commit()
@@ -3024,10 +3133,10 @@
     RevCommit commit3 =
         repo.parseBody(repo.commit().add("dir/file2", "contents2 different").create());
     RevCommit commit4 = repo.parseBody(repo.commit().add("file4", "contents4").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
-    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
-    Change change4 = insert("repo", newChangeForCommit(repo, commit4));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
+    Change change3 = insert(project, newChangeForCommit(repo, commit3));
+    Change change4 = insert(project, newChangeForCommit(repo, commit4));
 
     assertQuery("conflicts:" + change1.getId().get(), change3);
     assertQuery("conflicts:" + change2.getId().get());
@@ -3041,11 +3150,12 @@
       value = "API_REF_UPDATED_AND_CHANGE_REINDEX")
   public void mergeable() throws Exception {
     assume().that(getSchema().hasField(ChangeField.MERGEABLE_SPEC)).isTrue();
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
     RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents2").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
 
     assertQuery("conflicts:" + change1.getId().get(), change2);
     assertQuery("conflicts:" + change2.getId().get(), change1);
@@ -3069,9 +3179,10 @@
   @Test
   public void cherrypick() throws Exception {
     assume().that(getSchema().hasField(ChangeField.CHERRY_PICK_SPEC)).isTrue();
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newCherryPickChange(repo, "foo", change1.currentPatchSetId()));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newCherryPickChange(repo, "foo", change1.currentPatchSetId()));
 
     assertQuery("is:cherrypick", change2);
     assertQuery("-is:cherrypick", change1);
@@ -3080,14 +3191,15 @@
   @Test
   public void merge() throws Exception {
     assume().that(getSchema().hasField(ChangeField.MERGE_SPEC)).isTrue();
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
     RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents2").create());
     RevCommit commit3 =
         repo.parseBody(repo.commit().parent(commit2).add("file1", "contents3").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
-    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
+    Change change3 = insert(project, newChangeForCommit(repo, commit3));
     RevCommit mergeCommit =
         repo.branch("master")
             .commit()
@@ -3096,7 +3208,7 @@
             .parent(commit3)
             .insertChangeId()
             .create();
-    Change mergeChange = insert("repo", newChangeForCommit(repo, mergeCommit));
+    Change mergeChange = insert(project, newChangeForCommit(repo, mergeCommit));
 
     assertQuery("status:open is:merge", mergeChange);
     assertQuery("status:open -is:merge", change3, change2, change1);
@@ -3106,21 +3218,22 @@
   @Test
   public void reviewedBy() throws Exception {
     resetTimeWithClockStep(2, MINUTES);
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
-    Change change3 = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    Change change3 = insert(project, newChange(repo));
 
     getChangeApi(change1).current().review(new ReviewInput().message("comment"));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
 
     getChangeApi(change2).current().review(new ReviewInput().message("comment"));
 
     PatchSet.Id ps3_1 = change3.currentPatchSetId();
-    change3 = newPatchSet("repo", change3, user, /* message= */ Optional.empty());
+    change3 = newPatchSet(project, change3, user, /* message= */ Optional.empty());
     assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
     // Response to previous patch set still counts as reviewing.
     getChangeApi(change3).revision(ps3_1.get()).review(new ReviewInput().message("comment"));
@@ -3144,11 +3257,12 @@
   @Test
   public void reviewerAndCc() throws Exception {
     Account.Id user1 = createAccount("user1");
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
-    Change change3 = insert("repo", newChange(repo));
-    insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    Change change3 = insert(project, newChange(repo));
+    insert(project, newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
@@ -3166,7 +3280,7 @@
     assertQuery("is:reviewer", change3);
     assertQuery("reviewer:self", change3);
 
-    requestContext.setContext(newRequestContext(user1));
+    setRequestContextForUser(user1);
     assertQuery("reviewer:" + user1, change1);
     assertQuery("cc:" + user1, change2);
     assertQuery("is:cc", change2);
@@ -3175,18 +3289,19 @@
 
   @Test
   public void byReviewed() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     Account.Id otherUser =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
 
     assertQuery("is:reviewed");
     assertQuery("status:reviewed");
     assertQuery("-is:reviewed", change2, change1);
     assertQuery("-status:reviewed", change2, change1);
 
-    requestContext.setContext(newRequestContext(otherUser));
+    setRequestContextForUser(otherUser);
     getChangeApi(change1).current().review(ReviewInput.recommend());
 
     assertQuery("is:reviewed", change1);
@@ -3203,11 +3318,12 @@
         accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId();
     Account.Id user3 =
         accountManager.authenticate(authRequestFactory.createForUser("user3")).getAccountId();
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
 
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
-    Change change3 = insert("repo", newChange(repo));
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    Change change3 = insert(project, newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
@@ -3247,7 +3363,7 @@
   @Test
   public void reviewerAndCcByEmail() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    repo = createAndOpenProject(project.get());
+    repo = createAndOpenProject(project);
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
@@ -3255,9 +3371,9 @@
     String userByEmail = "un.registered@reviewer.com";
     String userByEmailWithName = "John Doe <" + userByEmail + ">";
 
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
-    insert("repo", newChange(repo));
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    insert(project, newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmailWithName;
@@ -3280,16 +3396,16 @@
   @Test
   public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    repo = createAndOpenProject(project.get());
+    repo = createAndOpenProject(project);
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
 
     String userByEmail = "John Doe <un.registered@reviewer.com>";
 
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
-    insert("repo", newChange(repo));
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    insert(project, newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmail;
@@ -3308,14 +3424,16 @@
   @Test
   public void submitRecords() throws Exception {
     Account.Id user1 = createAccount("user1");
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
 
     getChangeApi(change1).current().review(ReviewInput.approve());
-    requestContext.setContext(newRequestContext(user1));
+    setRequestContextForUser(user1);
+
     getChangeApi(change2).current().review(ReviewInput.recommend());
-    requestContext.setContext(newRequestContext(user.getAccountId()));
+    setRequestContextForUser(user.getAccountId());
 
     assertQuery("is:submittable", change1);
     assertQuery("-is:submittable", change2);
@@ -3340,34 +3458,36 @@
   public void hasEdit() throws Exception {
     Account.Id user1 = createAccount("user1");
     Account.Id user2 = createAccount("user2");
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
     String changeId1 = change1.getKey().get();
-    Change change2 = insert("repo", newChange(repo));
+    Change change2 = insert(project, newChange(repo));
     String changeId2 = change2.getKey().get();
 
-    requestContext.setContext(newRequestContext(user1));
+    setRequestContextForUser(user1);
     assertQuery("has:edit");
     gApi.changes().id(changeId1).edit().create();
     gApi.changes().id(changeId2).edit().create();
 
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
     assertQuery("has:edit");
     gApi.changes().id(changeId2).edit().create();
 
-    requestContext.setContext(newRequestContext(user1));
+    setRequestContextForUser(user1);
     assertQuery("has:edit", change2, change1);
 
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
     assertQuery("has:edit", change2);
   }
 
   @Test
   public void byUnresolved() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
-    Change change3 = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
+    Change change3 = insert(project, newChange(repo));
 
     // Change1 has one resolved comment (unresolvedcount = 0)
     // Change2 has one unresolved comment (unresolvedcount = 1)
@@ -3395,13 +3515,15 @@
 
   @Test
   public void byCommitsOnBranchNotMerged() throws Exception {
-    createProject("repo");
-    testByCommitsOnBranchNotMerged("repo", ImmutableSet.of());
+    Project.NameKey project = Project.nameKey("repo");
+    createProject(project);
+    testByCommitsOnBranchNotMerged(project, ImmutableSet.of());
   }
 
   @Test
   public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     ObjectId missing =
         repo.branch(PatchSet.id(Change.id(987654), 1).toRefName())
             .commit()
@@ -3409,10 +3531,10 @@
             .insertChangeId()
             .create()
             .copy();
-    testByCommitsOnBranchNotMerged("repo", ImmutableSet.of(missing));
+    testByCommitsOnBranchNotMerged(project, ImmutableSet.of(missing));
   }
 
-  private void testByCommitsOnBranchNotMerged(String repo, Collection<ObjectId> extra)
+  private void testByCommitsOnBranchNotMerged(Project.NameKey project, Collection<ObjectId> extra)
       throws Exception {
     int n = 10;
     List<String> shas = new ArrayList<>(n + extra.size());
@@ -3420,10 +3542,10 @@
     List<Integer> expectedIds = new ArrayList<>(n);
     BranchNameKey dest = null;
     try (TestRepository<Repository> repository =
-        new TestRepository<>(repoManager.openRepository(Project.nameKey(repo)))) {
+        new TestRepository<>(repoManager.openRepository(project))) {
       for (int i = 0; i < n; i++) {
         ChangeInserter ins = newChange(repository);
-        insert("repo", ins);
+        insert(project, ins);
         if (dest == null) {
           dest = ins.getChange().getDest();
         }
@@ -3431,7 +3553,7 @@
         expectedIds.add(ins.getChange().getId().get());
       }
     }
-    try (Repository repository = repoManager.openRepository(Project.nameKey(repo))) {
+    try (Repository repository = repoManager.openRepository(project)) {
       for (int i = 1; i <= 11; i++) {
         Iterable<ChangeData> cds =
             queryProvider.get().byCommitsOnBranchNotMerged(repository, dest, shas, i);
@@ -3446,16 +3568,15 @@
   @Test
   public void reindexIfStale() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    repo = createAndOpenProject(project.get());
-    Change change = insert("repo", newChange(repo));
-    String changeId = change.getKey().get();
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChange(repo));
 
     Account.Id anotherUser = createAccount("another-user");
-    requestContext.setContext(newRequestContext(anotherUser));
-    gApi.changes().id(changeId).addReviewer(anotherUser.toString());
+    setRequestContextForUser(anotherUser);
+    getChangeApi(change).addReviewer(anotherUser.toString());
 
     assertQuery("reviewer:self", change);
-    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
+    assertThat(indexer.reindexIfStale(project, change.getId())).isFalse();
 
     // Remove reviewer behind index's back.
     ChangeUpdate update = newUpdate(change);
@@ -3464,7 +3585,7 @@
 
     // Index is stale.
     assertQuery("reviewer:self", change);
-    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isTrue();
+    assertThat(indexer.reindexIfStale(project, change.getId())).isTrue();
     assertQuery("reviewer:self");
 
     // Index is not stale when a draft comment exists
@@ -3472,20 +3593,22 @@
     in.line = 1;
     in.message = "nit: trailing whitespace";
     in.path = Patch.COMMIT_MSG;
-    gApi.changes().id(project.get(), change.getId().get()).current().createDraft(in);
-    assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
+    getChangeApi(change).current().createDraft(in);
+    assertThat(indexer.reindexIfStale(project, change.getId())).isFalse();
   }
 
   @Test
   public void watched() throws Exception {
-    createProject("repo");
-    ChangeInserter ins1 = newChangeWithStatus("repo", Change.Status.NEW);
-    Change change1 = insert("repo", ins1);
+    Project.NameKey project = Project.nameKey("repo");
+    createProject(project);
+    ChangeInserter ins1 = newChangeWithStatus(project, Change.Status.NEW);
+    Change change1 = insert(project, ins1);
 
-    createProject("repo2");
+    Project.NameKey project2 = Project.nameKey("repo2");
+    createProject(project2);
 
-    ChangeInserter ins2 = newChangeWithStatus("repo2", Change.Status.NEW);
-    insert("repo2", ins2);
+    ChangeInserter ins2 = newChangeWithStatus(project2, Change.Status.NEW);
+    insert(project2, ins2);
 
     assertQuery("is:watched");
 
@@ -3505,8 +3628,9 @@
 
   @Test
   public void watched_projectWatchThatUsesIsWatchedIsIgnored() throws Exception {
-    createProject("repo");
-    insert("repo", newChangeWithStatus("repo", Change.Status.NEW));
+    Project.NameKey project = Project.nameKey("repo");
+    createProject(project);
+    insert(project, newChangeWithStatus(project, Change.Status.NEW));
 
     List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
     ProjectWatchInfo pwi = new ProjectWatchInfo();
@@ -3522,17 +3646,18 @@
 
   @Test
   public void trackingid() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 =
         repo.parseBody(repo.commit().message("Change one\n\nBug:QUERY123").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
     RevCommit commit2 =
         repo.parseBody(repo.commit().message("Change two\n\nIssue: Issue 16038\n").create());
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
 
     RevCommit commit3 =
         repo.parseBody(repo.commit().message("Change two\n\nGoogle-Bug-Id: b/16039\n").create());
-    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+    Change change3 = insert(project, newChangeForCommit(repo, commit3));
 
     assertQuery("tr:QUERY123", change1);
     assertQuery("bug:QUERY123", change1);
@@ -3558,9 +3683,10 @@
 
   @Test
   public void revertOf() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     // Create two commits and revert second commit (initial commit can't be reverted)
-    Change initial = insert("repo", newChange(repo));
+    Change initial = insert(project, newChange(repo));
     getChangeApi(initial).current().review(ReviewInput.approve());
     getChangeApi(initial).current().submit();
 
@@ -3575,10 +3701,11 @@
 
   @Test
   public void submissionId() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChange(repo));
     // create irrelevant change
-    insert("repo", newChange(repo));
+    insert(project, newChange(repo));
     getChangeApi(change).current().review(ReviewInput.approve());
     getChangeApi(change).current().submit();
     String submissionId = getChangeApi(change).get().submissionId;
@@ -3642,9 +3769,11 @@
       return this;
     }
 
-    DashboardChangeState create(TestRepository<Repository> repo) throws Exception {
-      requestContext.setContext(newRequestContext(ownerId));
-      Change change = insert("repo", newChange(repo), ownerId);
+    @CanIgnoreReturnValue
+    DashboardChangeState create(Project.NameKey project, TestRepository<Repository> repo)
+        throws Exception {
+      setRequestContextForUser(ownerId);
+      Change change = insert(project, newChange(repo), ownerId);
       id = change.getId();
       ChangeApi cApi = getChangeApi(change);
       if (wip) {
@@ -3666,24 +3795,25 @@
       in.path = Patch.COMMIT_MSG;
       in.message = "message";
       for (Account.Id commenterId : draftCommentBy) {
-        requestContext.setContext(newRequestContext(commenterId));
+        setRequestContextForUser(commenterId);
         getChangeApi(change).current().createDraft(in);
       }
       for (Account.Id commenterId : deleteDraftCommentBy) {
-        requestContext.setContext(newRequestContext(commenterId));
+        setRequestContextForUser(commenterId);
         getChangeApi(change).current().createDraft(in).delete();
       }
       if (mergedBy != null) {
-        requestContext.setContext(newRequestContext(mergedBy));
+        setRequestContextForUser(mergedBy);
         cApi = getChangeApi(change);
         cApi.current().review(ReviewInput.approve());
         cApi.current().submit();
       }
-      requestContext.setContext(newRequestContext(user.getAccountId()));
+      setRequestContextForUser(user.getAccountId());
       return this;
     }
   }
 
+  @CanIgnoreReturnValue
   protected List<ChangeInfo> assertDashboardQuery(
       String viewedUser, String query, DashboardChangeState... expected) throws Exception {
     Change.Id[] ids = new Change.Id[expected.length];
@@ -3693,6 +3823,7 @@
     return assertQueryByIds(query.replaceAll("\\$\\{user}", viewedUser), ids);
   }
 
+  @CanIgnoreReturnValue
   protected List<ChangeInfo> assertDashboardQueryWithStart(
       String viewedUser, String query, int start, DashboardChangeState... expected)
       throws Exception {
@@ -3700,24 +3831,29 @@
     for (int i = 0; i < expected.length; i++) {
       ids[i] = expected[i].id;
     }
-    QueryRequest queryRequest = newQuery(query.replaceAll("\\$\\{user}", viewedUser));
-    queryRequest.withStart(start);
+    QueryRequest queryRequest =
+        newQuery(query.replaceAll("\\$\\{user}", viewedUser)).withStart(start);
     return assertQueryByIds(queryRequest, ids);
   }
 
   @Test
   public void dashboardHasUnpublishedDrafts() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState hasUnpublishedDraft =
-        new DashboardChangeState(otherAccountId).draftCommentBy(user.getAccountId()).create(repo);
+        new DashboardChangeState(otherAccountId)
+            .draftCommentBy(user.getAccountId())
+            .create(project, repo);
 
     // Create changes that should not be returned by query.
-    new DashboardChangeState(user.getAccountId()).create(repo);
-    new DashboardChangeState(user.getAccountId()).draftCommentBy(otherAccountId).create(repo);
+    new DashboardChangeState(user.getAccountId()).create(project, repo);
+    new DashboardChangeState(user.getAccountId())
+        .draftCommentBy(otherAccountId)
+        .create(project, repo);
     new DashboardChangeState(user.getAccountId())
         .draftAndDeleteCommentBy(user.getAccountId())
-        .create(repo);
+        .create(project, repo);
 
     assertDashboardQuery(
         "self", IndexPreloadingUtil.DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY, hasUnpublishedDraft);
@@ -3725,14 +3861,17 @@
 
   @Test
   public void dashboardWorkInProgressReviews() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     DashboardChangeState ownedOpenWip =
-        new DashboardChangeState(user.getAccountId()).wip().create(repo);
+        new DashboardChangeState(user.getAccountId()).wip().create(project, repo);
 
     // Create changes that should not be returned by query.
-    new DashboardChangeState(user.getAccountId()).wip().abandon().create(repo);
-    new DashboardChangeState(user.getAccountId()).mergeBy(user.getAccountId()).create(repo);
-    new DashboardChangeState(createAccount("other")).wip().create(repo);
+    new DashboardChangeState(user.getAccountId()).wip().abandon().create(project, repo);
+    new DashboardChangeState(user.getAccountId())
+        .mergeBy(user.getAccountId())
+        .create(project, repo);
+    new DashboardChangeState(createAccount("other")).wip().create(project, repo);
 
     assertDashboardQuery(
         "self", IndexPreloadingUtil.DASHBOARD_WORK_IN_PROGRESS_QUERY, ownedOpenWip);
@@ -3740,80 +3879,90 @@
 
   @Test
   public void dashboardOutgoingReviews() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState ownedOpenReviewable =
-        new DashboardChangeState(user.getAccountId()).create(repo);
+        new DashboardChangeState(user.getAccountId()).create(project, repo);
 
     // Create changes that should not be returned by any queries in this test.
-    new DashboardChangeState(user.getAccountId()).wip().create(repo);
-    new DashboardChangeState(otherAccountId).create(repo);
+    new DashboardChangeState(user.getAccountId()).wip().create(project, repo);
+    new DashboardChangeState(otherAccountId).create(project, repo);
 
     // Viewing one's own dashboard.
     assertDashboardQuery("self", IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY, ownedOpenReviewable);
 
     // Viewing another user's dashboard.
-    requestContext.setContext(newRequestContext(otherAccountId));
+    setRequestContextForUser(otherAccountId);
     assertDashboardQuery(
         userId.toString(), IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY, ownedOpenReviewable);
   }
 
   @Test
   public void dashboardIncomingReviews() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState reviewingReviewable =
-        new DashboardChangeState(otherAccountId).addReviewer(user.getAccountId()).create(repo);
+        new DashboardChangeState(otherAccountId)
+            .addReviewer(user.getAccountId())
+            .create(project, repo);
 
     // Create changes that should not be returned by any queries in this test.
-    new DashboardChangeState(otherAccountId).wip().addReviewer(user.getAccountId()).create(repo);
-    new DashboardChangeState(otherAccountId).addReviewer(otherAccountId).create(repo);
+    new DashboardChangeState(otherAccountId)
+        .wip()
+        .addReviewer(user.getAccountId())
+        .create(project, repo);
+    new DashboardChangeState(otherAccountId).addReviewer(otherAccountId).create(project, repo);
     new DashboardChangeState(otherAccountId)
         .addReviewer(user.getAccountId())
         .mergeBy(user.getAccountId())
-        .create(repo);
+        .create(project, repo);
 
     // Viewing one's own dashboard.
     assertDashboardQuery("self", IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY, reviewingReviewable);
 
     // Viewing another user's dashboard.
-    requestContext.setContext(newRequestContext(otherAccountId));
+    setRequestContextForUser(otherAccountId);
     assertDashboardQuery(
         userId.toString(), IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY, reviewingReviewable);
   }
 
   @Test
   public void dashboardRecentlyClosedReviews() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState mergedOwned =
-        new DashboardChangeState(user.getAccountId()).mergeBy(user.getAccountId()).create(repo);
+        new DashboardChangeState(user.getAccountId())
+            .mergeBy(user.getAccountId())
+            .create(project, repo);
     DashboardChangeState mergedReviewing =
         new DashboardChangeState(otherAccountId)
             .addReviewer(user.getAccountId())
             .mergeBy(user.getAccountId())
-            .create(repo);
+            .create(project, repo);
     DashboardChangeState mergedCced =
         new DashboardChangeState(otherAccountId)
             .addCc(user.getAccountId())
             .mergeBy(user.getAccountId())
-            .create(repo);
+            .create(project, repo);
     DashboardChangeState abandonedOwned =
-        new DashboardChangeState(user.getAccountId()).abandon().create(repo);
+        new DashboardChangeState(user.getAccountId()).abandon().create(project, repo);
     DashboardChangeState abandonedOwnedWip =
-        new DashboardChangeState(user.getAccountId()).wip().abandon().create(repo);
+        new DashboardChangeState(user.getAccountId()).wip().abandon().create(project, repo);
     DashboardChangeState abandonedReviewing =
         new DashboardChangeState(otherAccountId)
             .addReviewer(user.getAccountId())
             .abandon()
-            .create(repo);
+            .create(project, repo);
 
     // Create changes that should not be returned by any queries in this test.
     new DashboardChangeState(otherAccountId)
         .addReviewer(user.getAccountId())
         .wip()
         .abandon()
-        .create(repo);
+        .create(project, repo);
 
     // Viewing one's own dashboard.
     assertDashboardQuery(
@@ -3827,7 +3976,7 @@
         mergedOwned);
 
     // Viewing another user's dashboard.
-    requestContext.setContext(newRequestContext(otherAccountId));
+    setRequestContextForUser(otherAccountId);
     assertDashboardQuery(
         userId.toString(),
         IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
@@ -3842,9 +3991,10 @@
   public void attentionSetIndexed() throws Exception {
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS_COUNT)).isTrue();
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChange(repo));
 
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "some reason");
     getChangeApi(change1).addToAttentionSet(input);
@@ -3865,8 +4015,9 @@
   @Test
   public void attentionSetStored() throws Exception {
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
-    repo = createAndOpenProject("repo");
-    Change change = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChange(repo));
 
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "reason 1");
     getChangeApi(change).addToAttentionSet(input);
@@ -3895,10 +4046,12 @@
   @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
   public void namedDestination() throws Exception {
-    createProject("repo1");
-    Change change1 = insert("repo1", newChange("repo1"));
-    createProject("repo2");
-    Change change2 = insert("repo2", newChange("repo2"));
+    Project.NameKey project1 = Project.nameKey("repo1");
+    createProject(project1);
+    Change change1 = insert(project1, newChange(project1));
+    Project.NameKey project2 = Project.nameKey("repo2");
+    createProject(project2);
+    Change change2 = insert(project2, newChange(project2));
 
     assertThatQueryException("destination:foo")
         .hasMessageThat()
@@ -3976,14 +4129,14 @@
         .hasMessageThat()
         .isEqualTo("Account 'non-existent' not found");
 
-    requestContext.setContext(newRequestContext(anotherUserId));
+    setRequestContextForUser(anotherUserId);
     // account userId is not visible to 'anotheruser' as they are not an admin
     assertThatQueryException("destination:destination3,user=" + userId)
         .hasMessageThat()
         .isEqualTo(String.format("Account '%s' not found", userId));
 
     // Group destinations
-    requestContext.setContext(newRequestContext(userId));
+    setRequestContextForUser(userId);
     assertThatQueryException("destination:non-existent-dest,group=" + group)
         .hasMessageThat()
         .isEqualTo("Unknown named destination: non-existent-dest");
@@ -4004,9 +4157,10 @@
   @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
   public void namedQuery() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
-    Change change2 = insert("repo", newChangeForBranch(repo, "stable"));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
+    Change change2 = insert(project, newChangeForBranch(repo, "stable"));
 
     String group = "test-group";
     AccountGroup.UUID groupId = groupOperations.newGroup().name(group).create();
@@ -4051,12 +4205,12 @@
         .hasMessageThat()
         .isEqualTo("Account 'non-existent' not found");
 
-    requestContext.setContext(newRequestContext(anotherUserId));
+    setRequestContextForUser(anotherUserId);
     // account 1000000 is not visible to 'anotheruser' as they are not an admin
     assertThatQueryException("query:query1,user=" + userId)
         .hasMessageThat()
         .isEqualTo(String.format("Account '%s' not found", userId));
-    requestContext.setContext(newRequestContext(userId));
+    setRequestContextForUser(userId);
 
     assertQuery("query:query1", change2, change1);
     assertQuery("query:query2", change2, change1);
@@ -4091,8 +4245,9 @@
 
   @Test
   public void byDeletedChange() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChange(repo));
 
     String query = "change:" + change.getId();
     assertQuery(query, change);
@@ -4103,8 +4258,9 @@
 
   @Test
   public void byUrlEncodedProject() throws Exception {
-    repo = createAndOpenProject("repo+foo");
-    Change change = insert("repo+foo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo+foo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChange(repo));
     assertQuery("project:repo+foo", change);
   }
 
@@ -4137,14 +4293,15 @@
   @Test
   public void isPureRevert() throws Exception {
     assume().that(getSchema().hasField(ChangeField.IS_PURE_REVERT_SPEC)).isTrue();
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     // Create two commits and revert second commit (initial commit can't be reverted)
-    Change initial = insert("repo", newChange(repo));
+    Change initial = insert(project, newChange(repo));
     getChangeApi(initial).current().review(ReviewInput.approve());
     getChangeApi(initial).current().submit();
 
     ChangeInfo changeToRevert =
-        gApi.changes().create(new ChangeInput("repo", "master", "commit to revert")).get();
+        gApi.changes().create(new ChangeInput(project.get(), "master", "commit to revert")).get();
     gApi.changes().id(changeToRevert.id).current().review(ReviewInput.approve());
     gApi.changes().id(changeToRevert.id).current().submit();
 
@@ -4168,12 +4325,14 @@
       RequestContext oldContext = requestContext.setContext(anonymousUserProvider::get);
 
       try {
-        requestContext.setContext(anonymousUserProvider::get);
+        @SuppressWarnings("unused")
+        var unused = requestContext.setContext(anonymousUserProvider::get);
         assertThatAuthException(query)
             .hasMessageThat()
             .isEqualTo("Must be signed-in to use this operator");
       } finally {
-        requestContext.setContext(oldContext);
+        @SuppressWarnings("unused")
+        var unused = requestContext.setContext(oldContext);
       }
     }
   }
@@ -4183,32 +4342,35 @@
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
-    repo = createAndOpenProject("repo");
-    Change change = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChange(repo));
     getChangeApi(change).addReviewer(user2.toString());
 
     RequestContext adminContext = requestContext.setContext(newRequestContext(user2));
     assertQuery("reviewer:self", change);
 
-    requestContext.setContext(adminContext);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(adminContext);
     gApi.accounts().id(user2.get()).setActive(false);
 
-    requestContext.setContext(newRequestContext(user2));
+    setRequestContextForUser(user2);
     assertQuery("reviewer:self", change);
   }
 
   @Test
   public void none() throws Exception {
-    repo = createAndOpenProject("repo");
-    Change change = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change = insert(project, newChange(repo));
 
     assertQuery(ChangeIndexPredicate.none());
 
     for (Predicate<ChangeData> matchingOneChange :
         ImmutableList.of(
             // One index query, one post-filtering query.
-            queryBuilder.parse(change.getId().toString()),
-            queryBuilder.parse("ownerin:Administrators"))) {
+            queryBuilderProvider.get().parse(change.getId().toString()),
+            queryBuilderProvider.get().parse("ownerin:Administrators"))) {
       assertQuery(matchingOneChange, change);
       assertQuery(Predicate.or(ChangeIndexPredicate.none(), matchingOneChange), change);
       assertQuery(Predicate.and(ChangeIndexPredicate.none(), matchingOneChange));
@@ -4221,9 +4383,10 @@
   @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
   public void mergeableFailsWhenNotIndexed() throws Exception {
     assume().that(getSchema().hasField(ChangeField.MERGE_SPEC)).isTrue();
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
-    insert("repo", newChangeForCommit(repo, commit1));
+    insert(project, newChangeForCommit(repo, commit1));
 
     Throwable thrown = assertThrows(Throwable.class, () -> assertQuery("status:open is:mergeable"));
     assertThat(thrown.getCause()).isInstanceOf(QueryParseException.class);
@@ -4236,20 +4399,21 @@
   public void customKeyedValue() throws Exception {
     assume().that(getSchema().hasField(ChangeField.CUSTOM_KEYED_VALUES_SPEC)).isTrue();
 
-    repo = createAndOpenProject("repo");
-    Change change1 = insert("repo", newChange(repo));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change change1 = insert(project, newChange(repo));
     CustomKeyedValuesInput in = new CustomKeyedValuesInput();
     in.add = ImmutableMap.of("workspace", "my-ws");
     getChangeApi(change1).setCustomKeyedValues(in);
 
-    Change change2 = insert("repo", newChange(repo));
+    Change change2 = insert(project, newChange(repo));
 
     in = new CustomKeyedValuesInput();
     in.add = ImmutableMap.of("workspace", "123");
     getChangeApi(change2).setCustomKeyedValues(in);
 
     // Insert a change without a KV pair
-    insert("repo", newChange(repo));
+    insert(project, newChange(repo));
 
     assertThat(customKeyedValues("workspace="))
         .containsExactly(change1.getChangeId(), change2.getChangeId());
@@ -4288,9 +4452,9 @@
     return newChange(repo, null, null, status, null, null, false, false);
   }
 
-  protected ChangeInserter newChangeWithStatus(String repoName, Change.Status status)
+  protected ChangeInserter newChangeWithStatus(Project.NameKey project, Change.Status status)
       throws Exception {
-    return newChange(repoName, null, null, status, null, null, false, false);
+    return newChange(project, null, null, status, null, null, false, false);
   }
 
   protected ChangeInserter newChangeWithTopic(TestRepository<Repository> repo, String topic)
@@ -4312,12 +4476,12 @@
     return newChange(repo, null, branch, null, null, cherryPickOf, false, true);
   }
 
-  protected ChangeInserter newChange(String repoName) throws Exception {
-    return newChange(repoName, null, null, null, null, null, false, false);
+  protected ChangeInserter newChange(Project.NameKey project) throws Exception {
+    return newChange(project, null, null, null, null, null, false, false);
   }
 
   protected ChangeInserter newChange(
-      String repoName,
+      Project.NameKey project,
       @Nullable RevCommit commit,
       @Nullable String branch,
       @Nullable Change.Status status,
@@ -4327,7 +4491,7 @@
       boolean isPrivate)
       throws Exception {
     try (TestRepository<Repository> repo =
-        new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+        new TestRepository<>(repoManager.openRepository(project))) {
       return newChange(
           repo, commit, branch, status, topic, cherryPickOf, workInProgress, isPrivate);
     }
@@ -4368,21 +4532,20 @@
   }
 
   @CanIgnoreReturnValue
-  protected Change insert(String repoName, ChangeInserter ins, @Nullable Account.Id owner)
+  protected Change insert(Project.NameKey project, ChangeInserter ins, @Nullable Account.Id owner)
       throws Exception {
-    return insert(repoName, ins, owner, TimeUtil.now());
+    return insert(project, ins, owner, TimeUtil.now());
   }
 
   @CanIgnoreReturnValue
-  protected Change insert(String repoName, ChangeInserter ins) throws Exception {
-    return insert(repoName, ins, null, TimeUtil.now());
+  protected Change insert(Project.NameKey project, ChangeInserter ins) throws Exception {
+    return insert(project, ins, null, TimeUtil.now());
   }
 
   @CanIgnoreReturnValue
   protected Change insert(
-      String repoName, ChangeInserter ins, @Nullable Account.Id owner, Instant createdOn)
+      Project.NameKey project, ChangeInserter ins, @Nullable Account.Id owner, Instant createdOn)
       throws Exception {
-    Project.NameKey project = Project.nameKey(repoName);
     Account.Id ownerId = owner != null ? owner : userId;
     IdentifiedUser user = userFactory.create(ownerId);
     return testRefAction(
@@ -4396,9 +4559,10 @@
   }
 
   protected Change newPatchSet(
-      String repoName, Change c, CurrentUser user, Optional<String> message) throws Exception {
+      Project.NameKey project, Change c, CurrentUser user, Optional<String> message)
+      throws Exception {
     try (TestRepository<Repository> repo =
-        new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+        new TestRepository<>(repoManager.openRepository(project))) {
       // Add a new file so the patch set is not a trivial rebase, to avoid default
       // Code-Review label copying.
       int n = c.currentPatchSetId().get() + 1;
@@ -4437,7 +4601,8 @@
 
   protected ThrowableSubject assertThatQueryException(QueryRequest query) throws Exception {
     try {
-      query.get();
+      @SuppressWarnings("unused")
+      var unused = query.get();
       throw new AssertionError("expected BadRequestException for query: " + query);
     } catch (BadRequestException e) {
       return assertThat(e);
@@ -4446,7 +4611,8 @@
 
   protected ThrowableSubject assertThatAuthException(Object query) throws Exception {
     try {
-      newQuery(query).get();
+      @SuppressWarnings("unused")
+      var unused = newQuery(query).get();
       throw new AssertionError("expected AuthException for query: " + query);
     } catch (AuthException e) {
       return assertThat(e);
@@ -4454,103 +4620,105 @@
   }
 
   @CanIgnoreReturnValue
-  protected TestRepository<Repository> createAndOpenProject(String name) throws Exception {
-    createProject(name);
-    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
-  }
-
-  protected TestRepository<Repository> createAndOpenProject(String name, String parent)
+  protected TestRepository<Repository> createAndOpenProject(Project.NameKey project)
       throws Exception {
-    createProject(name, parent);
-    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
+    createProject(project);
+    return new TestRepository<>(repoManager.openRepository(project));
   }
 
-  protected void createProject(String name) throws Exception {
-    gApi.projects().create(name).get();
+  protected TestRepository<Repository> createAndOpenProject(
+      Project.NameKey project, Project.NameKey parent) throws Exception {
+    createProject(project, parent);
+    return new TestRepository<>(repoManager.openRepository(project));
   }
 
-  protected void createProject(String name, String parent) throws Exception {
+  protected void createProject(Project.NameKey project) throws Exception {
+    gApi.projects().create(project.get());
+  }
+
+  protected void createProject(Project.NameKey project, Project.NameKey parent) throws Exception {
     ProjectInput input = new ProjectInput();
-    input.name = name;
-    input.parent = parent;
-    gApi.projects().create(input).get();
+    input.name = project.get();
+    input.parent = parent.get();
+    gApi.projects().create(input);
   }
 
   protected QueryRequest newQuery(Object query) {
     return gApi.changes().query(query.toString());
   }
 
+  @CanIgnoreReturnValue
   protected List<ChangeInfo> assertQuery(Object query, Change... changes) throws Exception {
     return assertQuery(newQuery(query), changes);
   }
 
+  @CanIgnoreReturnValue
   protected List<ChangeInfo> assertQueryByIds(Object query, Change.Id... changes) throws Exception {
     return assertQueryByIds(newQuery(query), changes);
   }
 
+  @CanIgnoreReturnValue
   protected List<ChangeInfo> assertQuery(QueryRequest query, Change... changes) throws Exception {
     return assertQueryByIds(
         query, Arrays.stream(changes).map(Change::getId).toArray(Change.Id[]::new));
   }
 
-  protected List<ChangeInfo> assertQueryByIds(QueryRequest query, Change.Id... changes)
+  @CanIgnoreReturnValue
+  protected List<ChangeInfo> assertQueryByIds(QueryRequest query, Change.Id... expectedChangeIds)
       throws Exception {
     List<ChangeInfo> result = query.get();
-    Iterable<Change.Id> ids = ids(result);
-    assertWithMessage(format(query.getQuery(), ids, changes))
-        .that(ids)
-        .containsExactlyElementsIn(Arrays.asList(changes))
-        .inOrder();
+    List<Change.Id> actualIds = ids(result);
+    assertThat(actualIds).containsExactlyElementsIn(Arrays.asList(expectedChangeIds)).inOrder();
     return result;
   }
 
   protected void assertQuery(Predicate<ChangeData> predicate, Change... changes) throws Exception {
-    ImmutableList<Change.Id> actualIds =
+    ImmutableList<Change> actualChanges =
         queryProvider.get().query(predicate).stream()
-            .map(ChangeData::getId)
+            .map(ChangeData::change)
             .collect(toImmutableList());
+    ImmutableList<Change.Id> actualIds =
+        actualChanges.stream().map(Change::getId).collect(toImmutableList());
     Change.Id[] expectedIds = Arrays.stream(changes).map(Change::getId).toArray(Change.Id[]::new);
-    assertWithMessage(format(predicate.toString(), actualIds, expectedIds))
+    assertWithMessage(format(predicate.toString(), actualChanges, changes))
         .that(actualIds)
         .containsExactlyElementsIn(expectedIds)
         .inOrder();
   }
 
-  private String format(String query, Iterable<Change.Id> actualIds, Change.Id... expectedChanges)
-      throws RestApiException {
+  private String format(String query, Iterable<Change> actualChanges, Change... expectedChanges) {
     return "query '"
         + query
         + "' with expected changes "
         + format(Arrays.asList(expectedChanges))
         + " and result "
-        + format(actualIds);
+        + format(actualChanges);
   }
 
-  private String format(Iterable<Change.Id> changeIds) throws RestApiException {
-    return format(changeIds.iterator());
+  private String format(Iterable<Change> changes) {
+    return format(changes.iterator());
   }
 
-  private String format(Iterator<Change.Id> changeIds) throws RestApiException {
+  private String format(Iterator<Change> changes) {
     StringBuilder b = new StringBuilder();
     b.append("[");
-    while (changeIds.hasNext()) {
-      Change.Id id = changeIds.next();
-      ChangeInfo c = gApi.changes().id(id.get()).get();
+    while (changes.hasNext()) {
+      Change c = changes.next();
       b.append("{")
-          .append(id)
+          .append(c.getChangeId())
           .append(" (")
-          .append(c.changeId)
+          .append(c.getKey().get())
           .append("), ")
           .append("dest=")
-          .append(BranchNameKey.create(Project.nameKey(c.project), c.branch))
+          .append(c.getDest())
           .append(", ")
           .append("status=")
-          .append(c.status)
+          .append(c.getStatus().name())
           .append(", ")
           .append("lastUpdated=")
-          .append(c.updated.getTime())
+          .append(c.getLastUpdatedOn().toEpochMilli())
           .append("}");
-      if (changeIds.hasNext()) {
+      if (changes.hasNext()) {
         b.append(", ");
       }
     }
@@ -4558,11 +4726,11 @@
     return b.toString();
   }
 
-  protected static Iterable<Change.Id> ids(Change... changes) {
+  protected static List<Change.Id> ids(Change... changes) {
     return Arrays.stream(changes).map(Change::getId).collect(toList());
   }
 
-  protected static Iterable<Change.Id> ids(Iterable<ChangeInfo> changes) {
+  protected static List<Change.Id> ids(Iterable<ChangeInfo> changes) {
     return Streams.stream(changes).map(c -> Change.id(c._number)).collect(toList());
   }
 
@@ -4636,6 +4804,7 @@
     getChangeApi(change).current().review(input);
   }
 
+  @CanIgnoreReturnValue
   private Account.Id createAccount(String username, String fullName, String email, boolean active)
       throws Exception {
     try (ManualRequestContext ctx = oneOffRequestContext.open()) {
@@ -4656,6 +4825,11 @@
     }
   }
 
+  private void setRequestContextForUser(Account.Id userId) {
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(newRequestContext(userId));
+  }
+
   protected void assertFailingQuery(String query) throws Exception {
     assertFailingQuery(query, null);
   }
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index 5d54baf..d00cc45 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -24,6 +24,7 @@
 import static org.junit.Assume.assumeFalse;
 import static org.junit.Assume.assumeTrue;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -67,17 +68,19 @@
   @UseClockStep
   public void stopQueryIfNoMoreResults() throws Exception {
     // create 2 visible changes
-    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
-      insert("repo", newChange(testRepo));
-      insert("repo", newChange(testRepo));
+    Project.NameKey project = Project.nameKey("repo");
+    try (TestRepository<Repository> testRepo = createAndOpenProject(project)) {
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
     }
 
     // create 2 invisible changes
-    try (TestRepository<Repository> hiddenProject = createAndOpenProject("hiddenProject")) {
-      insert("hiddenProject", newChange(hiddenProject));
-      insert("hiddenProject", newChange(hiddenProject));
+    Project.NameKey hiddenProject = Project.nameKey("hiddenProject");
+    try (TestRepository<Repository> hiddenRepo = createAndOpenProject(hiddenProject)) {
+      insert(hiddenProject, newChange(hiddenRepo));
+      insert(hiddenProject, newChange(hiddenRepo));
       projectOperations
-          .project(Project.nameKey("hiddenProject"))
+          .project(hiddenProject)
           .forUpdate()
           .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
           .update();
@@ -85,7 +88,10 @@
 
     AbstractFakeIndex<?, ?, ?> idx =
         (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
-    newQuery("status:new").withLimit(5).get();
+
+    @SuppressWarnings("unused")
+    var unused = newQuery("status:new").withLimit(5).get();
+
     // Since the limit of the query (i.e. 5) is more than the total number of changes (i.e. 4),
     // only 1 index search is expected.
     assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
@@ -94,18 +100,19 @@
   @Test
   @UseClockStep
   public void queryRightNumberOfTimes() throws Exception {
-    TestRepository<Repository> repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    TestRepository<Repository> repo = createAndOpenProject(project);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
     // create 1 visible change
-    Change visibleChange1 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+    Change visibleChange1 = insert(project, newChangeWithStatus(repo, Change.Status.NEW));
 
     // create 4 private changes
-    Change invisibleChange2 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW), user2);
-    Change invisibleChange3 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW), user2);
-    Change invisibleChange4 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW), user2);
-    Change invisibleChange5 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange2 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange3 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange4 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange5 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
     gApi.changes().id(invisibleChange2.getKey().get()).setPrivate(true, null);
     gApi.changes().id(invisibleChange3.getKey().get()).setPrivate(true, null);
     gApi.changes().id(invisibleChange4.getKey().get()).setPrivate(true, null);
@@ -131,11 +138,12 @@
   public void noLimitQueryPaginates() throws Exception {
     assumeFalse(PaginationType.NONE == getCurrentPaginationType());
 
-    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
-      insert("repo", newChange(testRepo));
-      insert("repo", newChange(testRepo));
-      insert("repo", newChange(testRepo));
-      insert("repo", newChange(testRepo));
+    Project.NameKey project = Project.nameKey("repo");
+    try (TestRepository<Repository> testRepo = createAndOpenProject(project)) {
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
     }
     // Set queryLimit to 2
     projectOperations
@@ -150,7 +158,9 @@
     // 2 index searches are expected. The first index search will run with size 3 (i.e.
     // the configured query-limit+1), and then we will paginate to get the remaining
     // changes with the second index search.
-    newQuery("status:new").withNoLimit().get();
+    @SuppressWarnings("unused")
+    var unused = newQuery("status:new").withNoLimit().get();
+
     assertThatSearchQueryWasPaginated(idx.getQueryCount(), 2);
   }
 
@@ -158,8 +168,12 @@
   @UseClockStep
   public void noLimitQueryDoesNotPaginatesWithNonePaginationType() throws Exception {
     assumeTrue(PaginationType.NONE == getCurrentPaginationType());
+
     AbstractFakeIndex<?, ?, ?> idx = setupRepoWithFourChanges();
-    newQuery("status:new").withNoLimit().get();
+
+    @SuppressWarnings("unused")
+    var unused = newQuery("status:new").withNoLimit().get();
+
     assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
   }
 
@@ -182,7 +196,9 @@
         .add(allowCapability(QUERY_LIMIT).group(ANONYMOUS_USERS).range(0, LIMIT))
         .update();
 
-    requestContext.setContext(anonymousUserProvider::get);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(anonymousUserProvider::get);
+
     List<ChangeInfo> result = newQuery("status:new").withLimit(LIMIT).get();
     assertThat(result.size()).isEqualTo(0);
     assertThatSearchQueryWasPaginated(idx.getQueryCount(), 2);
@@ -196,11 +212,12 @@
     assumeFalse(PaginationType.NONE == getCurrentPaginationType());
     final int LIMIT = 2;
 
-    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
-      insert("repo", newChange(testRepo));
-      insert("repo", newChange(testRepo));
-      insert("repo", newChange(testRepo));
-      insert("repo", newChange(testRepo));
+    Project.NameKey project = Project.nameKey("repo");
+    try (TestRepository<Repository> testRepo = createAndOpenProject(project)) {
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
     }
     // Set queryLimit to 2
     projectOperations
@@ -230,7 +247,8 @@
 
   @SuppressWarnings("unused")
   private void executeQuery(String query) throws QueryParseException {
-    List<ChangeData> unused = queryProvider.get().query(queryBuilder.parse(query));
+    ImmutableList<ChangeData> unused =
+        queryProvider.get().query(queryBuilderProvider.get().parse(query));
   }
 
   private void assertThatSearchQueryWasNotPaginated(int queryCount) {
@@ -242,11 +260,12 @@
   }
 
   private AbstractFakeIndex<?, ?, ?> setupRepoWithFourChanges() throws Exception {
-    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
-      insert("repo", newChange(testRepo));
-      insert("repo", newChange(testRepo));
-      insert("repo", newChange(testRepo));
-      insert("repo", newChange(testRepo));
+    Project.NameKey project = Project.nameKey("repo");
+    try (TestRepository<Repository> testRepo = createAndOpenProject(project)) {
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
+      insert(project, newChange(testRepo));
     }
 
     // Set queryLimit to 2
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index e7600d9..82c9065 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -22,6 +22,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.testing.InMemoryModule;
@@ -44,11 +45,12 @@
 
   @Test
   public void fullTextWithSpecialChars() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
     RevCommit commit1 = repo.parseBody(repo.commit().message("foo_bar_foo").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("one.two.three").create());
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change2 = insert(project, newChangeForCommit(repo, commit2));
 
     assertQuery("message:foo_ba");
     assertQuery("message:bar", change1);
@@ -60,6 +62,23 @@
   }
 
   @Test
+  public void byChangeId() throws Exception {
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    RevCommit commit1 = repo.parseBody(repo.commit().message("foo_bar_foo").create());
+    Change change1 = insert(project, newChangeForCommit(repo, commit1));
+
+    assertQuery(String.format("change:%s", change1.getChangeId()), change1);
+    assertQuery(String.format("change:%s", change1.getId()), change1);
+    assertQuery(
+        String.format("change:%s~%s", change1.getProject(), change1.getChangeId()), change1);
+    assertQuery(
+        String.format(
+            "change:%s~%s~%s", change1.getProject(), change1.getDest().branch(), change1.getKey()),
+        change1);
+  }
+
+  @Test
   public void invalidQuery() throws Exception {
     BadRequestException thrown =
         assertThrows(BadRequestException.class, () -> newQuery("\\").get());
@@ -68,17 +87,18 @@
 
   @Test
   public void openAndClosedChanges() throws Exception {
-    repo = createAndOpenProject("repo");
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
 
     // create 3 closed changes
-    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change2 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change3 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change1 = insert(project, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change2 = insert(project, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change3 = insert(project, newChangeWithStatus(repo, Change.Status.MERGED));
 
     // create 3 new changes
-    Change change4 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
-    Change change5 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
-    Change change6 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+    Change change4 = insert(project, newChangeWithStatus(repo, Change.Status.NEW));
+    Change change5 = insert(project, newChangeWithStatus(repo, Change.Status.NEW));
+    Change change6 = insert(project, newChangeWithStatus(repo, Change.Status.NEW));
 
     // Set queryLimit to 1
     projectOperations
@@ -94,8 +114,9 @@
   @Test
   public void skipChangesNotVisible() throws Exception {
     // create 1 new change on a repo
-    repo = createAndOpenProject("repo");
-    Change visibleChange = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change visibleChange = insert(project, newChangeWithStatus(repo, Change.Status.NEW));
     Change[] expected = new Change[] {visibleChange};
 
     // pagination does not need to restart the datasource, the request is fulfilled
@@ -105,8 +126,8 @@
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
-    Change invisibleChange1 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW), user2);
-    Change invisibleChange2 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange1 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange2 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
     gApi.changes().id(invisibleChange1.getKey().get()).setPrivate(true, null);
     gApi.changes().id(invisibleChange2.getKey().get()).setPrivate(true, null);
 
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 12bafd5..572e7af 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -16,13 +16,13 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.fail;
 
 import com.google.common.base.CharMatcher;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -144,7 +144,7 @@
     Account.Id userId =
         createAccountOutsideRequestContext("user", "User", "user@example.com", true);
     user = userFactory.create(userId);
-    requestContext.setContext(newRequestContext(userId));
+    setRequestContextForUser(userId);
     currentUserInfo = gApi.accounts().id(userId.get()).get();
   }
 
@@ -156,7 +156,8 @@
   }
 
   protected void setAnonymous() {
-    requestContext.setContext(anonymousUser::get);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(anonymousUser::get);
   }
 
   @After
@@ -165,7 +166,8 @@
       lifecycle.stop();
     }
     if (requestContext != null) {
-      requestContext.setContext(null);
+      @SuppressWarnings("unused")
+      var unused = requestContext.setContext(null);
     }
   }
 
@@ -436,14 +438,22 @@
     return gApi.accounts().create(accountInput).get();
   }
 
+  private void setRequestContextForUser(Account.Id userId) {
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(newRequestContext(userId));
+  }
+
+  @CanIgnoreReturnValue
   protected GroupInfo createGroup(String name, AccountInfo... members) throws Exception {
     return createGroupWithDescription(name, null, members);
   }
 
+  @CanIgnoreReturnValue
   protected GroupInfo createGroup(GroupInput in) throws Exception {
     return gApi.groups().create(in).get();
   }
 
+  @CanIgnoreReturnValue
   protected GroupInfo createGroupWithDescription(
       String name, String description, AccountInfo... members) throws Exception {
     GroupInput in = new GroupInput();
@@ -454,6 +464,7 @@
     return createGroup(in);
   }
 
+  @CanIgnoreReturnValue
   protected GroupInfo createGroupWithOwner(String name, GroupInfo ownerGroup) throws Exception {
     GroupInput in = new GroupInput();
     in.name = name;
@@ -461,6 +472,7 @@
     return createGroup(in);
   }
 
+  @CanIgnoreReturnValue
   protected GroupInfo createGroupThatIsVisibleToAll(String name) throws Exception {
     GroupInput in = new GroupInput();
     in.name = name;
@@ -478,18 +490,21 @@
     return gApi.groups().id(uuid.get()).get();
   }
 
+  @CanIgnoreReturnValue
   protected List<GroupInfo> assertQuery(Object query, GroupInfo... groups) throws Exception {
     return assertQuery(newQuery(query), groups);
   }
 
+  @CanIgnoreReturnValue
   protected List<GroupInfo> assertQuery(QueryRequest query, GroupInfo... groups) throws Exception {
     return assertQuery(query, Arrays.asList(groups));
   }
 
+  @CanIgnoreReturnValue
   protected List<GroupInfo> assertQuery(QueryRequest query, List<GroupInfo> groups)
       throws Exception {
     List<GroupInfo> result = query.get();
-    Iterable<String> uuids = uuids(result);
+    List<String> uuids = uuids(result);
     assertWithMessage(format(query, result, groups))
         .that(uuids)
         .containsExactlyElementsIn(uuids(groups))
@@ -562,11 +577,11 @@
     return b == null ? false : b;
   }
 
-  protected static Iterable<String> ids(GroupInfo... groups) {
+  protected static List<String> ids(GroupInfo... groups) {
     return uuids(Arrays.asList(groups));
   }
 
-  protected static Iterable<String> uuids(List<GroupInfo> groups) {
+  protected static List<String> uuids(List<GroupInfo> groups) {
     return groups.stream().map(g -> g.id).sorted().collect(toList());
   }
 
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index 550cb41..25cd76b 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -24,6 +24,7 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/guice",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
@@ -41,6 +42,7 @@
         ":abstract_query_tests",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib:jgit",
         "//lib/guice",
     ],
@@ -57,6 +59,7 @@
         ":abstract_query_tests",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib:jgit",
         "//lib/guice",
     ],
diff --git a/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
index d347716..d623f30 100644
--- a/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.group;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -21,7 +22,6 @@
 import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
@@ -48,7 +48,8 @@
   @ConfigSuite.Configs
   public static Map<String, Config> againstPreviousIndexVersion() {
     // the current schema version is already tested by the inherited default config suite
-    List<Integer> schemaVersions = IndexVersions.getWithoutLatest(GroupSchemaDefinitions.INSTANCE);
+    ImmutableList<Integer> schemaVersions =
+        IndexVersions.getWithoutLatest(GroupSchemaDefinitions.INSTANCE);
     return IndexVersions.asConfigMap(
         GroupSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
   }
diff --git a/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
index 2a453a0..3f3eb58 100644
--- a/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.group;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -21,7 +22,6 @@
 import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
@@ -34,7 +34,8 @@
   @ConfigSuite.Configs
   public static Map<String, Config> againstPreviousIndexVersion() {
     // the current schema version is already tested by the inherited default config suite
-    List<Integer> schemaVersions = IndexVersions.getWithoutLatest(GroupSchemaDefinitions.INSTANCE);
+    ImmutableList<Integer> schemaVersions =
+        IndexVersions.getWithoutLatest(GroupSchemaDefinitions.INSTANCE);
     return IndexVersions.asConfigMap(
         GroupSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
   }
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index 47d485d..d766e16 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -147,7 +147,7 @@
 
     Account.Id userId = createAccount("user", "User", "user@example.com", true);
     user = userFactory.create(userId);
-    requestContext.setContext(newRequestContext(userId));
+    setRequestContextForUser(userId);
     currentUserInfo = gApi.accounts().id(userId.get()).get();
 
     // All-Projects and All-Users are not indexed, index them now.
@@ -166,7 +166,8 @@
   }
 
   protected void setAnonymous() {
-    requestContext.setContext(anonymousUser::get);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(anonymousUser::get);
   }
 
   @After
@@ -174,7 +175,8 @@
     if (lifecycle != null) {
       lifecycle.stop();
     }
-    requestContext.setContext(null);
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(null);
   }
 
   @Test
@@ -426,6 +428,12 @@
     }
   }
 
+  private void setRequestContextForUser(Account.Id userId) {
+    @SuppressWarnings("unused")
+    var unused = requestContext.setContext(newRequestContext(userId));
+  }
+
+  @CanIgnoreReturnValue
   protected ProjectInfo createProject(String name) throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name;
@@ -440,6 +448,7 @@
     return gApi.projects().create(in).get();
   }
 
+  @CanIgnoreReturnValue
   protected ProjectInfo createProjectWithDescription(String name, String description)
       throws Exception {
     ProjectInput in = new ProjectInput();
@@ -448,6 +457,7 @@
     return gApi.projects().create(in).get();
   }
 
+  @CanIgnoreReturnValue
   protected ProjectInfo createProjectWithState(String name, ProjectState state) throws Exception {
     ProjectInfo info = createProject(name);
     ConfigInput config = new ConfigInput();
@@ -456,6 +466,7 @@
     return info;
   }
 
+  @CanIgnoreReturnValue
   protected ProjectInfo createProjectRestrictedToRegisteredUsers(String name) throws Exception {
     createProject(name);
 
@@ -475,19 +486,22 @@
     return gApi.projects().name(nameKey.get()).get();
   }
 
+  @CanIgnoreReturnValue
   protected List<ProjectInfo> assertQuery(Object query, ProjectInfo... projects) throws Exception {
     return assertQuery(newQuery(query), projects);
   }
 
+  @CanIgnoreReturnValue
   protected List<ProjectInfo> assertQuery(QueryRequest query, ProjectInfo... projects)
       throws Exception {
     return assertQuery(query, Arrays.asList(projects));
   }
 
+  @CanIgnoreReturnValue
   protected List<ProjectInfo> assertQuery(QueryRequest query, List<ProjectInfo> projects)
       throws Exception {
     List<ProjectInfo> result = query.get();
-    Iterable<String> names = names(result);
+    List<String> names = names(result);
     assertWithMessage(format(query, result, projects))
         .that(names)
         .containsExactlyElementsIn(names(projects))
@@ -552,11 +566,11 @@
     return indexes.getSearchIndex().getSchema();
   }
 
-  protected static Iterable<String> names(ProjectInfo... projects) {
+  protected static List<String> names(ProjectInfo... projects) {
     return names(Arrays.asList(projects));
   }
 
-  protected static Iterable<String> names(List<ProjectInfo> projects) {
+  protected static List<String> names(List<ProjectInfo> projects) {
     return projects.stream().map(p -> p.name).collect(toList());
   }
 
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index 2ae73b3..ae94e69 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -37,7 +37,9 @@
     deps = [
         ":abstract_query_tests",
         "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib:jgit",
         "//lib/guice",
     ],
@@ -53,7 +55,9 @@
     deps = [
         ":abstract_query_tests",
         "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib:jgit",
         "//lib/guice",
     ],
diff --git a/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
index 6fc0568..19fbe66 100644
--- a/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.project;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -21,7 +22,6 @@
 import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
@@ -34,7 +34,7 @@
   @ConfigSuite.Configs
   public static Map<String, Config> againstPreviousIndexVersion() {
     // the current schema version is already tested by the inherited default config suite
-    List<Integer> schemaVersions =
+    ImmutableList<Integer> schemaVersions =
         IndexVersions.getWithoutLatest(
             com.google.gerrit.index.project.ProjectSchemaDefinitions.INSTANCE);
     return IndexVersions.asConfigMap(
diff --git a/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
index 77a56ed..09e8388 100644
--- a/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.project;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.InMemoryModule;
@@ -21,7 +22,6 @@
 import com.google.gerrit.testing.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
-import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
@@ -34,7 +34,7 @@
   @ConfigSuite.Configs
   public static Map<String, Config> againstPreviousIndexVersion() {
     // the current schema version is already tested by the inherited default config suite
-    List<Integer> schemaVersions =
+    ImmutableList<Integer> schemaVersions =
         IndexVersions.getWithoutLatest(
             com.google.gerrit.index.project.ProjectSchemaDefinitions.INSTANCE);
     return IndexVersions.asConfigMap(
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index eb1d275..6f504c4 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -86,7 +86,7 @@
     linkAuthor(comments.get(5), accountId1);
 
     // Change massages have exactly same timestamps
-    List<ChangeMessage> changeMessages =
+    ImmutableList<ChangeMessage> changeMessages =
         ImmutableList.of(
             createChangeMessage("cm0", "10", Account.id(accountId1)),
             createChangeMessage("cm1", "11", Account.id(accountId1)),
@@ -142,7 +142,7 @@
     linkAuthor(comments.get(1), accountId2Imported);
     linkAuthor(comments.get(2), accountId3);
 
-    List<ChangeMessage> changeMessages =
+    ImmutableList<ChangeMessage> changeMessages =
         ImmutableList.of(
             createChangeMessage("changeMessage0", tsCm0, Account.id(accountId1)),
             createChangeMessage("changeMessage1", tsCm1, Account.id(accountId2)),
diff --git a/javatests/com/google/gerrit/server/restapi/project/TagSorterTest.java b/javatests/com/google/gerrit/server/restapi/project/TagSorterTest.java
new file mode 100644
index 0000000..9a926a2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/restapi/project/TagSorterTest.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.ListTagSortOption;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TagSorterTest {
+  private static final String revision = "dfdd715e31db256dfba48239f83f9b8da4bc243f";
+  private static final boolean canDelete = true;
+  private static final List<WebLinkInfo> webLinks = new ArrayList<>();
+  private static final TagSorter tagSorter = new TagSorter();
+  private List<TagInfo> tags;
+
+  @Before
+  public void initializeTags() {
+    tags = createTags();
+  }
+
+  @Test
+  public void testSortTagsByRef() {
+    tagSorter.sort(ListTagSortOption.REF, tags, false);
+
+    assertThat(tags.get(0).ref).isEqualTo("refs/tags/v1.0");
+    assertThat(tags.get(1).ref).isEqualTo("refs/tags/v2.0");
+    assertThat(tags.get(2).ref).isEqualTo("refs/tags/v3.0");
+    assertThat(tags.get(3).ref).isEqualTo("refs/tags/v4.0");
+  }
+
+  @Test
+  public void testSortTagsByCreationTime() {
+    tagSorter.sort(ListTagSortOption.CREATION_TIME, tags, false);
+
+    assertThat(tags.get(0).ref).isEqualTo("refs/tags/v2.0");
+    assertThat(tags.get(1).ref).isEqualTo("refs/tags/v3.0");
+    assertThat(tags.get(2).ref).isEqualTo("refs/tags/v1.0");
+    assertThat(tags.get(3).ref).isEqualTo("refs/tags/v4.0");
+  }
+
+  @Test
+  public void testSortTagsByCreationTimeDescendingOrder() {
+    tagSorter.sort(ListTagSortOption.CREATION_TIME, tags, true);
+
+    assertThat(tags.get(0).ref).isEqualTo("refs/tags/v4.0");
+    assertThat(tags.get(1).ref).isEqualTo("refs/tags/v2.0");
+    assertThat(tags.get(2).ref).isEqualTo("refs/tags/v3.0");
+    assertThat(tags.get(3).ref).isEqualTo("refs/tags/v1.0");
+  }
+
+  private List<TagInfo> createTags() {
+    Instant t1 = Instant.now();
+    Instant t2 = t1.minusSeconds(10);
+    Instant t3 = t1.minusSeconds(1);
+
+    List<TagInfo> tags = new ArrayList<>();
+    tags.add(new TagInfo("refs/tags/v1.0", revision, canDelete, webLinks, t1));
+    tags.add(new TagInfo("refs/tags/v2.0", revision, canDelete, webLinks, t2));
+    tags.add(new TagInfo("refs/tags/v3.0", revision, canDelete, webLinks, t3));
+    tags.add(new TagInfo("refs/tags/v4.0", revision, canDelete, webLinks, (Instant) null));
+
+    return tags;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
index 509447a..c7ac533 100644
--- a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 import org.junit.Test;
 
@@ -42,7 +41,7 @@
     PatchSetApproval approvalVerified = makeApproval(VERIFIED.getLabelId(), USER1, 2);
     PatchSetApproval approvalCr = makeApproval(codeReview.getLabelId(), USER1, 2);
 
-    Collection<PatchSetApproval> filteredApprovals =
+    ImmutableList<PatchSetApproval> filteredApprovals =
         IgnoreSelfApprovalRule.filterApprovalsByLabel(
             ImmutableList.of(approvalVerified, approvalCr), VERIFIED);
 
@@ -62,7 +61,7 @@
             makeApproval(VERIFIED.getLabelId(), USER1, +1),
             makeApproval(VERIFIED.getLabelId(), USER1, +2));
 
-    Collection<PatchSetApproval> filteredApprovals =
+    ImmutableList<PatchSetApproval> filteredApprovals =
         IgnoreSelfApprovalRule.filterOutPositiveApprovalsOfUser(approvals, USER1);
 
     assertThat(filteredApprovals).containsExactly(approvalM1, approvalM2);
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
index 5d5ef4e..4970228 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.schema;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.server.schema.NoteDbSchemaUpdater.requiredUpgrades;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
@@ -146,14 +145,16 @@
     }
 
     protected void seedGroupSequenceRef() {
-      new RepoSequence(
-              repoManager,
-              GitReferenceUpdated.DISABLED,
-              allUsersName,
-              Sequence.NAME_GROUPS,
-              () -> 1,
-              1)
-          .next();
+      @SuppressWarnings("unused")
+      var unused =
+          new RepoSequence(
+                  repoManager,
+                  GitReferenceUpdated.DISABLED,
+                  allUsersName,
+                  Sequence.NAME_GROUPS,
+                  () -> 1,
+                  1)
+              .next();
     }
 
     /** Test-specific setup. */
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java
index d2ccaa9..2c9aad4 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.inject.ProvisionException;
 import java.io.IOException;
-import java.nio.file.Paths;
+import java.nio.file.Path;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
@@ -37,11 +37,11 @@
   public void setup() throws Exception {
     AllProjectsName allProjectsName = new AllProjectsName("All-Projects");
     GitRepositoryManager repoManager = new InMemoryRepositoryManager();
-    repoManager.createRepository(allProjectsName);
+    repoManager.createRepository(allProjectsName).close();
     versionManager = new NoteDbSchemaVersionManager(allProjectsName, repoManager);
     testRefAction(() -> versionManager.init());
 
-    sitePaths = new SitePaths(Paths.get("/tmp/foo"));
+    sitePaths = new SitePaths(Path.of("/tmp/foo"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
index 31697fd..bd3b8af 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.server.schema.NoteDbSchemaVersions.guessVersion;
 
 import com.google.common.collect.ImmutableList;
@@ -67,7 +66,8 @@
   @Test
   public void schemaConstructors() throws Exception {
     for (int version : NoteDbSchemaVersions.ALL.keySet()) {
-      NoteDbSchemaVersions.get(NoteDbSchemaVersions.ALL, version);
+      @SuppressWarnings("unused")
+      var unused = NoteDbSchemaVersions.get(NoteDbSchemaVersions.ALL, version);
     }
   }
 }
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index 1304c53..325961c 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.schema;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.GroupReference;
@@ -110,7 +109,7 @@
 
   private boolean hasGroup(String name) throws Exception {
     try (Repository repo = repositoryManager.openRepository(allUsersName)) {
-      List<GroupReference> nameNotes = GroupNameNotes.loadAllGroups(repo);
+      ImmutableList<GroupReference> nameNotes = GroupNameNotes.loadAllGroups(repo);
       return nameNotes.stream().anyMatch(g -> g.getName().equals(name));
     }
   }
diff --git a/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java b/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
index a391c03..8724b9a 100644
--- a/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
+++ b/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.when;
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index 345681d..a602ee9 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -12,11 +12,13 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/testing:test-ref-update-context",
         "//lib:guava",
+        "//lib:guava-retrying",
         "//lib:jgit",
         "//lib:jgit-junit",
         "//lib/guice",
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index f7a2afa..79a688c 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -22,6 +22,9 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
+import com.github.rholder.retry.Attempt;
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.RetryListener;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -37,7 +40,10 @@
 import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.Sequences;
@@ -53,6 +59,7 @@
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
@@ -61,12 +68,17 @@
 import com.google.inject.name.Named;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -107,6 +119,8 @@
   @Inject private IdentifiedUser.GenericFactory userFactory;
   @Inject private InternalUser.Factory internalUserFactory;
   @Inject private AbandonOp.Factory abandonOpFactory;
+  @Inject @GerritPersonIdent private PersonIdent serverIdent;
+  @Inject private RetryHelper retryHelper;
 
   @Rule public final MockitoRule mockito = MockitoJUnit.rule();
 
@@ -566,6 +580,245 @@
     assertThat(metaCommit.getParent(0)).isEqualTo(oldHead);
   }
 
+  @Test
+  public void lockFailureOnConcurrentUpdate() throws Exception {
+    Change.Id changeId = createChange();
+    ObjectId metaId = getMetaId(changeId);
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+
+    AtomicBoolean doneBackgroundUpdate = new AtomicBoolean(false);
+
+    // Create a listener that updates the change meta ref concurrently on the first attempt.
+    BatchUpdateListener listener =
+        new BatchUpdateListener() {
+          @Override
+          public BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) {
+            try (RevWalk rw = new RevWalk(repo.getRepository())) {
+              RevCommit old = rw.parseCommit(metaId);
+              RevCommit commit =
+                  repo.commit()
+                      .parent(old)
+                      .author(serverIdent)
+                      .committer(serverIdent)
+                      .setTopLevelTree(old.getTree())
+                      .message("Concurrent Update\n\nPatch-Set: 1")
+                      .create();
+              RefUpdate ru = repo.getRepository().updateRef(RefNames.changeMetaRef(changeId));
+              ru.setExpectedOldObjectId(metaId);
+              ru.setNewObjectId(commit);
+              ru.update();
+              RefUpdateUtil.checkResult(ru);
+              doneBackgroundUpdate.set(true);
+            } catch (Exception e) {
+              // Ignore. If an exception happens doneBackgroundUpdate is false and we fail later
+              // when doneBackgroundUpdate is checked.
+            }
+            return bru;
+          }
+        };
+
+    // Do a batch update, expect that it fails with LOCK_FAILURE due to the concurrent update.
+    assertThat(doneBackgroundUpdate.get()).isFalse();
+    UpdateException exception =
+        assertThrows(
+            UpdateException.class,
+            () -> {
+              try (BatchUpdate bu =
+                  batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+                bu.addOp(changeId, abandonOpFactory.create(null, "abandon"));
+                bu.execute(listener);
+              }
+            });
+    assertThat(exception).hasCauseThat().isInstanceOf(LockFailureException.class);
+    assertThat(doneBackgroundUpdate.get()).isTrue();
+
+    // Check that the change was not updated.
+    notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+  }
+
+  @Test
+  public void useRetryHelperToRetryOnLockFailure() throws Exception {
+    Change.Id changeId = createChange();
+    ObjectId metaId = getMetaId(changeId);
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+
+    AtomicBoolean doneBackgroundUpdate = new AtomicBoolean(false);
+
+    // Create a listener that updates the change meta ref concurrently on the first attempt.
+    BatchUpdateListener listener =
+        new BatchUpdateListener() {
+          @Override
+          public BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) {
+            if (!doneBackgroundUpdate.getAndSet(true)) {
+              try (RevWalk rw = new RevWalk(repo.getRepository())) {
+                RevCommit old = rw.parseCommit(metaId);
+                RevCommit commit =
+                    repo.commit()
+                        .parent(old)
+                        .author(serverIdent)
+                        .committer(serverIdent)
+                        .setTopLevelTree(old.getTree())
+                        .message("Concurrent Update\n\nPatch-Set: 1")
+                        .create();
+                RefUpdate ru = repo.getRepository().updateRef(RefNames.changeMetaRef(changeId));
+                ru.setExpectedOldObjectId(metaId);
+                ru.setNewObjectId(commit);
+                ru.update();
+                RefUpdateUtil.checkResult(ru);
+              } catch (Exception e) {
+                // Ignore. If an exception happens doneBackgroundUpdate is false and we fail later
+                // when doneBackgroundUpdate is checked.
+              }
+            }
+            return bru;
+          }
+        };
+
+    // Do a batch update, expect that it succeeds due to retrying despite the LOCK_FAILURE on the
+    // first attempt.
+    assertThat(doneBackgroundUpdate.get()).isFalse();
+
+    @SuppressWarnings("unused")
+    var unused =
+        retryHelper
+            .changeUpdate(
+                "batchUpdate",
+                updateFactory -> {
+                  try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.now())) {
+                    bu.addOp(changeId, abandonOpFactory.create(null, "abandon"));
+                    bu.execute(listener);
+                  }
+                  return null;
+                })
+            .call();
+
+    // Check that the concurrent update was done.
+    assertThat(doneBackgroundUpdate.get()).isTrue();
+
+    // Check that the BatchUpdate updated the change.
+    notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.ABANDONED);
+  }
+
+  @Test
+  public void noRetryingOnOuterLevelIfRetryingWasAlreadyDoneOnInnerLevel() throws Exception {
+    Change.Id changeId = createChange();
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+
+    AtomicBoolean backgroundFailure = new AtomicBoolean(false);
+
+    // Create a listener that updates the change meta ref concurrently on all attempts.
+    BatchUpdateListener listener =
+        new BatchUpdateListener() {
+          @Override
+          public BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) {
+            try (RevWalk rw = new RevWalk(repo.getRepository())) {
+              String changeMetaRef = RefNames.changeMetaRef(changeId);
+              ObjectId metaId = repo.getRepository().exactRef(changeMetaRef).getObjectId();
+              RevCommit old = rw.parseCommit(metaId);
+              RevCommit commit =
+                  repo.commit()
+                      .parent(old)
+                      .author(serverIdent)
+                      .committer(serverIdent)
+                      .setTopLevelTree(old.getTree())
+                      .message("Concurrent Update\n\nPatch-Set: 1")
+                      .create();
+              RefUpdate ru = repo.getRepository().updateRef(changeMetaRef);
+              ru.setExpectedOldObjectId(metaId);
+              ru.setNewObjectId(commit);
+              ru.update();
+              RefUpdateUtil.checkResult(ru);
+            } catch (Exception e) {
+              backgroundFailure.set(true);
+            }
+
+            return bru;
+          }
+        };
+
+    AtomicInteger innerRetryOnExceptionCounter = new AtomicInteger();
+    AtomicInteger outerRetryOnExceptionCounter = new AtomicInteger();
+    UpdateException exception =
+        assertThrows(
+            UpdateException.class,
+            () ->
+                // Outer level retrying. We expect that no retrying is happens here because retrying
+                // is already done on the inner level.
+                retryHelper
+                    .action(
+                        ActionType.CHANGE_UPDATE,
+                        "batchUpdate",
+                        () ->
+                            // Inner level retrying. We expect that retrying happens here.
+                            retryHelper
+                                .changeUpdate(
+                                    "batchUpdate",
+                                    updateFactory -> {
+                                      try (BatchUpdate bu =
+                                          updateFactory.create(
+                                              project, user.get(), TimeUtil.now())) {
+                                        bu.addOp(
+                                            changeId, abandonOpFactory.create(null, "abandon"));
+                                        bu.execute(listener);
+                                      }
+                                      return null;
+                                    })
+                                .listener(
+                                    new RetryListener() {
+                                      @Override
+                                      public <V> void onRetry(Attempt<V> attempt) {
+                                        if (attempt.hasException()) {
+                                          @SuppressWarnings("unused")
+                                          var unused =
+                                              innerRetryOnExceptionCounter.incrementAndGet();
+                                        }
+                                      }
+                                    })
+                                .call())
+                    // give it enough time to potentially retry multiple times when each retry also
+                    // does retrying
+                    .defaultTimeoutMultiplier(5)
+                    .listener(
+                        new RetryListener() {
+                          @Override
+                          public <V> void onRetry(Attempt<V> attempt) {
+                            if (attempt.hasException()) {
+                              @SuppressWarnings("unused")
+                              var unused = outerRetryOnExceptionCounter.incrementAndGet();
+                            }
+                          }
+                        })
+                    .call());
+    assertThat(backgroundFailure.get()).isFalse();
+
+    // Check that retrying was done on the inner level.
+    assertThat(innerRetryOnExceptionCounter.get()).isGreaterThan(1);
+
+    // Check that there was no retrying on the outer level since retrying was already done on the
+    // inner level.
+    // We expect 1 because RetryListener#onRetry is invoked before the rejection predicate and stop
+    // strategies are applied (i.e. before RetryHelper decides whether retrying should be done).
+    assertThat(outerRetryOnExceptionCounter.get()).isEqualTo(1);
+
+    assertThat(exception).hasCauseThat().isInstanceOf(RetryException.class);
+    assertThat(exception.getCause()).hasCauseThat().isInstanceOf(UpdateException.class);
+    assertThat(exception.getCause().getCause())
+        .hasCauseThat()
+        .isInstanceOf(LockFailureException.class);
+
+    // Check that the change was not updated.
+    notes = changeNotesFactory.create(project, changeId);
+    assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW);
+  }
+
   private Change.Id createChange() throws Exception {
     Change.Id id = Change.id(sequences.nextChangeId());
     try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
diff --git a/javatests/com/google/gerrit/server/update/RepoViewTest.java b/javatests/com/google/gerrit/server/update/RepoViewTest.java
index b118c9f..ca4cb96 100644
--- a/javatests/com/google/gerrit/server/update/RepoViewTest.java
+++ b/javatests/com/google/gerrit/server/update/RepoViewTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.update;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
diff --git a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
index 0646669..d9d5214 100644
--- a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
+++ b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.update.context;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.DIRECT_PUSH;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GROUPS_UPDATE;
diff --git a/javatests/com/google/gerrit/testing/BUILD b/javatests/com/google/gerrit/testing/BUILD
index 9443b0d..136938a 100644
--- a/javatests/com/google/gerrit/testing/BUILD
+++ b/javatests/com/google/gerrit/testing/BUILD
@@ -7,6 +7,7 @@
     deps = [
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
         "//lib:jgit",
         "//lib/truth",
     ],
diff --git a/javatests/com/google/gerrit/testing/IndexVersionsTest.java b/javatests/com/google/gerrit/testing/IndexVersionsTest.java
index 0362ddc..3bd3e75 100644
--- a/javatests/com/google/gerrit/testing/IndexVersionsTest.java
+++ b/javatests/com/google/gerrit/testing/IndexVersionsTest.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.testing.IndexVersions.CURRENT;
 import static com.google.gerrit.testing.IndexVersions.PREVIOUS;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import java.util.ArrayList;
 import java.util.List;
@@ -129,7 +130,7 @@
             + SCHEMA_DEF.getSchemas().keySet());
   }
 
-  private static List<Integer> get(String value) {
+  private static ImmutableList<Integer> get(String value) {
     return IndexVersions.get(ChangeSchemaDefinitions.INSTANCE, "test", value);
   }
 
diff --git a/lib/errorprone/BUILD b/lib/errorprone/BUILD
index 456860a..f95a430 100644
--- a/lib/errorprone/BUILD
+++ b/lib/errorprone/BUILD
@@ -3,6 +3,7 @@
 java_library(
     name = "annotations",
     data = ["//lib:LICENSE-Apache2.0"],
+    neverlink = 1,
     visibility = ["//visibility:public"],
     exports = ["@error-prone-annotations//jar"],
 )
diff --git a/lib/lucene/BUILD b/lib/lucene/BUILD
index 93eeccb..6b14a78 100644
--- a/lib/lucene/BUILD
+++ b/lib/lucene/BUILD
@@ -2,51 +2,36 @@
 
 package(default_visibility = ["//visibility:public"])
 
-# Merge jars so
-# META-INF/services/org.apache.lucene.codecs.Codec
-# contains the union of both Codec collections.
-java_binary(
-    name = "lucene-core-and-backward-codecs-merged",
-    data = ["//lib:LICENSE-Apache2.0"],
-    main_class = "NotImportant",
-    runtime_deps = [
-        # in case of conflict, we want the implementation of backwards-codecs
-        # first.
-        "@backward-codecs//jar",
-        "@lucene-core//jar",
-    ],
-)
-
-java_import(
-    name = "lucene-core-and-backward-codecs",
-    jars = [
-        ":lucene-core-and-backward-codecs-merged_deploy.jar",
-    ],
-)
-
 java_library(
     name = "lucene-analyzers-common",
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@lucene-analyzers-common//jar"],
-    runtime_deps = [":lucene-core-and-backward-codecs"],
+    runtime_deps = [":lucene-core"],
+)
+
+java_library(
+    name = "lucene-backward-codecs",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exports = ["@lucene-backward-codecs//jar"],
 )
 
 java_library(
     name = "lucene-core",
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@lucene-core//jar"],
+    runtime_deps = [":lucene-backward-codecs"],
 )
 
 java_library(
     name = "lucene-misc",
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@lucene-misc//jar"],
-    runtime_deps = [":lucene-core-and-backward-codecs"],
+    runtime_deps = [":lucene-core"],
 )
 
 java_library(
     name = "lucene-queryparser",
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@lucene-queryparser//jar"],
-    runtime_deps = [":lucene-core-and-backward-codecs"],
+    runtime_deps = [":lucene-core"],
 )
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 78f4852..6865340 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -11,7 +11,6 @@
 grep 'name = "[^"]*"' ${bzl} | sed 's|^[^"]*"||g;s|".*$||g' | sort > $TMP/names
 
 cat << EOF > $TMP/want
-backward-codecs
 cglib-3_2
 commons-io
 dropwizard-core
@@ -36,6 +35,7 @@
 log-ext
 log4j
 lucene-analyzers-common
+lucene-backward-codecs
 lucene-core
 lucene-misc
 lucene-queryparser
diff --git a/package.json b/package.json
index e671e86..bfbf0c41 100644
--- a/package.json
+++ b/package.json
@@ -11,16 +11,16 @@
   },
   "devDependencies": {
     "@koa/cors": "^3.4.3",
-    "@types/page": "^1.11.6",
+    "@types/page": "^1.11.9",
     "@typescript-eslint/eslint-plugin": "^5.62.0",
     "@web/dev-server": "^0.1.38",
     "@web/dev-server-esbuild": "^0.3.6",
-    "eslint": "^8.49.0",
+    "eslint": "^8.56.0",
     "eslint-config-google": "^0.14.0",
     "eslint-plugin-html": "^7.1.0",
-    "eslint-plugin-import": "^2.28.1",
+    "eslint-plugin-import": "^2.29.1",
     "eslint-plugin-jsdoc": "^44.2.7",
-    "eslint-plugin-lit": "^1.9.1",
+    "eslint-plugin-lit": "^1.11.0",
     "eslint-plugin-node": "^11.1.0",
     "eslint-plugin-prettier": "^4.2.1",
     "eslint-plugin-regex": "^1.10.0",
diff --git a/plugins/delete-project b/plugins/delete-project
index 9303850..ea78b4b 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 93038507ff113042bf09ba96d788af43a5deff28
+Subproject commit ea78b4b817151f47f6e3aca7bf1e90f14518caa1
diff --git a/plugins/hooks b/plugins/hooks
index 3007362..f975f91 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 30073628612bce23826f4be71bfdd159da521cbc
+Subproject commit f975f914312b258f84957d19f96014c3edd12644
diff --git a/plugins/package.json b/plugins/package.json
index 9e92086..dc63a8c 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -3,39 +3,39 @@
   "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
   "browser": true,
   "dependencies": {
-    "@codemirror/autocomplete": "^6.9.1",
-    "@codemirror/commands": "^6.2.5",
+    "@codemirror/autocomplete": "^6.11.1",
+    "@codemirror/commands": "^6.3.3",
     "@codemirror/lang-cpp": "^6.0.2",
     "@codemirror/lang-css": "^6.2.1",
-    "@codemirror/lang-html": "^6.4.6",
+    "@codemirror/lang-html": "^6.4.7",
     "@codemirror/lang-java": "^6.0.1",
     "@codemirror/lang-javascript": "^6.2.1",
     "@codemirror/lang-json": "^6.0.1",
-    "@codemirror/lang-less": "^6.0.0",
-    "@codemirror/lang-markdown": "^6.2.1",
+    "@codemirror/lang-less": "^6.0.2",
+    "@codemirror/lang-markdown": "^6.2.3",
     "@codemirror/lang-php": "^6.0.1",
     "@codemirror/lang-python": "^6.1.3",
     "@codemirror/lang-rust": "^6.0.1",
     "@codemirror/lang-sass": "^6.0.2",
-    "@codemirror/lang-sql": "^6.5.4",
+    "@codemirror/lang-sql": "^6.5.5",
     "@codemirror/lang-xml": "^6.0.2",
     "@codemirror/language": "^6.9.1",
     "@codemirror/language-data": "^6.3.1",
     "@codemirror/legacy-modes": "^6.3.3",
     "@codemirror/lint": "^6.4.2",
-    "@codemirror/search": "^6.5.4",
-    "@codemirror/state": "^6.2.1",
-    "@codemirror/view": "^6.20.2",
+    "@codemirror/search": "^6.5.5",
+    "@codemirror/state": "^6.4.0",
+    "@codemirror/view": "^6.23.0",
     "@gerritcodereview/typescript-api": "3.8.0",
-    "@open-wc/testing": "^3.2.0",
+    "@open-wc/testing": "^3.2.2",
     "@polymer/decorators": "^3.0.0",
     "@polymer/polymer": "^3.5.1",
     "@web/dev-server-esbuild": "^0.3.6",
     "@web/test-runner": "^0.15.3",
-    "lit": "^3.0.0",
+    "lit": "^3.1.0",
     "rxjs": "^6.6.7",
     "sinon": "^13.0.2"
   },
   "license": "Apache-2.0",
   "private": true
-}
\ No newline at end of file
+}
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index ba74d49..cdd2d2d 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit ba74d4969462c2592bcf97868dd76c33041d47b2
+Subproject commit cdd2d2d69666a70a16ac02bacf8e7fbbf4ca9979
diff --git a/plugins/replication b/plugins/replication
index 8fd3c27..56b8ffb 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 8fd3c271ce0a21480e3d04da5ad2112efea3bedf
+Subproject commit 56b8ffbab5bf619c0b6b5d44f0255fd41b9e1c89
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 9321303..18c867b 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 9321303265fcab2ff7f764a444f8c23915747638
+Subproject commit 18c867b6a957b3ddeb7a9e9789819fc60bdcd99a
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 084a372..4bee62c 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 084a37253dc94ac52cfaa1c9d516fcb8b0318b31
+Subproject commit 4bee62cbbc21979b841843dd5faaf79470a35966
diff --git a/plugins/webhooks b/plugins/webhooks
index 1dc0a71..2e5ec3b 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 1dc0a718839f8872a59c189da7243ee77a4fe782
+Subproject commit 2e5ec3b3bcf5e7ba50edba9eca3c15c8057ad6c2
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 21ec522..844c4f1 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -11,11 +11,11 @@
     typical "^7.1.1"
 
 "@babel/code-frame@^7.12.11":
-  version "7.22.13"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
-  integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
+  version "7.23.5"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244"
+  integrity sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==
   dependencies:
-    "@babel/highlight" "^7.22.13"
+    "@babel/highlight" "^7.23.4"
     chalk "^2.4.2"
 
 "@babel/helper-validator-identifier@^7.22.20":
@@ -23,44 +23,44 @@
   resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
   integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
 
-"@babel/highlight@^7.22.13":
-  version "7.22.20"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
-  integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
+"@babel/highlight@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz#edaadf4d8232e1a961432db785091207ead0621b"
+  integrity sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==
   dependencies:
     "@babel/helper-validator-identifier" "^7.22.20"
     chalk "^2.4.2"
     js-tokens "^4.0.0"
 
-"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1", "@codemirror/autocomplete@^6.9.1":
-  version "6.9.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.9.1.tgz#e0989c6a33a37604b5d2c896dcca7562ae3d7c61"
-  integrity sha512-yma56tqD7khIZK4gy4X5lX3/k5ArMiCGat7HEWRF/8L2kqOjVdp2qKZqpcJjwTIjSj6fqKAHqi7IjtH3QFE+Bw==
+"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.11.1", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1":
+  version "6.11.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.11.1.tgz#c733900eee58ac2de817317b9fd1e91b857c4329"
+  integrity sha512-L5UInv8Ffd6BPw0P3EF7JLYAMeEbclY7+6Q11REt8vhih8RuLreKtPy/xk8wPxs4EQgYqzI7cdgpiYwWlbS/ow==
   dependencies:
     "@codemirror/language" "^6.0.0"
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.17.0"
     "@lezer/common" "^1.0.0"
 
-"@codemirror/commands@^6.2.5":
-  version "6.2.5"
-  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.2.5.tgz#e889f93f9cc85b32f6b2844d85d08688f695a6b8"
-  integrity sha512-dSi7ow2P2YgPBZflR9AJoaTHvqmeGIgkhignYMd5zK5y6DANTvxKxp6eMEpIDUJkRAaOY/TFZ4jP1ADIO/GLVA==
+"@codemirror/commands@^6.3.3":
+  version "6.3.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.3.3.tgz#03face5bf5f3de0fc4e09b177b3c91eda2ceb7e9"
+  integrity sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==
   dependencies:
     "@codemirror/language" "^6.0.0"
-    "@codemirror/state" "^6.2.0"
+    "@codemirror/state" "^6.4.0"
     "@codemirror/view" "^6.0.0"
-    "@lezer/common" "^1.0.0"
+    "@lezer/common" "^1.1.0"
 
 "@codemirror/lang-angular@^0.1.0":
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-angular/-/lang-angular-0.1.2.tgz#a3f565297842ad60caf2a0bf6f6137c13d19a666"
-  integrity sha512-Nq7lmx9SU+JyoaRcs6SaJs7uAmW2W06HpgJVQYeZptVGNWDzDvzhjwVb/ZuG1rwTlOocY4Y9GwNOBuKCeJbKtw==
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-angular/-/lang-angular-0.1.3.tgz#83035e7e9e1f0e2ba466e83d778407b519089a28"
+  integrity sha512-xgeWGJQQl1LyStvndWtruUvb4SnBZDAu/gvFH/ZU+c0W25tQR8e5hq7WTwiIY2dNxnf+49mRiGI/9yxIwB6f5w==
   dependencies:
     "@codemirror/lang-html" "^6.0.0"
     "@codemirror/lang-javascript" "^6.1.2"
     "@codemirror/language" "^6.0.0"
-    "@lezer/common" "^1.0.0"
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.3.3"
 
@@ -83,10 +83,10 @@
     "@lezer/common" "^1.0.2"
     "@lezer/css" "^1.0.0"
 
-"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.6":
-  version "6.4.6"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.6.tgz#25c1c71591da80e75dceb67ceeab41e87bde29a5"
-  integrity sha512-E4C8CVupBksXvgLSme/zv31x91g06eZHSph7NczVxZW+/K+3XgJGWNT//2WLzaKSBoxpAjaOi5ZnPU1SHhjh3A==
+"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.7":
+  version "6.4.7"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.7.tgz#e375e3c9ae898b5aca6e17b5055a3a76c7a8f5ff"
+  integrity sha512-y9hWSSO41XlcL4uYwWyk0lEgTHcelWWfRuqmvcAmxfCs0HNWZdriWo/EU43S63SxEZpc1Hd50Itw7ktfQvfkUg==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/lang-css" "^6.0.0"
@@ -127,20 +127,21 @@
     "@codemirror/language" "^6.0.0"
     "@lezer/json" "^1.0.0"
 
-"@codemirror/lang-less@^6.0.0":
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-less/-/lang-less-6.0.1.tgz#fef10e8dbcd07055b815c3928233a05a8549181e"
-  integrity sha512-ABcsKBjLbyPZwPR5gePpc8jEKCQrFF4pby2WlMVdmJOOr7OWwwyz8DZonPx/cKDE00hfoSLc8F7yAcn/d6+rTQ==
+"@codemirror/lang-less@^6.0.0", "@codemirror/lang-less@^6.0.2":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-less/-/lang-less-6.0.2.tgz#2e3d82a3ddb8710e6409689cd4a28c66558d0cb8"
+  integrity sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==
   dependencies:
     "@codemirror/lang-css" "^6.2.0"
     "@codemirror/language" "^6.0.0"
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.2.1":
-  version "6.2.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.2.1.tgz#2d9e14a579e9a17c164902dcc0d771e86a6803d1"
-  integrity sha512-Tpk1+CllQ/KU27AYixsxvtqqQS2xYfLEUiBEyyzO+Y9/0LfI2oB1qM2cMhy0D7oRnG0ZSAy9qcYniZc9VtlIxg==
+"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.2.3":
+  version "6.2.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.2.3.tgz#ce572230a872e8eef88bce40213f26e66a7e4497"
+  integrity sha512-wCewRLWpdefWi7uVkHIDiE8+45Fe4buvMDZkihqEom5uRUQrl76Zb13emjeK3W+8pcRgRfAmwelURBbxNEKCIg==
   dependencies:
     "@codemirror/autocomplete" "^6.7.1"
     "@codemirror/lang-html" "^6.0.0"
@@ -189,35 +190,37 @@
     "@lezer/common" "^1.0.2"
     "@lezer/sass" "^1.0.0"
 
-"@codemirror/lang-sql@^6.0.0", "@codemirror/lang-sql@^6.5.4":
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.5.4.tgz#cb1f0140a22d9d502d93d7b91390c2e0becedce5"
-  integrity sha512-5Gq7fYtT/5HbNyIG7a8vYaqOYQU3JbgtBe3+derkrFUXRVcjkf8WVgz++PIbMFAQsOFMDdDR+uiNM8ZRRuXH+w==
+"@codemirror/lang-sql@^6.0.0", "@codemirror/lang-sql@^6.5.5":
+  version "6.5.5"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.5.5.tgz#85619f4ea6738c07c0241b19c62d8ef86678e672"
+  integrity sha512-DvOaP2RXLb2xlxJxxydTFfwyYw5YDqEFea6aAfgh9UH0kUD6J1KFZ0xPgPpw1eo/5s2w3L6uh5PVR7GM23GxkQ==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/language" "^6.0.0"
     "@codemirror/state" "^6.0.0"
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@codemirror/lang-vue@^0.1.1":
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-vue/-/lang-vue-0.1.2.tgz#50aec87b93ba8a6b0742a24cbab566b3989ee6ca"
-  integrity sha512-D4YrefiRBAr+CfEIM4S3yvGSbYW+N69mttIfGMEf7diHpRbmygDxS+R/5xSqjgtkY6VO6qmUrre1GkRcWeZa9A==
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz#bf79b9152cc18b4903d64c1f67e186ae045c8a97"
+  integrity sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==
   dependencies:
     "@codemirror/lang-html" "^6.0.0"
     "@codemirror/lang-javascript" "^6.1.2"
     "@codemirror/language" "^6.0.0"
-    "@lezer/common" "^1.0.0"
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.3.1"
 
 "@codemirror/lang-wast@^6.0.0":
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-wast/-/lang-wast-6.0.1.tgz#c15bec84548a5e9b0a43fa69fb63631d087d6047"
-  integrity sha512-sQLsqhRjl2MWG3rxZysX+2XAyed48KhLBHLgq9xcKxIJu3npH/G+BIXW5NM5mHeDUjG0jcGh9BcjP0NfMStuzA==
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz#d2b14175e5e80d7878cbbb29e20ec90dc12d3a2b"
+  integrity sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==
   dependencies:
     "@codemirror/language" "^6.0.0"
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
@@ -258,12 +261,12 @@
     "@codemirror/legacy-modes" "^6.1.0"
 
 "@codemirror/language@^6.0.0", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0", "@codemirror/language@^6.9.1":
-  version "6.9.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.9.1.tgz#97e2c3e44cf4ff152add865ed7ecec73868446a4"
-  integrity sha512-lWRP3Y9IUdOms6DXuBpoWwjkR7yRmnS0hKYCbSfPz9v6Em1A1UCRujAkDiCrdYfs1Z0Eu4dGtwovNPStIfkgNA==
+  version "6.10.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.0.tgz#2d0e818716825ee2ed0dacd04595eaa61bae8f23"
+  integrity sha512-2vaNn9aPGCRFKWcHPFksctzJ8yS5p7YoaT+jHpc0UGKzNuAIx4qy6R5wiqbP+heEEdyaABA582mNqSHzSoYdmg==
   dependencies:
     "@codemirror/state" "^6.0.0"
-    "@codemirror/view" "^6.0.0"
+    "@codemirror/view" "^6.23.0"
     "@lezer/common" "^1.1.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
@@ -285,26 +288,26 @@
     "@codemirror/view" "^6.0.0"
     crelt "^1.0.5"
 
-"@codemirror/search@^6.5.4":
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.4.tgz#54005697bf581f7dccbbb4a0c34d3a7aa25a513a"
-  integrity sha512-YoTrvjv9e8EbPs58opjZKyJ3ewFrVSUzQ/4WXlULQLSDDr1nGPJ67mMXFNNVYwdFhybzhrzrtqgHmtpJwIF+8g==
+"@codemirror/search@^6.5.5":
+  version "6.5.5"
+  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.5.tgz#cf97e201da364da2285c2a250167af25bbd2a4a2"
+  integrity sha512-PIEN3Ke1buPod2EHbJsoQwlbpkz30qGZKcnmH1eihq9+bPQx8gelauUwLYaY4vBOuBAuEhmpDLii4rj/uO0yMA==
   dependencies:
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.0.0"
     crelt "^1.0.5"
 
-"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.4", "@codemirror/state@^6.2.0", "@codemirror/state@^6.2.1":
-  version "6.2.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.2.1.tgz#6dc8d8e5abb26b875e3164191872d69a5e85bd73"
-  integrity sha512-RupHSZ8+OjNT38zU9fKH2sv+Dnlr8Eb8sl4NOnnqz95mCFTZUaiRP8Xv5MeeaG0px2b8Bnfe7YGwCV3nsBhbuw==
+"@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0":
+  version "6.4.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.4.0.tgz#8bc3e096c84360b34525a84696a84f86b305363a"
+  integrity sha512-hm8XshYj5Fo30Bb922QX9hXB/bxOAVH+qaqHBzw5TKa72vOeslyGwd4X8M0c1dJ9JqxlaMceOQ8RsL9tC7gU0A==
 
-"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.20.2":
-  version "6.20.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.20.2.tgz#77c8e2801cb8c324740780a9f3ab19a15096a51a"
-  integrity sha512-tZ9F0UZU2P3eTRtgljg3DaCOTn2FIjQU/ktTCjSz9/6he3GHDNxSCDAPidMtF+09r23o0h9H/5U7xibtUuEgdg==
+"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0":
+  version "6.23.0"
+  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.23.0.tgz#8054a2043273abad7f1587d15accb0623e1960ed"
+  integrity sha512-/51px9N4uW8NpuWkyUX+iam5+PM6io2fm+QmRnzwqBy5v/pwGg9T0kILFtYeum8hjuvENtgsGNKluOfqIICmeQ==
   dependencies:
-    "@codemirror/state" "^6.1.4"
+    "@codemirror/state" "^6.4.0"
     style-mod "^4.1.0"
     w3c-keyname "^2.2.4"
 
@@ -441,152 +444,149 @@
   integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
 
 "@jridgewell/trace-mapping@^0.3.12":
-  version "0.3.19"
-  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz#f8a3249862f91be48d3127c3cfe992f79b4b8811"
-  integrity sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==
+  version "0.3.20"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz#72e45707cf240fa6b081d0366f8265b0cd10197f"
+  integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==
   dependencies:
     "@jridgewell/resolve-uri" "^3.1.0"
     "@jridgewell/sourcemap-codec" "^1.4.14"
 
-"@lezer/common@^1.0.0", "@lezer/common@^1.0.2", "@lezer/common@^1.1.0":
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.1.0.tgz#2e5bfe01d7a2ada6056d93c677bba4f1495e098a"
-  integrity sha512-XPIN3cYDXsoJI/oDWoR2tD++juVrhgIago9xyKhZ7IhGlzdDM9QgC8D8saKNCz5pindGcznFr2HBSsEQSWnSjw==
+"@lezer/common@^1.0.0", "@lezer/common@^1.0.2", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.0.tgz#f10493d12c4a196a02ff5fcf5695a516a4039aae"
+  integrity sha512-Wmvlm4q6tRpwiy20TnB3yyLTZim38Tkc50dPY8biQRwqE+ati/wD84rm3N15hikvdT4uSg9phs9ubjvcLmkpKg==
 
 "@lezer/cpp@^1.0.0":
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-1.1.1.tgz#ac0261f48dc3651bfea13fdaeff35f04c9011a7f"
-  integrity sha512-eS1M3L3U2mDowoFVPG7tEp01SWu9/68Nx3HEBgLJVn3N9ku7g5S7WdFv0jzmcTipAyONYfZJ+7x4WRkfdB2Ung==
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-1.1.2.tgz#1db93b09e011e8a7a08c347c9d5b7749971253bf"
+  integrity sha512-macwKtyeUO0EW86r3xWQCzOV9/CF8imJLpJlPv3sDY57cPGeUZ8gXWOWNlJr52TVByMV3PayFQCA5SHEERDmVQ==
   dependencies:
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/css@^1.0.0", "@lezer/css@^1.1.0":
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.1.3.tgz#605495b00fd8a122088becf196a93744cbe817fc"
-  integrity sha512-SjSM4pkQnQdJDVc80LYzEaMiNy9txsFbI7HsMgeVF28NdLaAdHNtQ+kB/QqDUzRBV/75NTXjJ/R5IdC8QQGxMg==
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.1.6.tgz#b887d8f66d3d7b9b61a4c614a0ce923e05eee6dc"
+  integrity sha512-/HhbnfXchRc995VdDH9TBzd1B2CO/A4uhOhELqGjd7Bymgc+tGlb0W9Vp5GA1Otq8Ef4JCXpuKmr4hH3aFny6A==
   dependencies:
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3":
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.1.6.tgz#87e56468c0f43c2a8b3dc7f0b7c2804b34901556"
-  integrity sha512-cmSJYa2us+r3SePpRCjN5ymCqCPv+zyXmDl0ciWtVaNiORT/MxM7ZgOMQZADD0o51qOaOg24qc/zBViOIwAjJg==
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.0.tgz#e5898c3644208b4b589084089dceeea2966f7780"
+  integrity sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==
   dependencies:
     "@lezer/common" "^1.0.0"
 
 "@lezer/html@^1.3.0":
-  version "1.3.6"
-  resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.6.tgz#26a2a17da4e0f91835e36db9ccd025b2ed8d33f7"
-  integrity sha512-Kk9HJARZTc0bAnMQUqbtuhFVsB4AnteR2BFUWfZV7L/x1H0aAKz6YabrfJ2gk/BEgjh9L3hg5O4y2IDZRBdzuQ==
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.8.tgz#e0c8b28f91607787ab6696a1dd802c0c38f679e4"
+  integrity sha512-EXseJ3pUzWxE6XQBQdqWHZqqlGQRSuNMBcLb6mZWS2J2v+QZhOObD+3ZIKIcm59ntTzyor4LqFTb72iJc3k23Q==
   dependencies:
-    "@lezer/common" "^1.0.0"
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/java@^1.0.0":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@lezer/java/-/java-1.0.4.tgz#f31f5af4bfc40475dc886f0e3e2d291889b87d25"
-  integrity sha512-POc53LHf2AuNeRXjqZbXNu88GKj0KZTjjSx0L7tYeXlrEHF+3NAQx+dEwKVuCbkl0ZMtpRy2VsDYOV7KKV0oyg==
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@lezer/java/-/java-1.1.1.tgz#eed8813a5f3eb1a913aa8eaf40d5b20f40dee3d6"
+  integrity sha512-mt3dX13fRlpY7RlWELYRakanXgmwXsLRCrhstrn+c1sZd7jR2xle46/3heoxGd+oHxnuTnpoyXTyxcLJQs9+mQ==
   dependencies:
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/javascript@^1.0.0":
-  version "1.4.7"
-  resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.7.tgz#4ebcce2db6043c07fbe827188c07cb001bc7fe37"
-  integrity sha512-OVWlK0YEi7HM+9JRWtRkir8qvcg0/kVYg2TAMHlVtl6DU1C9yK1waEOLBMztZsV/axRJxsqfJKhzYz+bxZme5g==
+  version "1.4.11"
+  resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.11.tgz#4ca7681e29fda6f960e15d958ee4f4ceaf577223"
+  integrity sha512-B5Y9EJF4BWiMgj4ufxUo2hrORnmMBDrMtR+L7dwIO5pocuSAahG6QBwXR6PbKJOjRywJczU2r2LJPg79ER91TQ==
   dependencies:
     "@lezer/highlight" "^1.1.3"
     "@lezer/lr" "^1.3.0"
 
 "@lezer/json@^1.0.0":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@lezer/json/-/json-1.0.1.tgz#3bf5641f3d1408ec31a5f9b29e4e96c6e3a232e6"
-  integrity sha512-nkVC27qiEZEjySbi6gQRuMwa2sDu2PtfjSgz0A4QF81QyRGm3kb2YRzLcOPcTEtmcwvrX/cej7mlhbwViA4WJw==
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@lezer/json/-/json-1.0.2.tgz#bdc849e174113e2d9a569a5e6fb1a27e2f703eaf"
+  integrity sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==
   dependencies:
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/lr@^1.0.0", "@lezer/lr@^1.1.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.3.1", "@lezer/lr@^1.3.3":
-  version "1.3.12"
-  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.3.12.tgz#ee65d79f5528d8f5c042cd8123325a48c411109b"
-  integrity sha512-5nwY1JzCueUdRtlMBnlf1SUi69iGCq2ABq7WQFQMkn/kxPvoACAEnTp4P17CtXxYr7WCwtYPLL2AEvxKPuF1OQ==
+  version "1.3.14"
+  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.3.14.tgz#59d4a3b25698bdac0ef182fa6eadab445fc4f29a"
+  integrity sha512-z5mY4LStlA3yL7aHT/rqgG614cfcvklS+8oFRFBYrs4YaWLJyKKM4+nN6KopToX0o9Hj6zmH6M5kinOYuy06ug==
   dependencies:
     "@lezer/common" "^1.0.0"
 
 "@lezer/markdown@^1.0.0":
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.1.0.tgz#5cee104ef353a3442ecee023ff1912826fac8658"
-  integrity sha512-JYOI6Lkqbl83semCANkO3CKbKc0pONwinyagBufWBm+k4yhIcqfCF8B8fpEpvJLmIy7CAfwiq7dQ/PzUZA340g==
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.2.0.tgz#387cd5fba85479e3fa1d74586060dc5392c9ccb6"
+  integrity sha512-d7MwsfAukZJo1GpPrcPGa3MxaFFOqNp0gbqF+3F7pTeNDOgeJN1muXzx1XXDPt+Ac+/voCzsH7qXqnn+xReG/g==
   dependencies:
     "@lezer/common" "^1.0.0"
     "@lezer/highlight" "^1.0.0"
 
 "@lezer/php@^1.0.0":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@lezer/php/-/php-1.0.1.tgz#4496b58c980ca710c0433fd743d27e9964fd74ea"
-  integrity sha512-aqdCQJOXJ66De22vzdwnuC502hIaG9EnPK2rSi+ebXyUd+j7GAX1mRjWZOVOmf3GST1YUfUCu6WXDiEgDGOVwA==
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@lezer/php/-/php-1.0.2.tgz#7c291631fc1e7f7efe99977522bc48bdc732658a"
+  integrity sha512-GN7BnqtGRpFyeoKSEqxvGvhJQiI4zkgmYnDk/JIyc7H7Ifc1tkPnUn/R2R8meH3h/aBf5rzjvU8ZQoyiNDtDrA==
   dependencies:
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.1.0"
 
 "@lezer/python@^1.1.4":
-  version "1.1.8"
-  resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.8.tgz#fe8d03d6cbc95a1d5625cffd30d78018ee816633"
-  integrity sha512-1T/XsmeF57ijrjpC0Zmrf9YeO5mn2zC1XeSNrOnc0KB+6PgxJ5m7kWKt0CnwyS74oHQXbJxUUL+QDQJR26c1Gw==
+  version "1.1.10"
+  resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.10.tgz#580160705ef5b557d8829fd2bf8f09dc9a91a0fb"
+  integrity sha512-pvSjn+OWivmA/si/SFeGouHO50xoOZcPIFzf8dql0gRvcfCvLDpVIpnnGFFlB7wa0WDscDLo0NmH+4Tx80nBdQ==
   dependencies:
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/rust@^1.0.0":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@lezer/rust/-/rust-1.0.1.tgz#ac2d7263fe22527e621bb5623929ba6d6c3a29ea"
-  integrity sha512-j+ToFKM6Wpglv3OQ4ebHYdYIMT2dh0ziCCV0rTf47AWiHOVhR0WjaKrBq+yuvDQNEhr5sxPxVI7+naJIgpqcsQ==
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@lezer/rust/-/rust-1.0.2.tgz#cc9a75605d67182a0e799ac40b1965a61dcc6ef0"
+  integrity sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==
   dependencies:
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/sass@^1.0.0":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/@lezer/sass/-/sass-1.0.3.tgz#17e5d27e40979bc8b4aec8d05df0d01f745aedb8"
-  integrity sha512-n4l2nVOB7gWiGU/Cg2IVxpt2Ic9Hgfgy/7gk+p/XJibAsPXs0lSbsfGwQgwsAw9B/euYo3oS6lEFr9WytoqcZg==
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@lezer/sass/-/sass-1.0.4.tgz#5d18c460a4a896145ac49bab8ea7998ac9d9b401"
+  integrity sha512-AqW4myvp73sbMk6y0+gJrMjN5xtqFZzqTftzO3YcO8gSL5d3pymIP3deQllAI8+s1ZoSzH6kD4hsoFLpkD9Kfg==
   dependencies:
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/xml@^1.0.0":
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/@lezer/xml/-/xml-1.0.2.tgz#5c934602d1d3565fdaf04e93b534c8b94f4df2d1"
-  integrity sha512-dlngsWceOtQBMuBPw5wtHpaxdPJ71aVntqjbpGkFtWsp4WtQmCnuTjQGocviymydN6M18fhj6UQX3oiEtSuY7w==
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@lezer/xml/-/xml-1.0.4.tgz#d565dd84af9ec0f620b0bb5f043b1233e63ffb0a"
+  integrity sha512-WmXKb5eX8+rRfZYSNRR5TPee/ZoDgBdVS/rj1VCJGDKa5gNldIctQYibCoFVyNhvZsyL/8nHbZJZPM4gnXN2Vw==
   dependencies:
+    "@lezer/common" "^1.2.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@lit-labs/ssr-dom-shim@^1.0.0", "@lit-labs/ssr-dom-shim@^1.1.0":
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.1.tgz#64df34e2f12e68e78ac57e571d25ec07fa460ca9"
-  integrity sha512-kXOeFbfCm4fFf2A3WwVEeQj55tMZa8c8/f9AKHMobQMkzNUfUj+antR3fRPaZJawsa1aZiP/Da3ndpZrwEe4rQ==
-
-"@lit-labs/ssr-dom-shim@^1.1.2-pre.0":
+"@lit-labs/ssr-dom-shim@^1.1.2":
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz#d693d972974a354034454ec1317eb6afd0b00312"
   integrity sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==
 
-"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.6.0":
-  version "1.6.3"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.3.tgz#25b4eece2592132845d303e091bad9b04cdcfe03"
-  integrity sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==
+"@lit/reactive-element@^1.0.0 || ^2.0.0", "@lit/reactive-element@^2.0.0":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.2.tgz#779ae9d265407daaf7737cb892df5ec2a86e22a0"
+  integrity sha512-SVOwLAWUQg3Ji1egtOt1UiFe4zdDpnWHyc5qctSceJ5XIu0Uc76YmGpIjZgx9YJ0XtdW0Jm507sDvjOu+HnB8w==
   dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.0.0"
-
-"@lit/reactive-element@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.0.tgz#da14a256ac5533873b935840f306d572bac4a2ab"
-  integrity sha512-wn+2+uDcs62ROBmVAwssO4x5xue/uKD3MGGZOXL2sMxReTRIT0JXKyMXeu7gh0aJ4IJNEIG/3aOnUaQvM7BMzQ==
-  dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.1.2-pre.0"
+    "@lit-labs/ssr-dom-shim" "^1.1.2"
 
 "@mdn/browser-compat-data@^4.0.0":
   version "4.2.1"
@@ -614,59 +614,44 @@
     "@nodelib/fs.scandir" "2.1.5"
     fastq "^1.6.0"
 
-"@open-wc/chai-dom-equals@^0.12.36":
-  version "0.12.36"
-  resolved "https://registry.yarnpkg.com/@open-wc/chai-dom-equals/-/chai-dom-equals-0.12.36.tgz#ed0eb56b9e98c4d7f7280facce6215654aae9f4c"
-  integrity sha512-Gt1fa37h4rtWPQGETSU4n1L678NmMi9KwHM1sH+JCGcz45rs8DBPx7MUVeGZ+HxRlbEI5t9LU2RGGv6xT2OlyA==
-  dependencies:
-    "@open-wc/semantic-dom-diff" "^0.13.16"
-    "@types/chai" "^4.1.7"
-
 "@open-wc/dedupe-mixin@^1.4.0":
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz#b3c58f8699b197bb5e923d624c720e67c9f324d6"
   integrity sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==
 
-"@open-wc/scoped-elements@^2.2.0":
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.2.0.tgz#4d65d7ba796c2bb76ef7934068532ca1795ea7b6"
-  integrity sha512-Qe+vWsuVHFzUkdChwlmJGuQf9cA3I+QOsSHULV/6qf6wsqLM2/32svNRH+rbBIMwiPEwzZprZlkvkqQRucYnVA==
+"@open-wc/scoped-elements@^2.2.4":
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.2.4.tgz#081559b62d885ac0ec043546f17f1f680294d500"
+  integrity sha512-12X4F4QGPWcvPbxAiJ4v8wQFCOu+laZHRGfTrkoj+3JzACCtuxHG49YbuqVzQ135QPKCuhP9wA0kpGGEfUegyg==
   dependencies:
-    "@lit/reactive-element" "^1.0.0"
+    "@lit/reactive-element" "^1.0.0 || ^2.0.0"
     "@open-wc/dedupe-mixin" "^1.4.0"
 
-"@open-wc/semantic-dom-diff@^0.13.16":
-  version "0.13.21"
-  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.13.21.tgz#718b9ec5f9a98935fc775e577ad094ae8d8b7dea"
-  integrity sha512-BONpjHcGX2zFa9mfnwBCLEmlDsOHzT+j6Qt1yfK3MzFXFtAykfzFjAgaxPetu0YbBlCfXuMlfxI4vlRGCGMvFg==
-
 "@open-wc/semantic-dom-diff@^0.20.0":
-  version "0.20.0"
-  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.20.0.tgz#3766aa88f67df624db0494adf82c8035216a2493"
-  integrity sha512-qGHl3nkXluXsjpLY9bSZka/cnlrybPtJMs6RjmV/OP4ID7Gcz1uNWQks05pAhptDB1R47G6PQjdwxG8dXl1zGA==
+  version "0.20.1"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.20.1.tgz#b1bb78be455bd99fb034d9baadbb959d7d124030"
+  integrity sha512-mPF/RPT2TU7Dw41LEDdaeP6eyTOWBD4z0+AHP4/d0SbgcfJZVRymlIB6DQmtz0fd2CImIS9kszaMmwMt92HBPA==
   dependencies:
     "@types/chai" "^4.3.1"
-    "@web/test-runner-commands" "^0.7.0"
+    "@web/test-runner-commands" "^0.9.0"
 
-"@open-wc/testing-helpers@^2.3.0":
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.3.0.tgz#6ee88baaf316a6217c43e7ba536cb187d15cb6f4"
-  integrity sha512-wkDipkia/OMWq5Z1KkAgvqNLfIOCiPGrrtfoCKuQje8u7F0Bz9Un44EwBtWcCdYtLc40quWP7XFpFsW8poIfUA==
+"@open-wc/testing-helpers@^2.3.1":
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.3.2.tgz#c2bfa82cedd833608effa2d2367fe9524ddf4434"
+  integrity sha512-uZMGC/C1m5EiwQsff6KMmCW25TYMQlJt4ilAWIjnelWGFg9HPUiLnlFvAas3ESUP+4OXLO8Oft7p4mHvbYvAEQ==
   dependencies:
-    "@open-wc/scoped-elements" "^2.2.0"
-    lit "^2.0.0"
-    lit-html "^2.0.0"
+    "@open-wc/scoped-elements" "^2.2.4"
+    lit "^2.0.0 || ^3.0.0"
+    lit-html "^2.0.0 || ^3.0.0"
 
-"@open-wc/testing@^3.2.0":
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.2.0.tgz#884ca348861a116829ce5657fccff11a1a9a07bd"
-  integrity sha512-9geTbFq8InbcfniPtS8KCfb5sbQ9WE6QMo1Tli8XMnfllnkZok7Az4kTRAskGQeMeQN/I2I//jE5xY/60qhrHg==
+"@open-wc/testing@^3.2.2":
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.2.2.tgz#c952f4b20af0d201cc8cc436c2c3cdd338bf8177"
+  integrity sha512-byN4dJTd6ZyI9mWmI4lVj30uiu+rYvQr93g64Pd7UFBdAUgb02DHLj6fkJ1gjxA6LC/MeFd7K7mOZ4+vKrMptw==
   dependencies:
     "@esm-bundle/chai" "^4.3.4-fix.0"
-    "@open-wc/chai-dom-equals" "^0.12.36"
     "@open-wc/semantic-dom-diff" "^0.20.0"
-    "@open-wc/testing-helpers" "^2.3.0"
-    "@types/chai" "^4.2.11"
+    "@open-wc/testing-helpers" "^2.3.1"
     "@types/chai-dom" "^1.11.0"
     "@types/sinon-chai" "^3.2.3"
     chai-a11y-axe "^1.5.0"
@@ -770,71 +755,71 @@
   integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==
 
 "@types/accepts@*":
-  version "1.3.5"
-  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
-  integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==
+  version "1.3.7"
+  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.7.tgz#3b98b1889d2b2386604c2bbbe62e4fb51e95b265"
+  integrity sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==
   dependencies:
     "@types/node" "*"
 
 "@types/babel__code-frame@^7.0.2":
-  version "7.0.4"
-  resolved "https://registry.yarnpkg.com/@types/babel__code-frame/-/babel__code-frame-7.0.4.tgz#0d14543f70ca91f4d2b0513a60f1eb31432c42e1"
-  integrity sha512-WBxINLlATjvmpCgBbb9tOPrKtcPfu4A/Yz2iRzmdaodfvjAS/Z0WZJClV9/EXvoC9viI3lgUs7B9Uo7G/RmMGg==
+  version "7.0.6"
+  resolved "https://registry.yarnpkg.com/@types/babel__code-frame/-/babel__code-frame-7.0.6.tgz#20a899c0d29fba1ddf5c2156a10a2bda75ee6f29"
+  integrity sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==
 
 "@types/body-parser@*":
-  version "1.19.3"
-  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.3.tgz#fb558014374f7d9e56c8f34bab2042a3a07d25cd"
-  integrity sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==
+  version "1.19.5"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4"
+  integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==
   dependencies:
     "@types/connect" "*"
     "@types/node" "*"
 
 "@types/chai-dom@^1.11.0":
-  version "1.11.1"
-  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-1.11.1.tgz#5f91fb34a612ccef177c70100c7c1b98a684d696"
-  integrity sha512-q+fs4jdKZFDhXOWBehY0jDGCp8nxVe11Ia8MxqlIsJC3Y2JU149PSBYF2li2F3uxJFSAl2Rf8XeLWonHglpcGw==
+  version "1.11.3"
+  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-1.11.3.tgz#1659ace2698cdcd9ed8b2c007876f53e37d9cc89"
+  integrity sha512-EUEZI7uID4ewzxnU7DJXtyvykhQuwe+etJ1wwOiJyQRTH/ifMWKX+ghiXkxCUvNJ6IQDodf0JXhuP6zZcy2qXQ==
   dependencies:
     "@types/chai" "*"
 
-"@types/chai@*", "@types/chai@^4.1.7", "@types/chai@^4.2.11", "@types/chai@^4.2.12", "@types/chai@^4.3.1":
-  version "4.3.6"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.6.tgz#7b489e8baf393d5dd1266fb203ddd4ea941259e6"
-  integrity sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw==
+"@types/chai@*", "@types/chai@^4.2.12", "@types/chai@^4.3.1":
+  version "4.3.11"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.11.tgz#e95050bf79a932cb7305dd130254ccdf9bde671c"
+  integrity sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ==
 
 "@types/co-body@^6.1.0":
-  version "6.1.1"
-  resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.1.tgz#28d253c95cfbe30c8e8c5d69d4c0dbbcffc101c2"
-  integrity sha512-I9A1k7o4m8m6YPYJIGb1JyNTLqRWtSPg1JOZPWlE19w8Su2VRgRVp/SkKftQSwoxWHGUxGbON4jltONMumC8bQ==
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.3.tgz#201796c6389066b400cfcb4e1ec5c3db798265a2"
+  integrity sha512-UhuhrQ5hclX6UJctv5m4Rfp52AfG9o9+d9/HwjxhVB5NjXxr5t9oKgJxN8xRHgr35oo8meUEHUPFWiKg6y71aA==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
 
 "@types/command-line-args@^5.0.0":
-  version "5.2.1"
-  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.1.tgz#233bd1ba687e84ecbec0388e09f9ec9ebf63c55b"
-  integrity sha512-U2OcmS2tj36Yceu+mRuPyUV0ILfau/h5onStcSCzqTENsq0sBiAp2TmaXu1k8fY4McLcPKSYl9FRzn2hx5bI+w==
+  version "5.2.3"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.3.tgz#553ce2fd5acf160b448d307649b38ffc60d39639"
+  integrity sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==
 
 "@types/connect@*":
-  version "3.4.36"
-  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.36.tgz#e511558c15a39cb29bd5357eebb57bd1459cd1ab"
-  integrity sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==
+  version "3.4.38"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858"
+  integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==
   dependencies:
     "@types/node" "*"
 
 "@types/content-disposition@*":
-  version "0.5.6"
-  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.6.tgz#0f5fa03609f308a7a1a57e0b0afe4b95f1d19740"
-  integrity sha512-GmShTb4qA9+HMPPaV2+Up8tJafgi38geFi7vL4qAM7k8BwjoelgHZqEUKJZLvughUw22h6vD/wvwN4IUCaWpDA==
+  version "0.5.8"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.8.tgz#6742a5971f490dc41e59d277eee71361fea0b537"
+  integrity sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==
 
 "@types/convert-source-map@^2.0.0":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-2.0.1.tgz#e72e8a3de9d6fe3d8e43d5c101c346de2ff6abdf"
-  integrity sha512-tm5Eb3AwhibN6ULRaad5TbNO83WoXVZLh2YRGAFH+qWkUz48l9Hu1jc+wJswB7T+ACWAG0cFnTeeQGpwedvlNw==
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-2.0.3.tgz#e586c22ca4af2d670d47d32d7fe365d5c5558695"
+  integrity sha512-ag0BfJLZf6CQz8VIuRIEYQ5Ggwk/82uvTQf27RcpyDNbY0Vw49LIPqAxk5tqYfrCs9xDaIMvl4aj7ZopnYL8bA==
 
 "@types/cookies@*":
-  version "0.7.8"
-  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.8.tgz#16fccd6d58513a9833c527701a90cc96d216bc18"
-  integrity sha512-y6KhF1GtsLERUpqOV+qZJrjUGzc0GE6UTa0b5Z/LZ7Nm2mKSdCXmS6Kdnl7fctPNnMSouHjxqEWI12/YqQfk5w==
+  version "0.7.10"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.10.tgz#c4881dca4dd913420c488508d192496c46eb4fd0"
+  integrity sha512-hmUCjAk2fwZVPPkkPBcI7jGLIR5mg4OVoNMBwU6aVsMm/iNPY7z9/R+x2fSwLt/ZXoGua6C5Zy2k5xOo9jUyhQ==
   dependencies:
     "@types/connect" "*"
     "@types/express" "*"
@@ -842,9 +827,9 @@
     "@types/node" "*"
 
 "@types/debounce@^1.2.0":
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.2.tgz#8a9fd94003d874b56204526e6686b8a57dc4b278"
-  integrity sha512-ow0L7we5RXNQocEO9LNBRJCk/ecBc8M0aTg0DLrlg1nsnKAcjvFmYFUbsxujlrbngRslmKIA4mKoOxIJdUElhw==
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.4.tgz#cb7e85d9ad5ababfac2f27183e8ac8b576b2abb3"
+  integrity sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==
 
 "@types/estree@0.0.39":
   version "0.0.39"
@@ -852,9 +837,9 @@
   integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
 
 "@types/express-serve-static-core@^4.17.33":
-  version "4.17.37"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.37.tgz#7e4b7b59da9142138a2aaa7621f5abedce8c7320"
-  integrity sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg==
+  version "4.17.41"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz#5077defa630c2e8d28aa9ffc2c01c157c305bef6"
+  integrity sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
@@ -862,9 +847,9 @@
     "@types/send" "*"
 
 "@types/express@*":
-  version "4.17.18"
-  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.18.tgz#efabf5c4495c1880df1bdffee604b143b29c4a95"
-  integrity sha512-Sxv8BSLLgsBYmcnGdGjjEjqET2U+AKAdCRODmMiq02FgjwuV75Ut85DRpvFjyw/Mk0vgUOliGRU0UUmuuZHByQ==
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d"
+  integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==
   dependencies:
     "@types/body-parser" "*"
     "@types/express-serve-static-core" "^4.17.33"
@@ -872,50 +857,50 @@
     "@types/serve-static" "*"
 
 "@types/http-assert@*":
-  version "1.5.3"
-  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661"
-  integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==
+  version "1.5.5"
+  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.5.tgz#dfb1063eb7c240ee3d3fe213dac5671cfb6a8dbf"
+  integrity sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g==
 
 "@types/http-errors@*":
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.2.tgz#a86e00bbde8950364f8e7846687259ffcd96e8c2"
-  integrity sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f"
+  integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==
 
 "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.3":
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
-  integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7"
+  integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==
 
 "@types/istanbul-lib-report@*":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686"
-  integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz#53047614ae72e19fc0401d872de3ae2b4ce350bf"
+  integrity sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==
   dependencies:
     "@types/istanbul-lib-coverage" "*"
 
 "@types/istanbul-reports@^3.0.0":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff"
-  integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54"
+  integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==
   dependencies:
     "@types/istanbul-lib-report" "*"
 
 "@types/keygrip@*":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.3.tgz#2286b16ef71d8dea74dab00902ef419a54341bfe"
-  integrity sha512-tfzBBb7OV2PbUfKbG6zRE5UbmtdLVCKT/XT364Z9ny6pXNbd9GnIB6aFYpq2A5lZ6mq9bhXgK6h5MFGNwhMmuQ==
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.6.tgz#1749535181a2a9b02ac04a797550a8787345b740"
+  integrity sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==
 
 "@types/koa-compose@*":
-  version "3.2.6"
-  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.6.tgz#17a077786d0ac5eee04c37a7d6c207b3252f6de9"
-  integrity sha512-PHiciWxH3NRyAaxUdEDE1NIZNfvhgtPlsdkjRPazHC6weqt90Jr0uLhIQs+SDwC8HQ/jnA7UQP6xOqGFB7ugWw==
+  version "3.2.8"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.8.tgz#dec48de1f6b3d87f87320097686a915f1e954b57"
+  integrity sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==
   dependencies:
     "@types/koa" "*"
 
 "@types/koa@*", "@types/koa@^2.11.6":
-  version "2.13.9"
-  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.9.tgz#8d989ac17d7f033475fbe34c4f906c9287c2041a"
-  integrity sha512-tPX3cN1dGrMn+sjCDEiQqXH2AqlPoPd594S/8zxwUm/ZbPsQXKqHPUypr2gjCPhHUc+nDJLduhh5lXI/1olnGQ==
+  version "2.13.12"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.12.tgz#70d87a9061a81909e0ee11ca50168416e8d3e795"
+  integrity sha512-vAo1KuDSYWFDB4Cs80CHvfmzSQWeUb909aQib0C0aFx4sw0K9UZFz2m5jaEP+b3X1+yr904iQiruS0hXi31jbw==
   dependencies:
     "@types/accepts" "*"
     "@types/content-disposition" "*"
@@ -927,14 +912,14 @@
     "@types/node" "*"
 
 "@types/mime@*":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
-  integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45"
+  integrity sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==
 
 "@types/mime@^1":
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
-  integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690"
+  integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==
 
 "@types/mocha@^8.2.0":
   version "8.2.3"
@@ -942,9 +927,11 @@
   integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==
 
 "@types/node@*":
-  version "20.6.5"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.5.tgz#4c6a79adf59a8e8193ac87a0e522605b16587258"
-  integrity sha512-2qGq5LAOTh9izcc0+F+dToFigBWiK1phKPt7rNhOqJSr35y8rlIBjDwGtFSgAI6MGIhjwOVNSQZVdJsZJ2uR1w==
+  version "20.10.6"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.10.6.tgz#a3ec84c22965802bf763da55b2394424f22bfbb5"
+  integrity sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==
+  dependencies:
+    undici-types "~5.26.4"
 
 "@types/parse5@^6.0.1":
   version "6.0.3"
@@ -952,14 +939,14 @@
   integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
 
 "@types/qs@*":
-  version "6.9.8"
-  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.8.tgz#f2a7de3c107b89b441e071d5472e6b726b4adf45"
-  integrity sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==
+  version "6.9.11"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.11.tgz#208d8a30bc507bd82e03ada29e4732ea46a6bbda"
+  integrity sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==
 
 "@types/range-parser@*":
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
-  integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
+  integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
 
 "@types/resolve@1.17.1":
   version "1.17.1"
@@ -969,46 +956,46 @@
     "@types/node" "*"
 
 "@types/send@*":
-  version "0.17.1"
-  resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.1.tgz#ed4932b8a2a805f1fe362a70f4e62d0ac994e301"
-  integrity sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==
+  version "0.17.4"
+  resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a"
+  integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==
   dependencies:
     "@types/mime" "^1"
     "@types/node" "*"
 
 "@types/serve-static@*":
-  version "1.15.2"
-  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.2.tgz#3e5419ecd1e40e7405d34093f10befb43f63381a"
-  integrity sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==
+  version "1.15.5"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.5.tgz#15e67500ec40789a1e8c9defc2d32a896f05b033"
+  integrity sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==
   dependencies:
     "@types/http-errors" "*"
     "@types/mime" "*"
     "@types/node" "*"
 
 "@types/sinon-chai@^3.2.3":
-  version "3.2.9"
-  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.9.tgz#71feb938574bbadcb176c68e5ff1a6014c5e69d4"
-  integrity sha512-/19t63pFYU0ikrdbXKBWj9PCdnKyTd0Qkz0X91Ta081cYsq90OxYdcWwK/dwEoDa6dtXgj2HJfmzgq+QZTHdmQ==
+  version "3.2.12"
+  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.12.tgz#c7cb06bee44a534ec84f3a5534c3a3a46fd779b6"
+  integrity sha512-9y0Gflk3b0+NhQZ/oxGtaAJDvRywCa5sIyaVnounqLvmf93yBF4EgIRspePtkMs3Tr844nCclYMlcCNmLCvjuQ==
   dependencies:
     "@types/chai" "*"
     "@types/sinon" "*"
 
 "@types/sinon@*":
-  version "10.0.16"
-  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.16.tgz#4bf10313bd9aa8eef1e50ec9f4decd3dd455b4d3"
-  integrity sha512-j2Du5SYpXZjJVJtXBokASpPRj+e2z+VUhCPHmM6WMfe3dpHu6iVKJMU6AiBcMp/XTAYnEj6Wc1trJUWwZ0QaAQ==
+  version "17.0.2"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.2.tgz#9a769f67e62b45b7233f1fe01cb1f231d2393e1c"
+  integrity sha512-Zt6heIGsdqERkxctIpvN5Pv3edgBrhoeb3yHyxffd4InN0AX2SVNKSrhdDZKGQICVOxWP/q4DyhpfPNMSrpIiA==
   dependencies:
     "@types/sinonjs__fake-timers" "*"
 
 "@types/sinonjs__fake-timers@*":
-  version "8.1.2"
-  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e"
-  integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==
+  version "8.1.5"
+  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2"
+  integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==
 
 "@types/trusted-types@^2.0.2":
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.4.tgz#2b38784cd16957d3782e8e2b31c03bc1d13b4d65"
-  integrity sha512-IDaobHimLQhjwsQ/NMwRVfa/yL7L/wriQPMhw1ZJall0KX6E1oxk29XMDeilW5qTIg5aoiqf5Udy8U/51aNoQQ==
+  version "2.0.7"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
+  integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
 
 "@types/ws@^7.4.0":
   version "7.4.7"
@@ -1018,9 +1005,9 @@
     "@types/node" "*"
 
 "@types/yauzl@^2.9.1":
-  version "2.10.1"
-  resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.1.tgz#4e8f299f0934d60f36c74f59cb5a8483fd786691"
-  integrity sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==
+  version "2.10.3"
+  resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999"
+  integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==
   dependencies:
     "@types/node" "*"
 
@@ -1031,10 +1018,10 @@
   dependencies:
     errorstacks "^2.2.0"
 
-"@web/browser-logs@^0.3.2":
-  version "0.3.3"
-  resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.3.3.tgz#121e5b662db2707c4b8cd1628d86903f059f5031"
-  integrity sha512-wt8arj0x7ghXbnipgCvLR+xQ90cFg16ae23cFbInCrJvAxvyI22bAtT24W4XOXMPXwWLBVUJwBgBcXo3oKIvDw==
+"@web/browser-logs@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.4.0.tgz#8c4adddac46be02dff1a605312132053b3737d0a"
+  integrity sha512-/EBiDAUCJ2DzZhaFxTPRIznEPeafdLbXShIL6aTu7x73x7ZoxSDv7DGuTsh2rWNMUa4+AKli4UORrpyv6QBOiA==
   dependencies:
     errorstacks "^2.2.0"
 
@@ -1069,14 +1056,14 @@
     picomatch "^2.2.2"
     ws "^7.4.2"
 
-"@web/dev-server-core@^0.5.1":
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.5.2.tgz#27fe5448e587a87272b556b44ce84c6453655cdb"
-  integrity sha512-7YjWmwzM+K5fPvBCXldUIMTK4EnEufi1aWQWinQE81oW1CqzEwmyUNCtnWV9fcPA4kJC4qrpcjWNGF4YDWxuSg==
+"@web/dev-server-core@^0.7.0":
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.7.0.tgz#ffe71dd272ecb73a2b0c1ee23f3fad812780b998"
+  integrity sha512-1FJe6cJ3r0x0ZmxY/FnXVduQD4lKX7QgYhyS6N+VmIpV+tBU4sGRbcrmeoYeY+nlnPa6p2oNuonk3X5ln/W95g==
   dependencies:
     "@types/koa" "^2.11.6"
     "@types/ws" "^7.4.0"
-    "@web/parse5-utils" "^2.0.0"
+    "@web/parse5-utils" "^2.1.0"
     chokidar "^3.4.3"
     clone "^2.1.2"
     es-module-lexer "^1.0.0"
@@ -1144,10 +1131,10 @@
     "@types/parse5" "^6.0.1"
     parse5 "^6.0.1"
 
-"@web/parse5-utils@^2.0.0":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-2.0.1.tgz#11b91417165a838954dcf228383cfd8e1bdaf914"
-  integrity sha512-FQI72BU5CXhpp7gLRskOQGGCcwvagLZnMnDwAfjrxo3pm1KOQzr8Vl+438IGpHV62xvjNdF1pjXwXcf7eekWGw==
+"@web/parse5-utils@^2.1.0":
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-2.1.0.tgz#3d33aca62c66773492f2fba89d23a45f8b57ba4a"
+  integrity sha512-GzfK5disEJ6wEjoPwx8AVNwUe9gYIiwc+x//QYxYDAFKUp4Xb1OJAGLc2l2gVrSQmtPGLKrTRcW90Hv4pEq1qA==
   dependencies:
     "@types/parse5" "^6.0.1"
     parse5 "^6.0.1"
@@ -1170,12 +1157,12 @@
     "@web/test-runner-core" "^0.10.29"
     mkdirp "^1.0.4"
 
-"@web/test-runner-commands@^0.7.0":
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.7.0.tgz#c9693e4e8b05ef06a2102e03ac924bcbf7985312"
-  integrity sha512-3aXeGrkynOdJ5jgZu5ZslcWmWuPVY9/HNdWDUqPyNePG08PKmLV9Ij342ODDL6OVsxF5dvYn1312PhDqu5AQNw==
+"@web/test-runner-commands@^0.9.0":
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.9.0.tgz#ed15a021249948204bb27559eb437ff6ceeee067"
+  integrity sha512-zeLI6QdH0jzzJMDV5O42Pd8WLJtYqovgdt0JdytgHc0d1EpzXDsc7NTCJSImboc2NcayIsWAvvGGeRF69SMMYg==
   dependencies:
-    "@web/test-runner-core" "^0.11.0"
+    "@web/test-runner-core" "^0.13.0"
     mkdirp "^1.0.4"
 
 "@web/test-runner-core@^0.10.20", "@web/test-runner-core@^0.10.29":
@@ -1210,10 +1197,10 @@
     picomatch "^2.2.2"
     source-map "^0.7.3"
 
-"@web/test-runner-core@^0.11.0":
-  version "0.11.4"
-  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.11.4.tgz#590994c3fc69337e2c5411bf11c293dd061cc07a"
-  integrity sha512-E7BsKAP8FAAEsfj4viASjmuaYfOw4UlKP9IFqo4W20eVyd1nbUWU3Amq4Jksh7W/j811qh3VaFNjDfCwklQXMg==
+"@web/test-runner-core@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.13.0.tgz#a3799461002fcb969b0baa100d88be6c1ff504f4"
+  integrity sha512-mUrETPg9n4dHWEk+D46BU3xVhQf+ljT4cG7FSpmF7AIOsXWgWHoaXp6ReeVcEmM5fmznXec2O/apTb9hpGrP3w==
   dependencies:
     "@babel/code-frame" "^7.12.11"
     "@types/babel__code-frame" "^7.0.2"
@@ -1222,8 +1209,8 @@
     "@types/debounce" "^1.2.0"
     "@types/istanbul-lib-coverage" "^2.0.3"
     "@types/istanbul-reports" "^3.0.0"
-    "@web/browser-logs" "^0.3.2"
-    "@web/dev-server-core" "^0.5.1"
+    "@web/browser-logs" "^0.4.0"
+    "@web/dev-server-core" "^0.7.0"
     chokidar "^3.4.3"
     cli-cursor "^3.1.0"
     co-body "^6.1.0"
@@ -1364,9 +1351,9 @@
     lodash "^4.17.14"
 
 axe-core@^4.3.3:
-  version "4.8.2"
-  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.8.2.tgz#2f6f3cde40935825cf4465e3c1c9e77b240ff6ae"
-  integrity sha512-/dlp0fxyM3R8YW7MFzaHWXrf4zzbr0vaYb23VBFCl83R7nWNPg/yaQw2Dc8jzCMmDVLhSdzH8MjrsuIUuvX+6g==
+  version "4.8.3"
+  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.8.3.tgz#205df863dd9917d5979e9435dab4d47692759051"
+  integrity sha512-d5ZQHPSPkF9Tw+yfyDcRoUOc4g/8UloJJe5J8m4L5+c7AtDdjDLRxew/knnI4CxvtdxEUVgWz4x3OIQUIFiMfw==
 
 base64-js@^1.3.1:
   version "1.5.1"
@@ -1426,12 +1413,13 @@
     ylru "^1.2.0"
 
 call-bind@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
-  integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.5.tgz#6fa2b7845ce0ea49bf4d8b9ef64727a2c2e2e513"
+  integrity sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==
   dependencies:
-    function-bind "^1.1.1"
-    get-intrinsic "^1.0.2"
+    function-bind "^1.1.2"
+    get-intrinsic "^1.2.1"
+    set-function-length "^1.1.1"
 
 camelcase@^6.2.0:
   version "6.3.0"
@@ -1598,20 +1586,15 @@
   resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
   integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
 
-convert-source-map@^1.6.0:
-  version "1.9.0"
-  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
-  integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
-
 convert-source-map@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
   integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
 
-cookies@~0.8.0:
-  version "0.8.0"
-  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
-  integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==
+cookies@~0.9.0:
+  version "0.9.1"
+  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.9.1.tgz#3ffed6f60bb4fb5f146feeedba50acc418af67e3"
+  integrity sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==
   dependencies:
     depd "~2.0.0"
     keygrip "~1.1.0"
@@ -1664,6 +1647,15 @@
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
   integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
 
+define-data-property@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.1.tgz#c35f7cd0ab09883480d12ac5cb213715587800b3"
+  integrity sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==
+  dependencies:
+    get-intrinsic "^1.2.1"
+    gopd "^1.0.1"
+    has-property-descriptors "^1.0.0"
+
 define-lazy-prop@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
@@ -1734,14 +1726,14 @@
     once "^1.4.0"
 
 errorstacks@^2.2.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.4.0.tgz#2155674dd9e741aacda3f3b8b967d9c40a4a0baf"
-  integrity sha512-5ecWhU5gt0a5G05nmQcgCxP5HperSMxLDzvWlT5U+ZSKkuDK0rJ3dbCQny6/vSCIXjwrhwSecXBbw1alr295hQ==
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.4.1.tgz#05adf6de1f5b04a66f2c12cc0593e1be2b18cd0f"
+  integrity sha512-jE4i0SMYevwu/xxAuzhly/KTwtj0xDhbzB6m1xPImxTkw8wcCbgarOQPfCVMi5JKVyW7in29pNJCCJrry3Ynnw==
 
 es-module-lexer@^1.0.0:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.3.1.tgz#c1b0dd5ada807a3b3155315911f364dc4e909db1"
-  integrity sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.4.1.tgz#41ea21b43908fe6a287ffcbe4300f790555331f5"
+  integrity sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==
 
 "esbuild@^0.16 || ^0.17":
   version "0.17.19"
@@ -1813,9 +1805,9 @@
     "@types/yauzl" "^2.9.1"
 
 fast-glob@^3.2.9:
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4"
-  integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
+  integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
   dependencies:
     "@nodelib/fs.stat" "^2.0.2"
     "@nodelib/fs.walk" "^1.2.3"
@@ -1824,9 +1816,9 @@
     micromatch "^4.0.4"
 
 fastq@^1.6.0:
-  version "1.15.0"
-  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a"
-  integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==
+  version "1.16.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.16.0.tgz#83b9a9375692db77a822df081edb6a9cf6839320"
+  integrity sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==
   dependencies:
     reusify "^1.0.4"
 
@@ -1866,25 +1858,25 @@
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
   integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
 
-function-bind@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
-  integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
+function-bind@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
+  integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
 
 get-caller-file@^2.0.5:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
-get-intrinsic@^1.0.2:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82"
-  integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==
+get-intrinsic@^1.0.2, get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.2.tgz#281b7622971123e1ef4b3c90fd7539306da93f3b"
+  integrity sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==
   dependencies:
-    function-bind "^1.1.1"
-    has "^1.0.3"
+    function-bind "^1.1.2"
     has-proto "^1.0.1"
     has-symbols "^1.0.3"
+    hasown "^2.0.0"
 
 get-stream@^5.1.0:
   version "5.2.0"
@@ -1917,6 +1909,13 @@
     merge2 "^1.4.1"
     slash "^3.0.0"
 
+gopd@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c"
+  integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==
+  dependencies:
+    get-intrinsic "^1.1.3"
+
 has-flag@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
@@ -1927,6 +1926,13 @@
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
+has-property-descriptors@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz#52ba30b6c5ec87fd89fa574bc1c39125c6f65340"
+  integrity sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==
+  dependencies:
+    get-intrinsic "^1.2.2"
+
 has-proto@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0"
@@ -1944,12 +1950,12 @@
   dependencies:
     has-symbols "^1.0.2"
 
-has@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
-  integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
+hasown@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c"
+  integrity sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==
   dependencies:
-    function-bind "^1.1.1"
+    function-bind "^1.1.2"
 
 html-escaper@^2.0.0:
   version "2.0.2"
@@ -2017,14 +2023,14 @@
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
 ignore@^5.2.0:
-  version "5.2.4"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
-  integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78"
+  integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==
 
 inflation@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f"
-  integrity sha512-m3xv4hJYR2oXw4o4Y5l6P5P16WYmazYof+el6Al3f+YlggGj6qT9kImBAnzDelRALnP5d3h4jGBPKzYCizjZZw==
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.1.0.tgz#9214db11a47e6f756d111c4f9df96971c60f886c"
+  integrity sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==
 
 inherits@2.0.3:
   version "2.0.3"
@@ -2056,11 +2062,11 @@
     builtin-modules "^3.3.0"
 
 is-core-module@^2.13.0:
-  version "2.13.0"
-  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db"
-  integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==
+  version "2.13.1"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384"
+  integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==
   dependencies:
-    has "^1.0.3"
+    hasown "^2.0.0"
 
 is-docker@^2.0.0, is-docker@^2.1.1:
   version "2.2.1"
@@ -2124,9 +2130,9 @@
   integrity sha512-UDdnyGvMajJUWCkib7Cei/dvyJrrvo4FIrsvSFWdPpXSUorzXrDJ0S+X5Q4ZlasfPjca4yqCNNsjbCeiy8FFeg==
 
 istanbul-lib-coverage@^3.0.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3"
-  integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756"
+  integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==
 
 istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1:
   version "3.0.1"
@@ -2200,15 +2206,15 @@
     koa-send "^5.0.0"
 
 koa@^2.13.0:
-  version "2.14.2"
-  resolved "https://registry.yarnpkg.com/koa/-/koa-2.14.2.tgz#a57f925c03931c2b4d94b19d2ebf76d3244863fc"
-  integrity sha512-VFI2bpJaodz6P7x2uyLiX6RLYpZmOJqNmoCst/Yyd7hQlszyPwG/I9CQJ63nOtKSxpt5M7NH67V6nJL2BwCl7g==
+  version "2.15.0"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.15.0.tgz#d24ae1b0ff378bf12eb3df584ab4204e4c12ac2b"
+  integrity sha512-KEL/vU1knsoUvfP4MC4/GthpQrY/p6dzwaaGI6Rt4NQuFqkw3qrvsdYF5pz3wOfi7IGTvMPHC9aZIcUKYFNxsw==
   dependencies:
     accepts "^1.3.5"
     cache-content-type "^1.0.0"
     content-disposition "~0.5.2"
     content-type "^1.0.4"
-    cookies "~0.8.0"
+    cookies "~0.9.0"
     debug "^4.3.2"
     delegates "^1.0.0"
     depd "^2.0.0"
@@ -2236,55 +2242,30 @@
     debug "^2.6.9"
     marky "^1.2.2"
 
-lit-element@^3.3.0:
-  version "3.3.3"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.3.3.tgz#10bc19702b96ef5416cf7a70177255bfb17b3209"
-  integrity sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==
-  dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.1.0"
-    "@lit/reactive-element" "^1.3.0"
-    lit-html "^2.8.0"
-
 lit-element@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.0.0.tgz#8343891bc9159a5fcb7f534914b37f2c0161e036"
-  integrity sha512-N6+f7XgusURHl69DUZU6sTBGlIN+9Ixfs3ykkNDfgfTkDYGGOWwHAYBhDqVswnFGyWgQYR2KiSpu4J76Kccs/A==
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.0.2.tgz#1a519896d5ab7c7be7a8729f400499e38779c093"
+  integrity sha512-/W6WQZUa5VEXwC7H9tbtDMdSs9aWil3Ou8hU6z2cOKWbsm/tXPAcsoaHVEtrDo0zcOIE5GF6QgU55tlGL2Nihg==
   dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.1.2-pre.0"
+    "@lit-labs/ssr-dom-shim" "^1.1.2"
     "@lit/reactive-element" "^2.0.0"
-    lit-html "^3.0.0"
+    lit-html "^3.1.0"
 
-lit-html@^2.0.0, lit-html@^2.8.0:
-  version "2.8.0"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.8.0.tgz#96456a4bb4ee717b9a7d2f94562a16509d39bffa"
-  integrity sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==
+"lit-html@^2.0.0 || ^3.0.0", lit-html@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.1.0.tgz#a7b93dd682073f2e2029656f4e9cd91e8034c196"
+  integrity sha512-FwAjq3iNsaO6SOZXEIpeROlJLUlrbyMkn4iuv4f4u1H40Jw8wkeR/OUXZUHUoiYabGk8Y4Y0F/rgq+R4MrOLmA==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
-lit-html@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.0.0.tgz#77d6776ee488642c74c5575315ef81aa09d24ea9"
-  integrity sha512-DNJIE8dNY0dQF2Gs0sdMNUppMQT2/CvV4OVnSdg7BXAsGqkVwsE5bqQ04POfkYH5dBIuGnJYdFz5fYYyNnOxiA==
-  dependencies:
-    "@types/trusted-types" "^2.0.2"
-
-lit@^2.0.0:
-  version "2.8.0"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.8.0.tgz#4d838ae03059bf9cafa06e5c61d8acc0081e974e"
-  integrity sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==
-  dependencies:
-    "@lit/reactive-element" "^1.6.0"
-    lit-element "^3.3.0"
-    lit-html "^2.8.0"
-
-lit@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-3.0.0.tgz#204bd65935892a73670471e893ee8ca55d2f9a3b"
-  integrity sha512-nQ0teRzU1Kdj++VdmttS2WvIen8M79wChJ6guRKIIym2M3Ansg3Adj9O6yuQh2IpjxiUXlNuS81WKlQ4iL3BmA==
+"lit@^2.0.0 || ^3.0.0", lit@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-3.1.0.tgz#76429b85dc1f5169fed499a0f7e89e2e619010c9"
+  integrity sha512-rzo/hmUqX8zmOdamDAeydfjsGXbbdtAFqMhmocnh2j9aDYqbu0fjXygjCa0T99Od9VQ/2itwaGrjZz/ZELVl7w==
   dependencies:
     "@lit/reactive-element" "^2.0.0"
     lit-element "^4.0.0"
-    lit-html "^3.0.0"
+    lit-html "^3.1.0"
 
 lodash.assignwith@^4.2.0:
   version "4.2.0"
@@ -2423,9 +2404,9 @@
   integrity sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==
 
 nanoid@^3.1.25:
-  version "3.3.6"
-  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
-  integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
+  version "3.3.7"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
+  integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
 
 negotiator@0.6.3:
   version "0.6.3"
@@ -2433,9 +2414,9 @@
   integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
 
 nise@^5.1.1:
-  version "5.1.4"
-  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.4.tgz#491ce7e7307d4ec546f5a659b2efe94a18b4bbc0"
-  integrity sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==
+  version "5.1.5"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.5.tgz#f2aef9536280b6c18940e32ba1fbdc770b8964ee"
+  integrity sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==
   dependencies:
     "@sinonjs/commons" "^2.0.0"
     "@sinonjs/fake-timers" "^10.0.2"
@@ -2456,9 +2437,9 @@
   integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
 
 object-inspect@^1.9.0:
-  version "1.12.3"
-  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
-  integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
+  version "1.13.1"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2"
+  integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==
 
 on-finished@^2.3.0:
   version "2.4.1"
@@ -2565,9 +2546,9 @@
     once "^1.3.1"
 
 punycode@^2.1.1:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
-  integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
+  integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
 
 puppeteer-core@^19.8.1:
   version "19.11.1"
@@ -2638,9 +2619,9 @@
     path-is-absolute "1.0.1"
 
 resolve@^1.19.0:
-  version "1.22.6"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.6.tgz#dd209739eca3aef739c626fea1b4f3c506195362"
-  integrity sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==
+  version "1.22.8"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
+  integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
   dependencies:
     is-core-module "^2.13.0"
     path-parse "^1.0.7"
@@ -2697,6 +2678,16 @@
   dependencies:
     lru-cache "^6.0.0"
 
+set-function-length@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.1.1.tgz#4bc39fafb0307224a33e106a7d35ca1218d659ed"
+  integrity sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==
+  dependencies:
+    define-data-property "^1.1.1"
+    get-intrinsic "^1.2.1"
+    gopd "^1.0.1"
+    has-property-descriptors "^1.0.0"
+
 setprototypeof@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
@@ -2916,9 +2907,9 @@
   integrity sha512-T+tKVNs6Wu7IWiAce5BgMd7OZfNYUndHwc5MknN+UHOudi7sGZzuHdCadllRuqJ3fPtgFtIH9+lt9qRv6lmpfA==
 
 ua-parser-js@^1.0.33:
-  version "1.0.36"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.36.tgz#a9ab6b9bd3a8efb90bb0816674b412717b7c428c"
-  integrity sha512-znuyCIXzl8ciS3+y3fHJI/2OhQIXbXw9MWC/o3qwyR+RGppjZHrM27CGFSKCJXi2Kctiz537iOu2KnXs1lMQhw==
+  version "1.0.37"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f"
+  integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==
 
 unbzip2-stream@1.4.3:
   version "1.4.3"
@@ -2928,6 +2919,11 @@
     buffer "^5.2.1"
     through "^2.3.8"
 
+undici-types@~5.26.4:
+  version "5.26.5"
+  resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
+  integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
+
 unpipe@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
@@ -2939,13 +2935,13 @@
   integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
 
 v8-to-istanbul@^9.0.1:
-  version "9.1.0"
-  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz#1b83ed4e397f58c85c266a570fc2558b5feb9265"
-  integrity sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==
+  version "9.2.0"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad"
+  integrity sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==
   dependencies:
     "@jridgewell/trace-mapping" "^0.3.12"
     "@types/istanbul-lib-coverage" "^2.0.1"
-    convert-source-map "^1.6.0"
+    convert-source-map "^2.0.0"
 
 vary@^1.1.2:
   version "1.1.2"
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index b8c5666..a9d10be 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -29,8 +29,7 @@
 
 ## Installing [Node.js](https://nodejs.org/en/download/) and npm packages
 
-The minimum nodejs version supported is 10.x+. We recommend at least the latest
-LTS (v16 as of October 2022).
+At the time of writing (November 2023) you should use version 18 of nodejs.
 
 ```sh
 # Debian experimental
@@ -38,7 +37,7 @@
 sudo apt-get install npm
 
 # OS X with Homebrew
-brew install node@16
+brew install node@18
 brew install npm
 ```
 
@@ -57,17 +56,8 @@
 # Install yarn package manager
 npm install -g yarn
 
-# Install packages from root-level packages.json
-bazel fetch @npm//:node_modules
-
-# Install packages from polygerrit-ui/app/packages.json
-bazel fetch @ui_npm//:node_modules
-
-# Install packages from polygerrit-ui/packages.json
-bazel fetch @ui_dev_npm//:node_modules
-
-# Install packages from tools/node_tools/packages.json
-bazel fetch @tools_npm//:node_modules
+# Install packages from all packages.json files
+yarn setup
 ```
 
 More information for installing and using nodejs rules can be found here https://bazelbuild.github.io/rules_nodejs/install.html
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index f66c373..914b099 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -85,7 +85,8 @@
   actions?: Action[];
 
   /**
-   * Shown prominently in the change summary below the run chips.
+   * Shown prominently in the change summary below the run chips. Interpreted
+   * as markdown.
    */
   summaryMessage?: string;
 
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 684429a..8630f75 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -34,6 +34,7 @@
   POST_REVERT = 'postrevert',
   ADMIN_MENU_LINKS = 'admin-menu-links',
   SHOW_DIFF = 'showdiff',
+  REPLY_SENT = 'replysent',
 }
 
 export declare interface PluginApi {
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 045aee5..997b8fe8 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -539,6 +539,7 @@
   commit_id?: string;
   context_lines?: ContextLine[];
   source_content_type?: string;
+  fix_suggestions?: FixSuggestionInfo[];
 }
 
 /**
@@ -658,6 +659,7 @@
  */
 export declare interface DownloadSchemeInfo {
   url: string;
+  description?: string;
   is_auth_required: boolean;
   is_auth_supported: boolean;
   commands: string;
@@ -1291,3 +1293,30 @@
 
 // The URL encoded UUID of the comment
 export type UrlEncodedCommentId = BrandType<string, '_urlEncodedCommentId'>;
+
+/**
+ * The FixSuggestionInfo entity represents a suggested fix
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fix-suggestion-info
+ */
+export interface FixSuggestionInfoInput {
+  description: string;
+  replacements: FixReplacementInfo[];
+}
+
+/**
+ * The FixReplacementInfo entity describes how the content of a file should be replaced by another content
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fix-replacement-info
+ */
+export interface FixReplacementInfo {
+  path: string;
+  range: CommentRange;
+  replacement: string;
+}
+// The UUID of the suggested fix.
+export type FixId = BrandType<string, '_fixId'>;
+
+export interface FixSuggestionInfo extends FixSuggestionInfoInput {
+  fix_id: FixId;
+  description: string;
+  replacements: FixReplacementInfo[];
+}
diff --git a/polygerrit-ui/app/api/suggestions.ts b/polygerrit-ui/app/api/suggestions.ts
index 5dedad4..b4158be 100644
--- a/polygerrit-ui/app/api/suggestions.ts
+++ b/polygerrit-ui/app/api/suggestions.ts
@@ -4,7 +4,12 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-import {CommentRange, NumericChangeId, RevisionPatchSetNum} from './rest-api';
+import {
+  ChangeInfo,
+  CommentRange,
+  RevisionPatchSetNum,
+  FixSuggestionInfo,
+} from './rest-api';
 
 export declare interface SuggestionsPluginApi {
   /**
@@ -15,7 +20,7 @@
 
 export declare interface SuggestCodeRequest {
   prompt: string;
-  changeNumber: NumericChangeId;
+  changeInfo: ChangeInfo;
   patchsetNumber: RevisionPatchSetNum;
   filePath: string;
   range?: CommentRange;
@@ -24,10 +29,30 @@
 
 export declare interface SuggestionsProvider {
   /**
-   * Gerrit calls this method when ...
+   * Gerrit calls these methods when ...
    * - ... user types a comment draft
    */
-  suggestCode(commentData: SuggestCodeRequest): Promise<SuggestCodeResponse>;
+  suggestCode?(commentData: SuggestCodeRequest): Promise<SuggestCodeResponse>;
+  suggestFix?(commentData: SuggestCodeRequest): Promise<SuggestedFixResponse>;
+  /**
+   * Gets the title to display on the fix suggestion preview.
+   *
+   * @param fix_suggestions A list of suggested fixes.
+   * @return The title string or empty to use the default title.
+   */
+  getFixSuggestionTitle?(fix_suggestions?: FixSuggestionInfo[]): string;
+  /**
+   * Gets a link to documentation for icon help next to title
+   *
+   * @param fix_suggestions A list of suggested fixes.
+   * @return The documentation URL string or empty to use the default link to
+   * gerrit documentation about fix suggestions.
+   */
+  getDocumentationLink?(fix_suggestions?: FixSuggestionInfo[]): string;
+  /**
+   * List of supported file extensions. If undefined, all file extensions supported.
+   */
+  supportedFileExtensions?: string[];
 }
 
 export declare interface SuggestCodeResponse {
@@ -35,6 +60,11 @@
   suggestions: Suggestion[];
 }
 
+export declare interface SuggestedFixResponse {
+  responseCode: ResponseCode;
+  fix_suggestions: FixSuggestionInfo[];
+}
+
 export declare interface Suggestion {
   replacement: string;
   newRange?: CommentRange;
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 24fb5c0..0fa58f4 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -263,6 +263,7 @@
     email_strategy: EmailStrategy.ATTENTION_SET_ONLY,
     default_base_for_merges: DefaultBase.AUTO_MERGE,
     allow_browser_notifications: false,
+    allow_suggest_code_while_commenting: true,
     diff_page_sidebar: 'NONE',
   };
 }
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index f57afca..37a17ba 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -146,4 +146,14 @@
   GENERATE_SUGGESTION_ENABLED = 'generate_suggestion_enabled',
   // User disabled generating suggestions
   GENERATE_SUGGESTION_DISABLED = 'generate_suggestion_disabled',
+  GENERATE_SUGGESTION_EDITED = 'generate_suggestion_edited',
+  START_REVIEW = 'start-review',
+  CODE_REVIEW_APPROVAL = 'code-review-approval',
+  FILE_LIST_DIFF_COLLAPSED = 'file-list-diff-collapsed',
+  FILE_LIST_DIFF_EXPANDED = 'file-list-diff-expanded',
+  FILE_LIST_ALL_DIFFS_COLLAPSED = 'file-list-all-diffs-collapsed',
+  FILE_LIST_ALL_DIFFS_EXPANDED = 'file-list-all-diffs-expanded',
+  // The very first reporting event with `ChangeId` set when visiting a change
+  // related page. Can be used as a starting point for user journeys.
+  CHANGE_ID_CHANGED = 'change-id-changed',
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index 8393d81..077ac94 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -103,10 +103,27 @@
         :host {
           display: inline-block;
         }
+        div.title-flex,
+        div.value-flex {
+          display: flex;
+          flex-direction: column;
+          justify-content: center;
+        }
         input {
           width: 20em;
+          box-sizing: border-box;
         }
-        gr-autocomplete {
+        div.gr-form-styles section {
+          margin: var(--spacing-m) 0;
+        }
+        div.gr-form-styles span.title {
+          width: 13em;
+        }
+        section .title gr-icon {
+          vertical-align: top;
+        }
+        section .value gr-autocomplete {
+          display: block;
           width: 20em;
         }
       `,
@@ -118,7 +135,9 @@
       <div class="gr-form-styles">
         <div id="form">
           <section>
-            <span class="title">Repository name</span>
+            <div class="title-flex">
+              <span class="title">Repository Name</span>
+            </div>
             <iron-input
               .bindValue=${convertToString(this.repoConfig.name)}
               @bind-value-changed=${this.handleNameBindValueChanged}
@@ -126,8 +145,10 @@
               <input id="repoNameInput" autocomplete="on" />
             </iron-input>
           </section>
-          <section>
-            <span class="title">Default Branch</span>
+          <section ?hidden=${!!this.repoConfig.permissions_only}>
+            <div class="title-flex">
+              <span class="title">Default Branch</span>
+            </div>
             <span class="value">
               <gr-autocomplete
                 id="defaultBranchNameInput"
@@ -139,7 +160,16 @@
             </span>
           </section>
           <section>
-            <span class="title">Rights inherit from</span>
+            <div class="title-flex">
+              <span class="title">
+                <gr-tooltip-content
+                  has-tooltip
+                  title="For inheriting access rights and repository configuration"
+                >
+                  Parent Repository <gr-icon icon="info"></gr-icon>
+                </gr-tooltip-content>
+              </span>
+            </div>
             <span class="value">
               <gr-autocomplete
                 id="rightsInheritFromInput"
@@ -152,13 +182,23 @@
             </span>
           </section>
           <section>
-            <span class="title">Owner</span>
+            <div class="title-flex">
+              <span class="title">
+                <gr-tooltip-content
+                  has-tooltip
+                  title="When the project is created, the 'Owner' access right is automatically assigned to this group."
+                >
+                  Owner Group <gr-icon icon="info"></gr-icon>
+                </gr-tooltip-content>
+              </span>
+            </div>
             <span class="value">
               <gr-autocomplete
                 id="ownerInput"
                 .text=${convertToString(this.repoOwner)}
                 .value=${convertToString(this.repoOwnerId)}
                 .query=${this.queryGroups}
+                .placeholder=${'Optional'}
                 @text-changed=${this.handleOwnerTextChanged}
                 @value-changed=${this.handleOwnerValueChanged}
               >
@@ -166,38 +206,61 @@
             </span>
           </section>
           <section>
-            <span class="title">Create initial empty commit</span>
-            <span class="value">
-              <gr-select
-                id="initialCommit"
-                .bindValue=${this.repoConfig.create_empty_commit}
-                @bind-value-changed=${this
-                  .handleCreateEmptyCommitBindValueChanged}
-              >
-                <select>
-                  <option value="false">False</option>
-                  <option value="true">True</option>
-                </select>
-              </gr-select>
-            </span>
+            <div class="title-flex">
+              <span class="title">
+                <gr-tooltip-content
+                  has-tooltip
+                  title="Choose 'false', if you want to import an existing repo, 'true' otherwise."
+                >
+                  Create Empty Commit <gr-icon icon="info"></gr-icon>
+                </gr-tooltip-content>
+              </span>
+            </div>
+            <div class="value-flex">
+              <span class="value">
+                <gr-select
+                  id="initialCommit"
+                  .bindValue=${this.repoConfig.create_empty_commit}
+                  @bind-value-changed=${this
+                    .handleCreateEmptyCommitBindValueChanged}
+                >
+                  <select>
+                    <option value="false">False</option>
+                    <option value="true">True</option>
+                  </select>
+                </gr-select>
+              </span>
+            </div>
           </section>
           <section>
-            <span class="title"
-              >Only serve as parent for other repositories</span
-            >
-            <span class="value">
-              <gr-select
-                id="parentRepo"
-                .bindValue=${this.repoConfig.permissions_only}
-                @bind-value-changed=${this
-                  .handlePermissionsOnlyBindValueChanged}
-              >
-                <select>
-                  <option value="false">False</option>
-                  <option value="true">True</option>
-                </select>
-              </gr-select>
-            </span>
+            <div class="title-flex">
+              <span class="title">
+                <gr-tooltip-content
+                  has-tooltip
+                  title="Only serve as a parent repository for other repositories
+to inheright access rights and configs.
+If 'true', then you cannot push code to this repo.
+It will only have a 'refs/meta/config' branch."
+                >
+                  Parent Repo Only <gr-icon icon="info"></gr-icon>
+                </gr-tooltip-content>
+              </span>
+            </div>
+            <div class="value-flex">
+              <span class="value">
+                <gr-select
+                  id="parentRepo"
+                  .bindValue=${this.repoConfig.permissions_only}
+                  @bind-value-changed=${this
+                    .handlePermissionsOnlyBindValueChanged}
+                >
+                  <select>
+                    <option value="false">False</option>
+                    <option value="true">True</option>
+                  </select>
+                </gr-select>
+              </span>
+            </div>
           </section>
         </div>
       </div>
@@ -252,8 +315,10 @@
   }
 
   private handleRightsTextChanged(e: ValueChangedEvent) {
-    this.repoConfig.parent = e.detail.value as RepoName;
-    this.requestUpdate();
+    this.repoConfig = {
+      ...this.repoConfig,
+      parent: e.detail.value as RepoName,
+    };
   }
 
   private handleOwnerTextChanged(e: ValueChangedEvent) {
@@ -279,14 +344,18 @@
   }
 
   private handleCreateEmptyCommitBindValueChanged(
-    e: ValueChangedEvent<boolean>
+    e: ValueChangedEvent<string>
   ) {
-    this.repoConfig.create_empty_commit = e.detail.value;
-    this.requestUpdate();
+    this.repoConfig = {
+      ...this.repoConfig,
+      create_empty_commit: e.detail.value === 'true',
+    };
   }
 
-  private handlePermissionsOnlyBindValueChanged(e: ValueChangedEvent<boolean>) {
-    this.repoConfig.permissions_only = e.detail.value;
-    this.requestUpdate();
+  private handlePermissionsOnlyBindValueChanged(e: ValueChangedEvent<string>) {
+    this.repoConfig = {
+      ...this.repoConfig,
+      permissions_only: e.detail.value === 'true',
+    };
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
index bc851a3..289cd03 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
@@ -32,52 +32,101 @@
         <div class="gr-form-styles">
           <div id="form">
             <section>
-              <span class="title"> Repository name </span>
+              <div class="title-flex">
+                <span class="title"> Repository Name </span>
+              </div>
               <iron-input>
                 <input autocomplete="on" id="repoNameInput" />
               </iron-input>
             </section>
             <section>
-              <span class="title"> Default Branch </span>
+              <div class="title-flex">
+                <span class="title"> Default Branch </span>
+              </div>
               <span class="value">
                 <gr-autocomplete id="defaultBranchNameInput"> </gr-autocomplete>
               </span>
             </section>
             <section>
-              <span class="title"> Rights inherit from </span>
+              <div class="title-flex">
+                <span class="title">
+                  <gr-tooltip-content
+                    has-tooltip=""
+                    title="For inheriting access rights and repository configuration"
+                  >
+                    Parent Repository
+                    <gr-icon icon="info"> </gr-icon>
+                  </gr-tooltip-content>
+                </span>
+              </div>
               <span class="value">
                 <gr-autocomplete id="rightsInheritFromInput"> </gr-autocomplete>
               </span>
             </section>
             <section>
-              <span class="title"> Owner </span>
+              <div class="title-flex">
+                <span class="title">
+                  <gr-tooltip-content
+                    has-tooltip=""
+                    title="When the project is created, the 'Owner' access right is automatically assigned to this group."
+                  >
+                    Owner Group
+                    <gr-icon icon="info"> </gr-icon>
+                  </gr-tooltip-content>
+                </span>
+              </div>
               <span class="value">
                 <gr-autocomplete id="ownerInput"> </gr-autocomplete>
               </span>
             </section>
             <section>
-              <span class="title"> Create initial empty commit </span>
-              <span class="value">
-                <gr-select id="initialCommit">
-                  <select>
-                    <option value="false">False</option>
-                    <option value="true">True</option>
-                  </select>
-                </gr-select>
-              </span>
+              <div class="title-flex">
+                <span class="title">
+                  <gr-tooltip-content
+                    has-tooltip=""
+                    title="Choose 'false', if you want to import an existing repo, 'true' otherwise."
+                  >
+                    Create Empty Commit
+                    <gr-icon icon="info"> </gr-icon>
+                  </gr-tooltip-content>
+                </span>
+              </div>
+              <div class="value-flex">
+                <span class="value">
+                  <gr-select id="initialCommit">
+                    <select>
+                      <option value="false">False</option>
+                      <option value="true">True</option>
+                    </select>
+                  </gr-select>
+                </span>
+              </div>
             </section>
             <section>
-              <span class="title">
-                Only serve as parent for other repositories
-              </span>
-              <span class="value">
-                <gr-select id="parentRepo">
-                  <select>
-                    <option value="false">False</option>
-                    <option value="true">True</option>
-                  </select>
-                </gr-select>
-              </span>
+              <div class="title-flex">
+                <span class="title">
+                  <gr-tooltip-content
+                    has-tooltip=""
+                    title="Only serve as a parent repository for other repositories
+to inheright access rights and configs.
+If 'true', then you cannot push code to this repo.
+It will only have a 'refs/meta/config' branch."
+                  >
+                    Parent Repo Only
+                    <gr-icon icon="info"> </gr-icon>
+                  </gr-tooltip-content>
+                </span>
+              </div>
+              <div class="value-flex">
+                <span class="value">
+                  <gr-select id="parentRepo">
+                    <select>
+                      <option value="false">False</option>
+                      <option value="true">True</option>
+                    </select>
+                  </gr-select>
+                </span>
+              </div>
             </section>
           </div>
         </div>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index 7d6d17d..0af614d 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -280,7 +280,7 @@
     filterActive = false
   ) {
     return this.restApiService
-      .getSuggestedAccounts(
+      .queryAccounts(
         input,
         SUGGESTIONS_LIMIT,
         canSee,
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
index a3c7bbd..c16a108 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
@@ -93,7 +93,7 @@
       },
     ];
 
-    stubRestApi('getSuggestedAccounts').callsFake(input => {
+    stubRestApi('queryAccounts').callsFake(input => {
       if (input.startsWith('test')) {
         return Promise.resolve([
           {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index 2809d6e..615528c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -695,7 +695,10 @@
     return this.restApiService
       .setRepoAccessRightsForReview(this.repo, obj)
       .then(change => {
-        this.getNavigation().setUrl(createChangeUrl({change}));
+        // Don't navigate on server error.
+        if (change) {
+          this.getNavigation().setUrl(createChangeUrl({change}));
+        }
       })
       .finally(() => {
         this.modified = false;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 7117944..90277534 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -19,6 +19,7 @@
   ConfigInput,
   MaxObjectSizeLimitInfo,
   PluginParameterToConfigParameterInfoMap,
+  DownloadSchemeInfo,
 } from '../../../types/common';
 import {
   InheritedBooleanInfoConfiguredValue,
@@ -224,13 +225,10 @@
         <fieldset>
           <gr-download-commands
             id="downloadCommands"
-            .commands=${this.computeCommands(
-              this.repo,
-              this.schemesObj,
-              this.selectedScheme
-            )}
+            .commands=${this.computeCommands()}
             .schemes=${this.schemes}
             .selectedScheme=${this.selectedScheme}
+            .description=${this.computeDescription()}
             @selected-scheme-changed=${(e: BindValueChangeEvent) => {
               if (this.loading) return;
               this.selectedScheme = e.detail.value;
@@ -1073,23 +1071,33 @@
     }
   }
 
-  private computeCommands(
-    repo?: RepoName,
-    schemesObj?: SchemesInfoMap,
-    selectedScheme?: string
-  ) {
-    if (!schemesObj || !repo || !selectedScheme) return [];
-    if (!hasOwnProperty(schemesObj, selectedScheme)) return [];
-    const commandObj = schemesObj[selectedScheme].clone_commands;
+  private getSchemeInfo(): DownloadSchemeInfo | undefined {
+    if (!this.schemesObj || !this.repo || !this.selectedScheme) {
+      return undefined;
+    }
+    if (!hasOwnProperty(this.schemesObj, this.selectedScheme)) return undefined;
+    return this.schemesObj[this.selectedScheme];
+  }
+
+  private computeDescription() {
+    const schemeInfo = this.getSchemeInfo();
+    return schemeInfo?.description;
+  }
+
+  private computeCommands() {
+    const schemeInfo = this.getSchemeInfo();
+    if (!this.repo || !schemeInfo) return undefined;
+
+    const commandObj = schemeInfo.clone_commands ?? {};
     const commands = [];
     for (const [title, command] of Object.entries(commandObj)) {
       commands.push({
         title,
         command: command
-          .replace(/\${project}/gi, encodeURI(repo))
+          .replace(/\${project}/gi, encodeURI(this.repo))
           .replace(
             /\${project-base-name}/gi,
-            encodeURI(repo.substring(repo.lastIndexOf('/') + 1))
+            encodeURI(this.repo.substring(this.repo.lastIndexOf('/') + 1))
           ),
       });
     }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
index df7a6ce..15f022b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
@@ -215,7 +215,7 @@
       `Status: ${ProgressStatus.RUNNING}`
     );
 
-    executeChangeAction.resolve({...new Response(), status: 200});
+    executeChangeAction.resolve(new Response());
     await waitUntil(
       () =>
         element.progress.get(1 as NumericChangeId) === ProgressStatus.SUCCESSFUL
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
index af997a9..94ca74a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
@@ -33,6 +33,7 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import './gr-change-list-hashtag-flow';
 import {GrChangeListHashtagFlow} from './gr-change-list-hashtag-flow';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 suite('gr-change-list-hashtag-flow tests', () => {
   let element: GrChangeListHashtagFlow;
@@ -201,7 +202,7 @@
         const promise = mockPromise<Hashtag[]>();
         setChangeHashtagPromises.push(promise);
         setChangeHashtagStub
-          .withArgs(changes[i]._number, sinon.match.any)
+          .withArgs(changes[i]._number, sinon.match.any, sinon.match.any)
           .returns(promise);
       }
       model = new BulkActionsModel(getAppContext().restApiService);
@@ -322,14 +323,17 @@
       assert.deepEqual(setChangeHashtagStub.firstCall.args, [
         changes[0]._number,
         {add: ['hashtag1']},
+        throwingErrorCallback,
       ]);
       assert.deepEqual(setChangeHashtagStub.secondCall.args, [
         changes[1]._number,
         {add: ['hashtag1']},
+        throwingErrorCallback,
       ]);
       assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
         changes[2]._number,
         {add: ['hashtag1']},
+        throwingErrorCallback,
       ]);
       await waitUntilCalled(alertStub, 'alertStub');
       assert.deepEqual(alertStub.lastCall.args[0].detail, {
@@ -346,34 +350,6 @@
       );
     });
 
-    test('shows error when add hashtag fails', async () => {
-      // selects "hashtag1"
-      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
-      await element.updateComplete;
-
-      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
-      await element.updateComplete;
-
-      assert.equal(
-        queryAndAssert(element, '.loadingText').textContent,
-        'Adding hashtag...'
-      );
-
-      await rejectPromises();
-      await element.updateComplete;
-      await waitUntil(() => query(element, '.error') !== undefined);
-
-      assert.equal(
-        queryAndAssert(element, '.error').textContent,
-        'Failed to add'
-      );
-      assert.equal(
-        queryAndAssert(element, 'gr-button#cancel-button').textContent,
-        'Cancel'
-      );
-      assert.isUndefined(query(element, '.loadingText'));
-    });
-
     test('add multiple hashtag from selected change', async () => {
       const alertStub = sinon.stub();
       element.addEventListener('show-alert', alertStub);
@@ -400,14 +376,17 @@
       assert.deepEqual(setChangeHashtagStub.firstCall.args, [
         changes[0]._number,
         {add: ['hashtag1', 'hashtag2']},
+        throwingErrorCallback,
       ]);
       assert.deepEqual(setChangeHashtagStub.secondCall.args, [
         changes[1]._number,
         {add: ['hashtag1', 'hashtag2']},
+        throwingErrorCallback,
       ]);
       assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
         changes[2]._number,
         {add: ['hashtag1', 'hashtag2']},
+        throwingErrorCallback,
       ]);
 
       await waitUntilCalled(alertStub, 'alertStub');
@@ -453,14 +432,17 @@
       assert.deepEqual(setChangeHashtagStub.firstCall.args, [
         changes[0]._number,
         {add: ['foo']},
+        throwingErrorCallback,
       ]);
       assert.deepEqual(setChangeHashtagStub.secondCall.args, [
         changes[1]._number,
         {add: ['foo']},
+        throwingErrorCallback,
       ]);
       assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
         changes[2]._number,
         {add: ['foo']},
+        throwingErrorCallback,
       ]);
 
       await waitUntilCalled(alertStub, 'alertStub');
@@ -515,14 +497,17 @@
       assert.deepEqual(setChangeHashtagStub.firstCall.args, [
         changes[0]._number,
         {add: ['foo']},
+        throwingErrorCallback,
       ]);
       assert.deepEqual(setChangeHashtagStub.secondCall.args, [
         changes[1]._number,
         {add: ['foo']},
+        throwingErrorCallback,
       ]);
       assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
         changes[2]._number,
         {add: ['foo']},
+        throwingErrorCallback,
       ]);
 
       await waitUntilCalled(alertStub, 'alertStub');
@@ -568,6 +553,8 @@
         'Adding hashtag...'
       );
 
+      // Rest api doesn't reject on error by default, but it does in topic flow,
+      // because we specify a throwing callback.
       await rejectPromises();
       await element.updateComplete;
       await waitUntil(() => query(element, '.error') !== undefined);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
index 4a01412..08d3218 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -370,7 +370,14 @@
           change =>
             change.topic && this.selectedExistingTopics.has(change.topic)
         )
-        .map(change => this.restApiService.setChangeTopic(change._number, '')),
+        .map(
+          change =>
+            // With throwing callback guaranteed to be non-null.
+            this.restApiService.removeChangeTopic(
+              change._number,
+              throwingErrorCallback
+            ) as Promise<string>
+        ),
       `${this.selectedChanges[0].topic} removed from changes`,
       'Failed to remove topic'
     );
@@ -384,8 +391,14 @@
     this.loadingText = 'Applying to all';
     const topic = Array.from(this.selectedExistingTopics.values())[0];
     this.trackPromises(
-      this.selectedChanges.map(change =>
-        this.restApiService.setChangeTopic(change._number, topic)
+      this.selectedChanges.map(
+        change =>
+          // With throwing callback guaranteed to be non-null.
+          this.restApiService.setChangeTopic(
+            change._number,
+            topic,
+            throwingErrorCallback
+          ) as Promise<string>
       ),
       `${topic} applied to all changes`,
       'Failed to apply topic'
@@ -403,8 +416,14 @@
     )} added to ${this.topicToAdd}`;
     this.loadingText = loadingText;
     this.trackPromises(
-      this.selectedChanges.map(change =>
-        this.restApiService.setChangeTopic(change._number, this.topicToAdd)
+      this.selectedChanges.map(
+        change =>
+          // With throwing callback guaranteed to be non-null.
+          this.restApiService.setChangeTopic(
+            change._number,
+            this.topicToAdd,
+            throwingErrorCallback
+          ) as Promise<string>
       ),
       alert,
       'Failed to set topic'
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
index 489d8ee..2934772 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
@@ -33,6 +33,7 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import './gr-change-list-topic-flow';
 import {GrChangeListTopicFlow} from './gr-change-list-topic-flow';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 suite('gr-change-list-topic-flow tests', () => {
   let element: GrChangeListTopicFlow;
@@ -193,7 +194,11 @@
         const promise = mockPromise<string>();
         setChangeTopicPromises.push(promise);
         setChangeTopicStub
-          .withArgs(changesWithTopics[i]._number, sinon.match.any)
+          .withArgs(
+            changesWithTopics[i]._number,
+            sinon.match.any,
+            sinon.match.any
+          )
           .returns(promise);
       }
       model = new BulkActionsModel(getAppContext().restApiService);
@@ -339,11 +344,12 @@
       await resolvePromises();
       await element.updateComplete;
 
-      // not called for second change which as a different topic
+      // not called for second change which has a different topic
       assert.isTrue(setChangeTopicStub.calledOnce);
       assert.deepEqual(setChangeTopicStub.firstCall.args, [
         changesWithTopics[0]._number,
         '',
+        throwingErrorCallback,
       ]);
 
       await waitUntilCalled(alertStub, 'alertStub');
@@ -372,15 +378,17 @@
       await resolvePromises();
       await element.updateComplete;
 
-      // not called for second change which as a different topic
+      // also called for second change which has a different topic
       assert.isTrue(setChangeTopicStub.calledTwice);
       assert.deepEqual(setChangeTopicStub.firstCall.args, [
         changesWithTopics[0]._number,
         '',
+        throwingErrorCallback,
       ]);
       assert.deepEqual(setChangeTopicStub.secondCall.args, [
         changesWithTopics[1]._number,
         '',
+        throwingErrorCallback,
       ]);
     });
 
@@ -397,6 +405,8 @@
         'Removing topic...'
       );
 
+      // Rest api doesn't reject on error by default, but it does in topic flow,
+      // because we specify a throwing callback.
       await rejectPromises();
       await element.updateComplete;
 
@@ -454,10 +464,12 @@
       assert.deepEqual(setChangeTopicStub.firstCall.args, [
         changesWithTopics[0]._number,
         'topic1',
+        throwingErrorCallback,
       ]);
       assert.deepEqual(setChangeTopicStub.secondCall.args, [
         changesWithTopics[1]._number,
         'topic1',
+        throwingErrorCallback,
       ]);
 
       await waitUntilCalled(alertStub, 'alertStub');
@@ -510,7 +522,11 @@
         const promise = mockPromise<string>();
         setChangeTopicPromises.push(promise);
         setChangeTopicStub
-          .withArgs(changesWithNoTopics[i]._number, sinon.match.any)
+          .withArgs(
+            changesWithNoTopics[i]._number,
+            sinon.match.any,
+            sinon.match.any
+          )
           .returns(promise);
       }
 
@@ -619,10 +635,12 @@
       assert.deepEqual(setChangeTopicStub.firstCall.args, [
         changesWithNoTopics[0]._number,
         'foo',
+        throwingErrorCallback,
       ]);
       assert.deepEqual(setChangeTopicStub.secondCall.args, [
         changesWithNoTopics[1]._number,
         'foo',
+        throwingErrorCallback,
       ]);
 
       await waitUntilCalled(alertStub, 'alertStub');
@@ -661,6 +679,8 @@
         'Setting topic...'
       );
 
+      // Rest api doesn't reject on error by default, but it does in topic flow,
+      // because we specify a throwing callback.
       await rejectPromises();
       await element.updateComplete;
       await waitUntil(() => query(element, '.error') !== undefined);
@@ -709,10 +729,12 @@
       assert.deepEqual(setChangeTopicStub.firstCall.args, [
         changesWithNoTopics[0]._number,
         'foo',
+        throwingErrorCallback,
       ]);
       assert.deepEqual(setChangeTopicStub.secondCall.args, [
         changesWithNoTopics[1]._number,
         'foo',
+        throwingErrorCallback,
       ]);
 
       await waitUntilCalled(alertStub, 'alertStub');
@@ -725,46 +747,5 @@
         selectedChangeCount: 2,
       });
     });
-
-    test('shows error when setting topic fails', async () => {
-      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves([
-        {...createChange(), topic: 'foo' as TopicName},
-      ]);
-      const alertStub = sinon.stub();
-      element.addEventListener('show-alert', alertStub);
-      const autocomplete = queryAndAssert<GrAutocomplete>(
-        element,
-        'gr-autocomplete'
-      );
-
-      autocomplete.setFocus(true);
-      autocomplete.text = 'foo';
-      await element.updateComplete;
-      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
-      assert.isFalse(
-        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
-      );
-      queryAndAssert<GrButton>(element, '#set-topic-button').click();
-      await element.updateComplete;
-
-      assert.equal(
-        queryAndAssert(element, '.loadingText').textContent,
-        'Setting topic...'
-      );
-
-      await rejectPromises();
-      await element.updateComplete;
-
-      await waitUntil(() => query(element, '.error') !== undefined);
-      assert.equal(
-        queryAndAssert(element, '.error').textContent,
-        'Failed to set topic'
-      );
-      assert.equal(
-        queryAndAssert(element, 'gr-button#cancel-button').textContent,
-        'Cancel'
-      );
-      assert.isUndefined(query(element, '.loadingText'));
-    });
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index dbca42f..81710e8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -32,9 +32,13 @@
 
 @customElement('gr-change-list-view')
 export class GrChangeListView extends LitElement {
-  @query('#prevArrow') protected prevArrow?: HTMLAnchorElement;
+  @query('#prevArrow') protected prevArrow?:
+    | HTMLAnchorElement
+    | HTMLSpanElement;
 
-  @query('#nextArrow') protected nextArrow?: HTMLAnchorElement;
+  @query('#nextArrow') protected nextArrow?:
+    | HTMLAnchorElement
+    | HTMLSpanElement;
 
   // private but used in test
   @state() account?: AccountDetailInfo;
@@ -143,13 +147,17 @@
         gr-repo-header {
           border-bottom: 1px solid var(--border-color);
         }
+        span[disabled] gr-icon {
+          background-color: transparent;
+          color: var(--disabled-foreground);
+          cursor: default;
+        }
         nav {
           align-items: center;
           display: flex;
           height: 3rem;
           justify-content: flex-end;
           margin-right: 20px;
-          color: var(--deemphasized-text-color);
         }
         gr-icon {
           font-size: 1.85rem;
@@ -212,15 +220,39 @@
 
     return html`
       <nav>
-        Page ${this.computePage()} ${this.renderPrevArrow()}
+        ${this.renderPageNums()}${this.renderPrevArrow()}
         ${this.renderNextArrow()}
       </nav>
     `;
   }
 
-  private renderPrevArrow() {
-    if (this.offset === 0) return nothing;
+  private renderPageNums() {
+    if (this.offset === 0 && this.changes.length <= 1) {
+      return html`<span><strong>${this.changes.length}</strong></span>`;
+    }
 
+    const changesCount = this.changes?.length ?? 0;
+    const hasMore = this.changes?.[changesCount - 1]._more_changes;
+
+    return html`<span>
+      <strong
+        >${this.offset + 1}&nbsp;-&nbsp;${this.offset + changesCount}</strong
+      >&nbsp;of&nbsp;<strong
+        >${hasMore ? 'many' : this.offset + changesCount}
+      </strong></span
+    >`;
+  }
+
+  private renderPrevArrow() {
+    const changesCount = this.changes?.length ?? 0;
+    if (changesCount === 0) return nothing;
+
+    const isDisabled = this.offset === 0;
+    if (isDisabled) {
+      return html`<span id="prevArrow" disabled>
+        <gr-icon icon="chevron_left" aria-label="Older"></gr-icon>
+      </span>`;
+    }
     return html`
       <a id="prevArrow" href=${this.computeNavLink(-1)}>
         <gr-icon icon="chevron_left" aria-label="Older"></gr-icon>
@@ -231,8 +263,13 @@
   private renderNextArrow() {
     const changesCount = this.changes?.length ?? 0;
     if (changesCount === 0) return nothing;
-    if (!this.changes?.[changesCount - 1]._more_changes) return nothing;
 
+    const isDisabled = !this.changes?.[changesCount - 1]._more_changes;
+    if (isDisabled) {
+      return html`<span id="nextArrow" disabled>
+        <gr-icon icon="chevron_right" aria-label="Newer"></gr-icon>
+      </span>`;
+    }
     return html`
       <a id="nextArrow" href=${this.computeNavLink(1)}>
         <gr-icon icon="chevron_right" aria-label="Newer"></gr-icon>
@@ -266,13 +303,15 @@
 
   // private but used in test
   handleNextPage() {
-    if (!this.nextArrow || !this.changesPerPage) return;
+    if (this.nextArrow?.hasAttribute('disabled') || !this.changesPerPage)
+      return;
     this.getNavigation().setUrl(this.computeNavLink(1));
   }
 
   // private but used in test
   handlePreviousPage() {
-    if (!this.prevArrow || !this.changesPerPage) return;
+    if (this.prevArrow?.hasAttribute('disabled') || !this.changesPerPage)
+      return;
     this.getNavigation().setUrl(this.computeNavLink(-1));
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index decc253..93a530d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -30,9 +30,10 @@
   });
 
   test('render', async () => {
-    element.changes = Array(25)
+    element.changes = Array(10)
       .fill(0)
       .map(_ => createChange());
+    element.changes[9]._more_changes = true;
     element.changesPerPage = 10;
     element.loading = false;
     await element.updateComplete;
@@ -43,7 +44,19 @@
         <div class="loading" hidden="">Loading...</div>
         <div>
           <gr-change-list> </gr-change-list>
-          <nav>Page 1</nav>
+          <nav>
+            <span>
+              <strong>1&nbsp;-&nbsp;10</strong>&nbsp;of&nbsp;<strong
+                >many</strong
+              >
+            </span>
+            <span disabled="" id="prevArrow">
+              <gr-icon aria-label="Older" icon="chevron_left"> </gr-icon>
+            </span>
+            <a href="/q/test-query,10" id="nextArrow">
+              <gr-icon aria-label="Newer" icon="chevron_right"> </gr-icon>
+            </a>
+          </nav>
         </div>
       `
     );
@@ -136,11 +149,13 @@
     element.offset = 0;
     element.loading = false;
     await element.updateComplete;
-    assert.isNotOk(query(element, '#prevArrow'));
+    assert.isTrue(
+      query<HTMLAnchorElement>(element, '#prevArrow')?.hasAttribute('disabled')
+    );
 
     element.offset = 5;
     await element.updateComplete;
-    assert.isOk(query(element, '#prevArrow'));
+    assert.isFalse(query(element, '#prevArrow')?.hasAttribute('disabled'));
   });
 
   test('nextArrow', async () => {
@@ -149,13 +164,13 @@
       .map(_ => ({...createChange(), _more_changes: true} as ChangeInfo));
     element.loading = false;
     await element.updateComplete;
-    assert.isOk(query(element, '#nextArrow'));
+    assert.isFalse(query(element, '#nextArrow')?.hasAttribute('disabled'));
 
     element.changes = Array(25)
       .fill(0)
       .map(_ => createChange());
     await element.updateComplete;
-    assert.isNotOk(query(element, '#nextArrow'));
+    assert.isTrue(query(element, '#nextArrow')?.hasAttribute('disabled'));
   });
 
   test('handleNextPage', async () => {
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 31c2b660..50ec339 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -42,6 +42,7 @@
   ChangeActionDialog,
   ChangeInfo,
   CherryPickInput,
+  CommentThread,
   CommitId,
   InheritedBooleanInfo,
   isDetailedLabelInfo,
@@ -115,6 +116,9 @@
 import {userModelToken} from '../../../models/user/user-model';
 import {ParsedChangeInfo} from '../../../types/types';
 import {configModelToken} from '../../../models/config/config-model';
+import {readJSONResponsePayload} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {when} from 'lit/directives/when.js';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -313,7 +317,6 @@
 interface OverflowAction {
   type: ActionType;
   key: string;
-  overflow?: boolean;
 }
 
 interface ActionPriorityOverride {
@@ -366,19 +369,12 @@
 
   @query('#confirmDeleteEditDialog') confirmDeleteEditDialog?: GrDialog;
 
+  @query('#confirmPublishEditDialog') confirmPublishEditDialog?: GrDialog;
+
   @query('#moreActions') moreActions?: GrDropdown;
 
   @query('#secondaryActions') secondaryActions?: HTMLElement;
 
-  // TODO(TS): Ensure that ActionType, ChangeActions and RevisionActions
-  // properties are replaced with enums everywhere and remove them from
-  // the GrChangeActions class
-  ActionType = ActionType;
-
-  ChangeActions = ChangeActions;
-
-  RevisionActions = RevisionActions;
-
   @state() change?: ParsedChangeInfo;
 
   @state() actions: ActionNameToActionInfoMap = {};
@@ -396,22 +392,20 @@
 
   @state() changeStatus?: ChangeStatus;
 
+  @state() mergeable?: boolean;
+
   @state() commitNum?: CommitId;
 
   @state() latestPatchNum?: PatchSetNumber;
 
   @state() commitMessage = '';
 
-  @state() revisionActions: ActionNameToActionInfoMap = {};
-
-  @state() revisionSubmitAction?: ActionInfo | null;
-
-  @state() revisionRebaseAction?: ActionInfo | null;
+  // The unfiltered result of calling `restApiService.getChangeRevisionActions()`.
+  // The DOWNLOAD action is also added to it in `actionsChanged()`.
+  @state() revisionActions?: ActionNameToActionInfoMap;
 
   @state() privateByDefault?: InheritedBooleanInfo;
 
-  @state() loading = true;
-
   @state() actionLoadingMessage = '';
 
   @state() inProgressActionKeys = new Set<string>();
@@ -489,6 +483,10 @@
 
   @state() loggedIn = false;
 
+  @state() pluginsLoaded = false;
+
+  @state() threadsWithSuggestions?: CommentThread[];
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
@@ -505,6 +503,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
   constructor() {
     super();
     subscribe(
@@ -539,6 +539,11 @@
     );
     subscribe(
       this,
+      () => this.getChangeModel().mergeable$,
+      x => (this.mergeable = x)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().editMode$,
       x => (this.editMode = x)
     );
@@ -564,9 +569,19 @@
     );
     subscribe(
       this,
+      () => this.getPluginLoader().pluginsModel.pluginsLoaded$,
+      x => (this.pluginsLoaded = x)
+    );
+    subscribe(
+      this,
       () => this.getConfigModel().repoConfig$,
       config => (this.privateByDefault = config?.private_by_default)
     );
+    subscribe(
+      this,
+      () => this.getCommentsModel().threadsWithSuggestions$,
+      x => (this.threadsWithSuggestions = x)
+    );
   }
 
   override connectedCallback() {
@@ -575,7 +590,6 @@
       TargetElement.CHANGE_ACTIONS,
       this
     );
-    this.handleLoadingComplete();
   }
 
   static override get styles() {
@@ -620,6 +634,15 @@
         .hidden {
           display: none;
         }
+        .info {
+          background-color: var(--info-background);
+          padding: var(--spacing-l) var(--spacing-xl);
+          margin-bottom: var(--spacing-l);
+        }
+        .info gr-icon {
+          color: var(--selected-foreground);
+          margin-right: var(--spacing-xl);
+        }
         @media screen and (max-width: 50em) {
           #mainContent {
             flex-wrap: wrap;
@@ -653,7 +676,7 @@
         </span>
         <section
           id="primaryActions"
-          ?hidden=${this.loading ||
+          ?hidden=${this.isLoading() ||
           !this.topLevelActions ||
           !this.topLevelActions.length}
         >
@@ -663,7 +686,7 @@
         </section>
         <section
           id="secondaryActions"
-          ?hidden=${this.loading ||
+          ?hidden=${this.isLoading() ||
           !this.topLevelActions ||
           !this.topLevelActions.length}
         >
@@ -671,14 +694,14 @@
             this.renderUIAction(action)
           )}
         </section>
-        <gr-button ?hidden=${!this.loading}>Loading actions...</gr-button>
+        <gr-button ?hidden=${!this.isLoading()}>Loading actions...</gr-button>
         <gr-dropdown
           id="moreActions"
           link
           .verticalOffset=${32}
           .horizontalAlign=${'right'}
           @tap-item=${this.handleOverflowItemTap}
-          ?hidden=${this.loading ||
+          ?hidden=${this.isLoading() ||
           !this.menuActions ||
           !this.menuActions.length}
           .disabledIds=${this.disabledMenuActions}
@@ -698,9 +721,7 @@
             RevisionActions.REBASE
           )}
           .branch=${this.change?.branch}
-          .rebaseOnCurrent=${this.revisionRebaseAction
-            ? !!this.revisionRebaseAction.enabled
-            : null}
+          .rebaseOnCurrent=${!!this.revisionActions?.rebase?.enabled}
         ></gr-confirm-rebase-dialog>
         <gr-confirm-cherrypick-dialog
           id="confirmCherrypick"
@@ -740,7 +761,7 @@
         <gr-confirm-submit-dialog
           id="confirmSubmitDialog"
           class="confirmDialog"
-          .action=${this.revisionSubmitAction}
+          .action=${this.revisionActions?.submit}
           @cancel=${this.handleConfirmDialogCancel}
           @confirm=${this.handleSubmitConfirm}
         ></gr-confirm-submit-dialog>
@@ -788,6 +809,27 @@
             Do you really want to delete the edit?
           </div>
         </gr-dialog>
+        <gr-dialog
+          id="confirmPublishEditDialog"
+          class="confirmDialog"
+          confirm-label="Publish"
+          confirm-on-enter=""
+          @cancel=${this.handleConfirmDialogCancel}
+          @confirm=${this.handlePublishEditConfirm}
+        >
+          <div class="header" slot="header">Publish Change Edit</div>
+          <div class="main" slot="main">
+            ${when(
+              this.numberOfThreadsWithSuggestions() > 0,
+              () => html`<p class="info">
+                <gr-icon id="icon" icon="info" small></gr-icon>
+                Heads Up! ${this.numberOfThreadsWithSuggestions()} comments have
+                suggestions you can apply before publishing
+              </p>`
+            )}
+            Do you really want to publish the edit?
+          </div>
+        </gr-dialog>
       </dialog>
     `;
   }
@@ -834,7 +876,7 @@
     this.topLevelActions = this.allActionValues.filter(a => {
       if (this.hiddenActions.includes(a.__key)) return false;
       if (this.editMode) return EDIT_ACTIONS.has(a.__key);
-      return this.getActionOverflowIndex(a.__type, a.__key) === -1;
+      return !this.isOverflowAction(a.__type, a.__key);
     });
     this.topLevelPrimaryActions = this.topLevelActions.filter(
       action => action.__primary
@@ -843,31 +885,6 @@
       action => !action.__primary
     );
     this.menuActions = this.computeMenuActions();
-    this.revisionSubmitAction = this.getSubmitAction(this.revisionActions);
-    this.revisionRebaseAction = this.getRebaseAction(this.revisionActions);
-  }
-
-  private getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
-    return this.getRevisionAction(revisionActions, 'submit');
-  }
-
-  private getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
-    return this.getRevisionAction(revisionActions, 'rebase');
-  }
-
-  private getRevisionAction(
-    revisionActions: ActionNameToActionInfoMap,
-    actionName: string
-  ) {
-    if (!revisionActions) {
-      return undefined;
-    }
-    if (revisionActions[actionName] === undefined) {
-      // Return null to fire an event when revisionActions was loaded
-      // but doesn't contain actionName. undefined doesn't fire an event
-      return null;
-    }
-    return revisionActions[actionName];
   }
 
   reload() {
@@ -875,33 +892,29 @@
       return Promise.resolve();
     }
     const change = this.change;
-
-    this.loading = true;
+    this.revisionActions = undefined;
     return this.restApiService
       .getChangeRevisionActions(this.changeNum, this.latestPatchNum)
       .then(revisionActions => {
-        if (!revisionActions) {
-          return;
-        }
-
-        this.revisionActions = revisionActions;
+        this.revisionActions = revisionActions ?? {};
         this.sendShowRevisionActions({
           change: change as ChangeInfo,
-          revisionActions,
+          revisionActions: this.revisionActions,
         });
-        this.handleLoadingComplete();
       })
       .catch(err => {
         fireAlert(this, ERR_REVISION_ACTIONS);
-        this.loading = false;
         throw err;
       });
   }
 
-  private handleLoadingComplete() {
-    this.getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => (this.loading = false));
+  private isLoading() {
+    return (
+      !this.pluginsLoaded ||
+      !this.change ||
+      this.mergeable === undefined ||
+      this.revisionActions === undefined
+    );
   }
 
   // private but used in test
@@ -942,22 +955,25 @@
     this.requestUpdate('additionalActions');
   }
 
+  // TODO: Rename to toggleOverflow().
   setActionOverflow(type: ActionType, key: string, overflow: boolean) {
     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
       throw Error(`Invalid action type given: ${type}`);
     }
-    const index = this.getActionOverflowIndex(type, key);
-    const action: OverflowAction = {
-      type,
-      key,
-      overflow,
-    };
-    if (!overflow && index !== -1) {
-      this.overflowActions.splice(index, 1);
-      this.requestUpdate('overflowActions');
-    } else if (overflow) {
-      this.overflowActions.push(action);
-      this.requestUpdate('overflowActions');
+    const isCurrentlyOverflow = this.isOverflowAction(type, key);
+    if (overflow === isCurrentlyOverflow) {
+      return;
+    }
+
+    // remove from overflowActions
+    if (!overflow) {
+      this.overflowActions = this.overflowActions.filter(
+        action => action.type !== type || action.key !== key
+      );
+    }
+    // add to overflowActions
+    if (overflow) {
+      this.overflowActions = [...this.overflowActions, {type, key}];
     }
   }
 
@@ -1006,9 +1022,9 @@
   }
 
   getActionDetails(actionName: string) {
-    if (this.revisionActions[actionName]) {
+    if (this.revisionActions?.[actionName]) {
       return this.revisionActions[actionName];
-    } else if (this.actions[actionName]) {
+    } else if (this.actions?.[actionName]) {
       return this.actions[actionName];
     } else {
       return undefined;
@@ -1028,7 +1044,7 @@
     this.actionLoadingMessage = '';
     this.disabledMenuActions = [];
 
-    if (!this.revisionActions.download) {
+    if (this.revisionActions && !this.revisionActions.download) {
       this.revisionActions = {
         ...this.revisionActions,
         download: DOWNLOAD_ACTION,
@@ -1228,7 +1244,7 @@
   }
 
   private getActionValues(
-    actionsChange: ActionNameToActionInfoMap,
+    actionsChange: ActionNameToActionInfoMap | undefined,
     primariesChange: PrimaryActionKey[],
     additionalActionsChange: UIActionInfo[],
     type: ActionType
@@ -1524,7 +1540,7 @@
       default:
         this.fireAction(
           this.prependSlash(key),
-          assertUIActionInfo(this.revisionActions[key]),
+          assertUIActionInfo(this.revisionActions?.[key]),
           true
         );
     }
@@ -1568,7 +1584,7 @@
     const rebaseChain = !!e.detail.rebaseChain;
     this.fireAction(
       rebaseChain ? '/rebase:chain' : '/rebase',
-      assertUIActionInfo(this.revisionActions.rebase),
+      assertUIActionInfo(this.revisionActions?.rebase),
       rebaseChain ? false : true,
       payload,
       {
@@ -1604,7 +1620,7 @@
     el.hidden = true;
     this.fireAction(
       '/cherrypick',
-      assertUIActionInfo(this.revisionActions.cherrypick),
+      assertUIActionInfo(this.revisionActions?.cherrypick),
       true,
       {
         destination: el.branch,
@@ -1719,6 +1735,23 @@
     );
   }
 
+  private handlePublishEditConfirm() {
+    this.hideAllDialogs();
+
+    if (!this.actions.publishEdit) return;
+
+    // We need to make sure that all cached version of a change
+    // edit are deleted.
+    this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
+
+    this.fireAction(
+      '/edit:publish',
+      assertUIActionInfo(this.actions.publishEdit),
+      false,
+      {notify: NotifyType.NONE}
+    );
+  }
+
   // private but used in test
   handleSubmitConfirm() {
     if (!this.canSubmitChange()) {
@@ -1727,13 +1760,13 @@
     this.hideAllDialogs();
     this.fireAction(
       '/submit',
-      assertUIActionInfo(this.revisionActions.submit),
+      assertUIActionInfo(this.revisionActions?.submit),
       true
     );
   }
 
-  private getActionOverflowIndex(type: string, key: string) {
-    return this.overflowActions.findIndex(
+  private isOverflowAction(type: string, key: string) {
+    return this.overflowActions.some(
       action => action.type === type && action.key === key
     );
   }
@@ -1751,8 +1784,7 @@
       buttonKey = ChangeActions.REVERT;
     }
 
-    // If the action appears in the overflow menu.
-    if (this.getActionOverflowIndex(action.__type, buttonKey) !== -1) {
+    if (this.isOverflowAction(action.__type, buttonKey)) {
       this.disabledMenuActions.push(buttonKey === '/' ? 'delete' : buttonKey);
       this.requestUpdate('disabledMenuActions');
       return () => {
@@ -1833,16 +1865,15 @@
   }
 
   // private but used in test
-  async handleResponse(action: UIActionInfo, response?: Response) {
-    if (!response) {
+  async handleResponse(action: UIActionInfo, response: Response | undefined) {
+    if (!response?.ok) {
       return;
     }
-    // response is guaranteed to be ok (due to semantics of rest-api methods)
-    const obj = await this.restApiService.getResponseObject(response);
     switch (action.__key) {
       case ChangeActions.REVERT: {
-        const revertChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
-        this.restApiService.setInProjectLookup(
+        const revertChangeInfo = (await readJSONResponsePayload(response))
+          .parsed as unknown as ChangeInfo;
+        this.restApiService.addRepoNameToCache(
           revertChangeInfo._number,
           revertChangeInfo.project
         );
@@ -1857,8 +1888,9 @@
         break;
       }
       case RevisionActions.CHERRYPICK: {
-        const cherrypickChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
-        this.restApiService.setInProjectLookup(
+        const cherrypickChangeInfo = (await readJSONResponsePayload(response))
+          .parsed as unknown as ChangeInfo;
+        this.restApiService.addRepoNameToCache(
           cherrypickChangeInfo._number,
           cherrypickChangeInfo.project
         );
@@ -1888,7 +1920,8 @@
         this.getChangeModel().navigateToChangeResetReload();
         break;
       case ChangeActions.REVERT_SUBMISSION: {
-        const revertSubmistionInfo = obj as unknown as RevertSubmissionInfo;
+        const revertSubmistionInfo = (await readJSONResponsePayload(response))
+          .parsed as unknown as RevertSubmissionInfo;
         if (
           !revertSubmistionInfo.revert_changes ||
           !revertSubmistionInfo.revert_changes.length
@@ -2072,18 +2105,8 @@
   }
 
   private handlePublishEditTap() {
-    if (!this.actions.publishEdit) return;
-
-    // We need to make sure that all cached version of a change
-    // edit are deleted.
-    this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
-
-    this.fireAction(
-      '/edit:publish',
-      assertUIActionInfo(this.actions.publishEdit),
-      false,
-      {notify: NotifyType.NONE}
-    );
+    assertIsDefined(this.confirmPublishEditDialog, 'confirmPublishEditDialog');
+    this.showActionDialog(this.confirmPublishEditDialog);
   }
 
   private handleRebaseEditTap() {
@@ -2180,7 +2203,7 @@
   private computeMenuActions(): MenuAction[] {
     return this.allActionValues
       .filter(a => {
-        const overflow = this.getActionOverflowIndex(a.__type, a.__key) !== -1;
+        const overflow = this.isOverflowAction(a.__type, a.__key);
         return overflow && !this.hiddenActions.includes(a.__key);
       })
       .map(action => {
@@ -2240,6 +2263,11 @@
   private handleStopEditTap() {
     fireNoBubbleNoCompose(this, 'stop-edit-tap', {});
   }
+
+  private numberOfThreadsWithSuggestions() {
+    if (!this.threadsWithSuggestions) return 0;
+    return this.threadsWithSuggestions.length;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index b953eec..a926f26 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
@@ -17,6 +17,7 @@
 } from '../../../test/test-data-generators';
 import {ChangeStatus, HttpMethod} from '../../../constants/constants';
 import {
+  makePrefixedJSON,
   mockPromise,
   query,
   queryAll,
@@ -39,7 +40,7 @@
   ReviewInput,
   TopicName,
 } from '../../../types/common';
-import {ActionType} from '../../../api/change-actions';
+import {ActionType, RevisionActions} from '../../../api/change-actions';
 import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -98,29 +99,6 @@
           },
         })
       );
-      stubRestApi('send').callsFake((method, url) => {
-        if (method !== 'POST') {
-          return Promise.reject(new Error('bad method'));
-        }
-        if (url === '/changes/test~42/revisions/2/submit') {
-          return Promise.resolve({
-            ...new Response(),
-            ok: true,
-            text() {
-              return Promise.resolve(")]}'\n{}");
-            },
-          });
-        } else if (url === '/changes/test~42/revisions/2/rebase') {
-          return Promise.resolve({
-            ...new Response(),
-            ok: true,
-            text() {
-              return Promise.resolve(")]}'\n{}");
-            },
-          });
-        }
-        return Promise.reject(new Error('bad url'));
-      });
 
       sinon
         .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded')
@@ -142,6 +120,7 @@
         },
       };
       element.changeNum = 42 as NumericChangeId;
+      element.mergeable = false;
       element.latestPatchNum = 2 as PatchSetNumber;
       element.account = {
         _account_id: 123 as AccountId,
@@ -299,11 +278,65 @@
                 Do you really want to delete the edit?
               </div>
             </gr-dialog>
+            <gr-dialog
+              class="confirmDialog"
+              confirm-label="Publish"
+              confirm-on-enter=""
+              id="confirmPublishEditDialog"
+              role="dialog"
+            >
+              <div class="header" slot="header">Publish Change Edit</div>
+              <div class="main" slot="main">
+                Do you really want to publish the edit?
+              </div>
+            </gr-dialog>
           </dialog>
         `
       );
     });
 
+    suite('isLoading', () => {
+      const isLoading = async () => {
+        await element.updateComplete;
+        const loadingButton = queryAndAssert(
+          element,
+          'div#mainContent > gr-button'
+        );
+        assert.equal(loadingButton.textContent, 'Loading actions...');
+        return loadingButton.getAttribute('hidden') === null;
+      };
+
+      test('change', async () => {
+        element.change = undefined;
+        await element.updateComplete;
+        assert.shadowDom.equal(element, '');
+      });
+
+      test('mergeable', async () => {
+        element.mergeable = undefined;
+        assert.isTrue(await isLoading());
+
+        element.mergeable = true;
+        assert.isFalse(await isLoading());
+      });
+
+      test('pluginsLoaded', async () => {
+        element.pluginsLoaded = false;
+        assert.isTrue(await isLoading());
+
+        element.pluginsLoaded = true;
+        assert.isFalse(await isLoading());
+      });
+
+      test('revisionActions', async () => {
+        element.revisionActions = undefined;
+        assert.isTrue(await isLoading());
+
+        element.revisionActions = {};
+        assert.isFalse(await isLoading());
+      });
+    });
+
     test('show-revision-actions event should fire', async () => {
       const spy = sinon.spy(element, 'sendShowRevisionActions');
       element.reload();
@@ -409,8 +442,8 @@
       );
       assert.isOk(buttonEl);
       element.setActionHidden(
-        element.ActionType.REVISION,
-        element.RevisionActions.SUBMIT,
+        ActionType.REVISION,
+        RevisionActions.SUBMIT,
         true
       );
       assert.lengthOf(element.hiddenActions, 1);
@@ -419,8 +452,8 @@
       assert.isNotOk(buttonEl);
 
       element.setActionHidden(
-        element.ActionType.REVISION,
-        element.RevisionActions.SUBMIT,
+        ActionType.REVISION,
+        RevisionActions.SUBMIT,
         false
       );
       await element.updateComplete;
@@ -429,7 +462,6 @@
     });
 
     test('buttons exist', async () => {
-      element.loading = false;
       await element.updateComplete;
       const buttonEls = queryAll(element, 'gr-button');
       const menuItems = queryAndAssert<GrDropdown>(
@@ -496,9 +528,7 @@
 
     test('submit change', async () => {
       const showSpy = sinon.spy(element, 'showActionDialog');
-      stubRestApi('getFromProjectLookup').returns(
-        Promise.resolve('test' as RepoName)
-      );
+      stubRestApi('getRepoName').returns(Promise.resolve('test' as RepoName));
       element.change = {
         ...createChangeViewChange(),
         revisions: {
@@ -532,9 +562,7 @@
           'resetFocus'
         )
         .callsFake(() => submitted.resolve());
-      stubRestApi('getFromProjectLookup').returns(
-        Promise.resolve('test' as RepoName)
-      );
+      stubRestApi('getRepoName').returns(Promise.resolve('test' as RepoName));
       element.change = {
         ...createChangeViewChange(),
         revisions: {
@@ -576,7 +604,7 @@
       assert.isTrue(fireStub.calledOnce);
       assert.deepEqual(fireStub.lastCall.args, [
         '/submit',
-        assertUIActionInfo(element.revisionActions.submit),
+        assertUIActionInfo(element.revisionActions?.submit),
         true,
       ]);
     });
@@ -1235,7 +1263,7 @@
     test('custom actions', async () => {
       // Add a button with the same key as a server-based one to ensure
       // collisions are taken care of.
-      const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
+      const key = element.addActionButton(ActionType.REVISION, 'Bork!');
       const keyTapped = mockPromise();
       element.addEventListener(key + '-tap', async e => {
         assert.equal(
@@ -2306,7 +2334,7 @@
     test('adds download revision action', async () => {
       const handler = sinon.stub();
       element.addEventListener('download-tap', handler);
-      assert.ok(element.revisionActions.download);
+      assert.ok(element.revisionActions?.download);
       element.handleDownloadTap();
       await element.updateComplete;
 
@@ -2408,7 +2436,6 @@
       const payload = {foo: 'bar'};
       let onShowError: sinon.SinonStub;
       let onShowAlert: sinon.SinonStub;
-      let getResponseObjectStub: sinon.SinonStub;
 
       setup(async () => {
         cleanup = sinon.stub();
@@ -2429,7 +2456,7 @@
       });
 
       suite('happy path', () => {
-        let sendStub: sinon.SinonStub;
+        let executeChangeActionStub: sinon.SinonStub;
         setup(() => {
           stubRestApi('getChangeDetail').returns(
             Promise.resolve({
@@ -2439,8 +2466,7 @@
               messages: createChangeMessages(1),
             })
           );
-          getResponseObjectStub = stubRestApi('getResponseObject');
-          sendStub = stubRestApi('executeChangeAction').returns(
+          executeChangeActionStub = stubRestApi('executeChangeAction').returns(
             Promise.resolve(new Response())
           );
         });
@@ -2457,7 +2483,7 @@
           assert.isFalse(onShowError.called);
           assert.isTrue(cleanup.calledOnce);
           assert.isTrue(
-            sendStub.calledWith(
+            executeChangeActionStub.calledWith(
               42,
               HttpMethod.DELETE,
               '/endpoint',
@@ -2474,11 +2500,12 @@
           });
 
           test('revert submission single change', async () => {
-            getResponseObjectStub.returns(
-              Promise.resolve({
+            const response = new Response(
+              makePrefixedJSON({
                 revert_changes: [{change_id: 12345, topic: 'T'}],
               })
             );
+            executeChangeActionStub.resolves(response);
             await element.send(
               HttpMethod.POST,
               {message: 'Revert submission'},
@@ -2493,20 +2520,21 @@
                 __type: ActionType.CHANGE,
                 label: 'l',
               },
-              new Response()
+              response
             );
             assert.isTrue(setUrlStub.called);
             assert.equal(setUrlStub.lastCall.args[0], '/q/topic:"T"');
           });
 
           test('revert single change', async () => {
-            getResponseObjectStub.returns(
-              Promise.resolve({
+            const response = new Response(
+              makePrefixedJSON({
                 change_id: 12345,
                 project: 'projectId',
                 _number: 12345,
               })
             );
+            executeChangeActionStub.resolves(response);
             stubRestApi('getChange').returns(
               Promise.resolve(createChangeViewChange())
             );
@@ -2524,7 +2552,7 @@
                 __type: ActionType.CHANGE,
                 label: 'l',
               },
-              new Response()
+              response
             );
             assert.isTrue(setUrlStub.called);
             assert.equal(setUrlStub.lastCall.args[0], '/c/projectId/+/12345');
@@ -2534,15 +2562,17 @@
         suite('multiple changes revert', () => {
           let showActionDialogStub: sinon.SinonStub;
           let setUrlStub: sinon.SinonStub;
+          let response: Response;
           setup(() => {
-            getResponseObjectStub.returns(
-              Promise.resolve({
+            response = new Response(
+              makePrefixedJSON({
                 revert_changes: [
                   {change_id: 12345, topic: 'T'},
                   {change_id: 23456, topic: 'T'},
                 ],
               })
             );
+            executeChangeActionStub.resolves(response);
             showActionDialogStub = sinon.stub(element, 'showActionDialog');
             setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
           });
@@ -2562,7 +2592,7 @@
                 __type: ActionType.CHANGE,
                 label: 'l',
               },
-              new Response()
+              response
             );
             assert.isFalse(showActionDialogStub.called);
             assert.isTrue(setUrlStub.called);
@@ -2582,7 +2612,13 @@
           assert.isFalse(onShowError.called);
           assert.isTrue(cleanup.calledOnce);
           assert.isTrue(
-            sendStub.calledWith(42, 'DELETE', '/endpoint', 12, payload)
+            executeChangeActionStub.calledWith(
+              42,
+              'DELETE',
+              '/endpoint',
+              12,
+              payload
+            )
           );
         });
       });
@@ -2599,7 +2635,7 @@
               messages: createChangeMessages(1),
             })
           );
-          const sendStub = stubRestApi('executeChangeAction');
+          const executeChangeActionStub = stubRestApi('executeChangeAction');
 
           return element
             .send(
@@ -2614,7 +2650,7 @@
               assert.isTrue(onShowAlert.calledOnce);
               assert.isFalse(onShowError.called);
               assert.isTrue(cleanup.calledOnce);
-              assert.isFalse(sendStub.called);
+              assert.isFalse(executeChangeActionStub.called);
             });
         });
 
@@ -2627,11 +2663,13 @@
               messages: createChangeMessages(1),
             })
           );
-          const sendStub = stubRestApi('executeChangeAction').callsFake(
+          const executeChangeActionStub = stubRestApi(
+            'executeChangeAction'
+          ).callsFake(
             (_num, _method, _patchNum, _endpoint, _payload, onErr) => {
               // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
               onErr!();
-              return Promise.resolve(undefined);
+              return Promise.resolve(new Response());
             }
           );
           const handleErrorStub = sinon.stub(element, 'handleResponseError');
@@ -2648,7 +2686,7 @@
             .then(() => {
               assert.isFalse(onShowError.called);
               assert.isTrue(cleanup.called);
-              assert.isTrue(sendStub.calledOnce);
+              assert.isTrue(executeChangeActionStub.calledOnce);
               assert.isTrue(handleErrorStub.called);
             });
         });
@@ -2662,7 +2700,6 @@
               messages: createChangeMessages(1),
             })
           );
-          getResponseObjectStub = stubRestApi('getResponseObject');
           const setUrlStub = sinon.stub(
             testResolver(navigationToken),
             'setUrl'
@@ -2671,8 +2708,8 @@
             element,
             'setReviewOnRevert'
           );
-          getResponseObjectStub.returns(
-            Promise.resolve({
+          const response = new Response(
+            makePrefixedJSON({
               change_id: 12345,
               project: 'projectId',
               _number: 12345,
@@ -2705,7 +2742,7 @@
               __type: ActionType.CHANGE,
               label: 'l',
             },
-            new Response()
+            response
           );
 
           assert.isTrue(errorFired);
@@ -2761,12 +2798,12 @@
       assert.strictEqual(
         queryAndAssert<GrConfirmSubmitDialog>(element, '#confirmSubmitDialog')
           .action,
-        null
+        undefined
       );
       assert.strictEqual(
         queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase')
           .rebaseOnCurrent,
-        null
+        false
       );
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 634fbf8..f5e403d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -59,7 +59,12 @@
   isSectionSet,
   DisplayRules,
 } from '../../../utils/change-metadata-util';
-import {fireAlert, fire, fireReload} from '../../../utils/event-util';
+import {
+  fireAlert,
+  fire,
+  fireReload,
+  fireError,
+} from '../../../utils/event-util';
 import {
   EditRevisionInfo,
   isDefined,
@@ -89,6 +94,7 @@
 import {changeModelToken} from '../../../models/change/change-model';
 import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 import {truncatePath} from '../../../utils/path-list-util';
+import {accountEmail, getDisplayName} from '../../../utils/display-name-util';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -134,6 +140,8 @@
 
   @state() revertedChange?: ChangeInfo;
 
+  @state() editMode = false;
+
   @state() account?: AccountDetailInfo;
 
   @state() revision?: RevisionInfo | EditRevisionInfo;
@@ -209,6 +217,11 @@
       () => this.getRelatedChangesModel().revertingChange$,
       revertingChange => (this.revertedChange = revertingChange)
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().editMode$,
+      x => (this.editMode = x)
+    );
     this.queryTopic = (input: string) => this.getTopicSuggestions(input);
     this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
@@ -231,6 +244,9 @@
         gr-weblink {
           display: block;
         }
+        gr-account-chip {
+          display: inline;
+        }
         gr-account-chip[disabled],
         gr-linked-chip[disabled] {
           opacity: 0;
@@ -471,6 +487,21 @@
             circle-shape
           ></gr-vote-chip>
         </gr-account-chip>
+        ${when(
+          this.editMode &&
+            (role === ChangeRole.AUTHOR || role === ChangeRole.COMMITTER),
+          () => html`
+            <gr-editable-label
+              id="${role}-edit-label"
+              placeholder="Update ${name}"
+              @changed=${(e: CustomEvent<string>) =>
+                this.handleIdentityChanged(e, role)}
+              showAsEditPencil
+              autocomplete
+              .query=${(text: string) => this.getIdentitySuggestions(text)}
+            ></gr-editable-label>
+          `
+        )}
       </span>
     </section>`;
   }
@@ -865,6 +896,31 @@
   }
 
   // private but used in test
+  async handleIdentityChanged(e: CustomEvent<string>, role: ChangeRole) {
+    assertIsDefined(this.change, 'change');
+    const input = e.detail.length ? e.detail.trim() : undefined;
+    if (!input?.length) return;
+    const reg = /(\w+.*)\s<(\S+@\S+.\S+)>/;
+    const [, name, email] = input.match(reg) ?? [];
+    if (!name || !email) {
+      fireError(
+        this,
+        'Invalid input format, valid identity format is "FullName <user@example.com>"'
+      );
+      return;
+    }
+    fireAlert(this, 'Saving identity and reloading ...');
+    await this.restApiService.updateIdentityInChangeEdit(
+      this.change._number,
+      name,
+      email,
+      role.toUpperCase()
+    );
+    fire(this, 'hide-alert', {});
+    fireReload(this);
+  }
+
+  // private but used in test
   computeTopicReadOnly() {
     return !this.mutable || !this.change?.actions?.topic?.enabled;
   }
@@ -973,16 +1029,9 @@
     return createSearchUrl({repo: project});
   }
 
-  private computeBranchUrl(project?: RepoName, branch?: BranchName) {
-    if (!project || !branch || !this.change || !this.change.status) return '';
-    return createSearchUrl({
-      branch,
-      repo: project,
-      statuses:
-        this.change.status === ChangeStatus.NEW
-          ? ['open']
-          : [this.change.status.toLowerCase()],
-    });
+  private computeBranchUrl(repo?: RepoName, branch?: BranchName) {
+    if (!repo || !branch || !this.change || !this.change.status) return '';
+    return createSearchUrl({branch, repo});
   }
 
   private computeCherryPickOfUrl(
@@ -1140,7 +1189,7 @@
     if (
       role === ChangeRole.AUTHOR &&
       rev.commit?.author &&
-      this.change.owner.email !== rev.commit.author.email
+      (this.editMode || this.change.owner.email !== rev.commit.author.email)
     ) {
       return rev.commit.author;
     }
@@ -1148,10 +1197,12 @@
     if (
       role === ChangeRole.COMMITTER &&
       rev.commit?.committer &&
-      this.change.owner.email !== rev.commit.committer.email &&
-      !(
-        rev.uploader?.email && rev.uploader.email === rev.commit.committer.email
-      )
+      (this.editMode ||
+        (this.change.owner.email !== rev.commit.committer.email &&
+          !(
+            rev.uploader?.email &&
+            rev.uploader.email === rev.commit.committer.email
+          )))
     ) {
       return rev.commit.committer;
     }
@@ -1227,6 +1278,25 @@
       );
   }
 
+  private async getIdentitySuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
+    const suggestions = await this.restApiService.getAccountSuggestions(input);
+    if (!suggestions) return [];
+    const identitySuggestions: AutocompleteSuggestion[] = [];
+    suggestions.forEach(account => {
+      const name = getDisplayName(this.serverConfig, account);
+      const emails: string[] = [];
+      account.email && emails.push(account.email);
+      account.secondary_emails && emails.push(...account.secondary_emails);
+      emails.forEach(email => {
+        const identity = name + ' ' + accountEmail(email);
+        identitySuggestions.push({name: identity});
+      });
+    });
+    return identitySuggestions;
+  }
+
   private computeVoteForRole(role: ChangeRole) {
     const reviewer = this.getNonOwnerRole(role);
     if (reviewer && isAccount(reviewer)) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index 93ef3e3..87875b2 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -166,7 +166,7 @@
               test-project
             </a>
             |
-            <a href="/q/project:test-project+branch:test-branch+status:open">
+            <a href="/q/project:test-project+branch:test-branch">
               test-branch
             </a>
           </span>
@@ -407,6 +407,15 @@
         element.change = change;
         assert.isNotOk(element.getNonOwnerRole(ChangeRole.COMMITTER));
       });
+
+      test('getNonOwnerRole returns committer with same email as owner in edit mode', () => {
+        // Set the committer email to be the same as the owner.
+        change!.revisions.rev1.commit!.committer.email =
+          'abc@def' as EmailAddress;
+        element.change = change;
+        element.editMode = true;
+        assert.isOk(element.getNonOwnerRole(ChangeRole.COMMITTER));
+      });
     });
 
     suite('role=author', () => {
@@ -430,6 +439,14 @@
         element.change = change;
         assert.isNotOk(element.getNonOwnerRole(ChangeRole.AUTHOR));
       });
+
+      test('getNonOwnerRole returns author with same email as owner in edit mode', () => {
+        // Set the author email to be the same as the owner.
+        change!.revisions.rev1.commit!.author.email = 'abc@def' as EmailAddress;
+        element.change = change;
+        element.editMode = true;
+        assert.isOk(element.getNonOwnerRole(ChangeRole.AUTHOR));
+      });
     });
   });
 
@@ -938,6 +955,35 @@
     });
   });
 
+  test('update author identity', async () => {
+    const change = createParsedChange();
+    element.change = change;
+    element.editMode = true;
+    await element.updateComplete;
+    const updateIdentityInChangeEditStub = stubRestApi(
+      'updateIdentityInChangeEdit'
+    ).resolves();
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    queryAndAssert(element, '#author-edit-label').dispatchEvent(
+      new CustomEvent('changed', {detail: 'user <user@example.com>'})
+    );
+    assert.isTrue(
+      updateIdentityInChangeEditStub.calledWith(
+        42 as NumericChangeId,
+        'user',
+        'user@example.com',
+        'AUTHOR'
+      )
+    );
+    await updateIdentityInChangeEditStub.lastCall.returnValue;
+    await waitUntilCalled(alertStub, 'alertStub');
+    assert.deepEqual(alertStub.lastCall.args[0].detail, {
+      message: 'Saving identity and reloading ...',
+      showDismiss: true,
+    });
+  });
+
   test('editTopic', async () => {
     element.account = createAccountDetailWithId();
     element.change = {
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index f6099fa..2a99126 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -348,7 +348,11 @@
             <gr-icon icon="info" filled></gr-icon>
           </div>
           <div class="right">
-            <div class="message" title=${m}>${m}</div>
+            <gr-formatted-text
+              class="message"
+              .markdown=${true}
+              .content=${m}
+            ></gr-formatted-text>
           </div>
         </div>
       `
@@ -534,11 +538,8 @@
           <tr>
             <td class="key">Comments</td>
             <td class="value">
-              ${when(
-                this.commentsLoading,
-                () => html`<span class="loadingSpin"></span>`
-              )}
               <gr-comments-summary
+                .commentsLoading=${this.commentsLoading}
                 .commentThreads=${this.commentThreads}
                 .draftCount=${this.draftCount}
                 .mentionCount=${this.mentionCount}
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
index c3d9774..c2ea4e5 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
@@ -92,7 +92,7 @@
               <gr-icon icon="info" filled></gr-icon>
             </div>
             <div class="right">
-              <div class="message" title="a message">a message</div>
+              <gr-formatted-text class="message"></gr-formatted-text>
             </div>
           </div>
         </div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 47f1756..2914a03 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
@@ -153,6 +153,7 @@
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 import {KnownExperimentId} from '../../../services/flags/flags';
+import {assign} from '../../../utils/location-util';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -408,6 +409,10 @@
   @state()
   replyModalOpened = false;
 
+  @state() private loginUrl = '';
+
+  @state() private loginText = '';
+
   // Accessed in tests.
   readonly reporting = getAppContext().reportingService;
 
@@ -488,6 +493,7 @@
     // TODO: Do we still need docOnly bindings?
     this.shortcutsController.addAbstract(Shortcut.EMOJI_DROPDOWN, () => {}); // docOnly
     this.shortcutsController.addAbstract(Shortcut.MENTIONS_DROPDOWN, () => {}); // docOnly
+    this.shortcutsController.addAbstract(Shortcut.SAVE_COMMENT, () => {}); // docOnly
     this.shortcutsController.addAbstract(Shortcut.REFRESH_CHANGE, () =>
       this.getChangeModel().navigateToChangeResetReload()
     );
@@ -710,6 +716,16 @@
     );
     subscribe(
       this,
+      () => this.getConfigModel().loginUrl$,
+      loginUrl => (this.loginUrl = loginUrl)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().loginText$,
+      loginText => (this.loginText = loginText)
+    );
+    subscribe(
+      this,
       () => this.getRelatedChangesModel().revertingChange$,
       revertingChange => {
         this.revertingChange = revertingChange;
@@ -1298,7 +1314,6 @@
                   Shortcut.OPEN_REPLY_DIALOG,
                   ShortcutSection.ACTIONS
                 )}
-                ?hidden=${!this.loggedIn}
                 primary=""
                 .disabled=${this.replyDisabled}
                 @click=${this.handleReplyTap}
@@ -1649,6 +1664,7 @@
     if (!this.commitMessageEditor || this.commitMessageEditor.disabled) return;
     // Trim trailing whitespace from each line.
     const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
+    const committerEmail = e.detail.committerEmail;
 
     this.getPluginLoader().jsApiService.handleCommitMessage(
       this.change,
@@ -1657,7 +1673,7 @@
 
     this.commitMessageEditor.disabled = true;
     this.restApiService
-      .putChangeCommitMessage(this.changeNum, message)
+      .putChangeCommitMessage(this.changeNum, message, committerEmail)
       .then(resp => {
         assertIsDefined(this.commitMessageEditor);
         this.commitMessageEditor.disabled = false;
@@ -1792,9 +1808,15 @@
     );
   }
 
-  private handleReplyTap(e: MouseEvent) {
-    e.preventDefault();
-    this.openReplyDialog(FocusTarget.ANY);
+  private handleReplyTap() {
+    if (this.loggedIn) {
+      this.openReplyDialog(FocusTarget.ANY);
+    } else {
+      // We are not using `this.getNavigation().setUrl()`, because the login
+      // page is served directly from the backend and is not part of the web
+      // app.
+      assign(window.location, this.loginUrl);
+    }
   }
 
   private onReplyModalCanceled() {
@@ -1983,6 +2005,9 @@
 
   // Private but used in tests.
   computeReplyButtonLabel() {
+    if (!this.loggedIn) {
+      return this.loginText;
+    }
     let label = this.canStartReview() ? 'Start Review' : 'Reply';
     if (this.draftCount > 0) {
       label += ` (${this.draftCount})`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 009a92f..4d6403a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -1061,14 +1061,11 @@
     });
   });
 
-  test('reply button is not visible when logged out', async () => {
+  test('reply button is a login button when logged out', async () => {
     assertIsDefined(element.replyBtn);
     element.loggedIn = false;
     await element.updateComplete;
-    assert.equal(getComputedStyle(element.replyBtn).display, 'none');
-    element.loggedIn = true;
-    await element.updateComplete;
-    assert.notEqual(getComputedStyle(element.replyBtn).display, 'none');
+    assert.equal(element.replyBtn.textContent, 'Sign in');
   });
 
   test('download tap calls handleOpenDownloadDialog', () => {
@@ -1194,17 +1191,24 @@
       Promise.resolve(new Response(null, {status: 500}))
     );
     await element.updateComplete;
-    const mockEvent = (content: string) =>
-      new CustomEvent('', {detail: {content}});
+    const committerEmail = 'test@example.org';
+    const mockEvent = (content: string, committerEmail: string) =>
+      new CustomEvent('', {
+        detail: {content, committerEmail},
+      });
 
     assertIsDefined(element.commitMessageEditor);
-    element.handleCommitMessageSave(mockEvent('test \n  test '));
+    element.handleCommitMessageSave(
+      mockEvent('test \n  test ', committerEmail)
+    );
     assert.equal(putStub.lastCall.args[1], 'test\n  test');
     element.commitMessageEditor.disabled = false;
-    element.handleCommitMessageSave(mockEvent('  test\ntest'));
+    element.handleCommitMessageSave(mockEvent('  test\ntest', committerEmail));
     assert.equal(putStub.lastCall.args[1], '  test\ntest');
     element.commitMessageEditor.disabled = false;
-    element.handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
+    element.handleCommitMessageSave(
+      mockEvent('\n\n\n\n\n\n\n\n', committerEmail)
+    );
     assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary.ts b/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary.ts
index bbd6003..9123cd6 100644
--- a/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary.ts
@@ -23,12 +23,17 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {userModelToken} from '../../../models/user/user-model';
+import {when} from 'lit/directives/when.js';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
 
 @customElement('gr-comments-summary')
 export class GrCommentsSummary extends LitElement {
   @property({type: Object})
   commentThreads?: CommentThread[];
 
+  @property({type: Boolean})
+  commentsLoading = false;
+
   @property({type: Number})
   draftCount = 0;
 
@@ -63,7 +68,18 @@
 
   static override get styles() {
     return [
+      spinnerStyles,
       css`
+        /* The basics of .loadingSpin are defined in shared styles. */
+        .loadingSpin {
+          width: calc(var(--line-height-normal) - 2px);
+          height: calc(var(--line-height-normal) - 2px);
+          display: inline-block;
+          vertical-align: top;
+          position: relative;
+          /* Making up for the 2px reduced height above. */
+          top: 1px;
+        }
         .zeroState {
           color: var(--deemphasized-text-color);
         }
@@ -91,6 +107,10 @@
       ? this.getAccounts(commentThreads.filter(isResolved))
       : undefined;
     return html`
+      ${when(
+        this.commentsLoading,
+        () => html`<span class="loadingSpin"></span>`
+      )}
       ${this.renderZeroState(countResolvedComments, countUnresolvedComments)}
       ${this.renderDraftChip()} ${this.renderMentionChip()}
       ${this.renderUnresolvedCommentsChip(
@@ -112,6 +132,10 @@
       !!countUnresolvedComments
     )
       return nothing;
+    if (this.commentsLoading) {
+      return html`<span class="zeroState"> Loading comments...</span>`;
+    }
+
     return html`<span class="zeroState"> No comments</span>`;
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 5e4e255..5d2c6a4 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -631,7 +631,10 @@
           payload,
           handleError
         )
-        .then(() => {
+        .then(response => {
+          if (!response.ok) {
+            return;
+          }
           this.updateStatus(change, {status: ProgressStatus.SUCCESSFUL});
           const failedOrPending = Object.values(this.statuses).find(
             v => v.status !== ProgressStatus.SUCCESSFUL
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
index 83e6f9e..4655c71 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
@@ -207,7 +207,7 @@
       await element.updateComplete;
       const executeChangeActionStub = stubRestApi(
         'executeChangeAction'
-      ).returns(Promise.resolve(new Response()));
+      ).resolves(new Response());
       queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!.click();
       await element.updateComplete;
       const args = executeChangeActionStub.args[0];
@@ -228,7 +228,7 @@
       await element.updateComplete;
       const executeChangeActionStub = stubRestApi(
         'executeChangeAction'
-      ).returns(Promise.resolve(new Response()));
+      ).resolves(new Response());
       const checkboxes = queryAll<HTMLInputElement>(
         element,
         'input[type="checkbox"]'
@@ -247,7 +247,7 @@
       await element.updateComplete;
       const executeChangeActionStub = stubRestApi(
         'executeChangeAction'
-      ).returns(Promise.resolve(new Response()));
+      ).resolves(new Response());
       const checkboxes = queryAll<HTMLInputElement>(
         element,
         'input[type="checkbox"]'
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index a4c52d7..c086359 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -166,6 +166,9 @@
           @selected-scheme-changed=${(e: BindValueChangeEvent) => {
             this.selectedScheme = e.detail.value;
           }}
+          @item-copied=${(e: Event) => {
+            this.handleCloseTap(e);
+          }}
         ></gr-download-commands>
       </section>
     `;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index 73d7618..c730671 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -19,7 +19,12 @@
 } from '../../../types/common';
 import './gr-download-dialog';
 import {GrDownloadDialog} from './gr-download-dialog';
-import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -153,6 +158,20 @@
     );
   });
 
+  test('closes when gr-download-commands fires item-selected', async () => {
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+    const commands = queryAndAssert<GrDownloadCommands>(
+      element,
+      'gr-download-commands'
+    );
+    commands.dispatchEvent(new CustomEvent('item-copied'));
+
+    await waitUntil(() => fireStub.called);
+
+    const events = fireStub.args.map(arg => arg[0].type || '');
+    assert.isTrue(events.includes('close'));
+  });
+
   test('anchors use download attribute', () => {
     const anchors = Array.from(queryAll(element, 'a'));
     assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index f939374..5186a1d 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -143,8 +143,8 @@
     return [
       sharedStyles,
       css`
-        .prefsButton {
-          float: right;
+        #diffPrefsContainer {
+          display: flex;
         }
         .patchInfoOldPatchSet.patchInfo-header {
           background-color: var(--emphasis-color);
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 5533add..dc75a16 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
@@ -51,12 +51,13 @@
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
-import {Timing} from '../../../constants/reporting';
+import {Timing, Interaction} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
 import {select} from '../../../utils/observable-util';
 import {resolve} from '../../../models/dependency';
 import {browserModelToken} from '../../../models/browser/browser-model';
 import {commentsModelToken} from '../../../models/comments/comments-model';
+import {RunResult, checksModelToken} from '../../../models/checks/checks-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {filesModelToken} from '../../../models/change/files-model';
 import {ShortcutController} from '../../lit/shortcut-controller';
@@ -86,6 +87,7 @@
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {FileMode, fileModeToString} from '../../../utils/file-util';
+import {ChecksIcon, iconFor} from '../../../models/checks/checks-util';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -198,6 +200,9 @@
   @property({type: Object})
   changeComments?: ChangeComments;
 
+  @property({type: Array})
+  checkResults?: RunResult[];
+
   @state() selectedIndex = 0;
 
   @property({type: Object})
@@ -300,6 +305,8 @@
 
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
+  private readonly getChecksModel = resolve(this, checksModelToken);
+
   private readonly getBrowserModel = resolve(this, browserModelToken);
 
   shortcutsController = new ShortcutController(this);
@@ -643,6 +650,46 @@
         :host(.hideComments) {
           --gr-comment-thread-display: none;
         }
+        .checkChip {
+          display: inline-flex;
+          align-items: center;
+          gap: var(--spacing-xs);
+          border: 1px solid;
+          border-radius: 999px;
+          padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
+            var(--spacing-s);
+          vertical-align: top;
+          position: relative;
+          top: 2px;
+          font-size: var(--font-size-small);
+          font-weight: var(--font-weight-normal);
+          line-height: var(--line-height-small);
+          color: var(--primary-text-color);
+          & gr-icon {
+            font-size: var(--line-height-small);
+          }
+          &.info {
+            border-color: var(--info-foreground);
+            background-color: var(--info-background);
+            & gr-icon {
+              color: var(--info-foreground);
+            }
+          }
+          &.warning {
+            border-color: var(--warning-foreground);
+            background-color: var(--warning-background);
+            & gr-icon {
+              color: var(--warning-foreground);
+            }
+          }
+          &.error {
+            border-color: var(--error-foreground);
+            background-color: var(--error-background);
+            & gr-icon {
+              color: var(--error-foreground);
+            }
+          }
+        }
       `,
     ];
   }
@@ -744,6 +791,13 @@
     );
     subscribe(
       this,
+      () => this.getChecksModel().allResultsSelected$,
+      results => {
+        this.checkResults = results;
+      }
+    );
+    subscribe(
+      this,
       () => this.getFilesModel().filesIncludingUnmodified$,
       files => {
         this.files = [...files];
@@ -1280,6 +1334,7 @@
     return html` <div role="gridcell">
       <div class="comments desktop">
         <span>${this.renderCommentsChips(file)}</span>
+        <span>${this.renderChecksChips(file)}</span>
         <span class="noCommentsScreenReaderText">
           <!-- Screen readers read the following content only if 2 other
           spans in the parent div is empty. The content is not visible on
@@ -1654,6 +1709,35 @@
     ></gr-comments-summary>`;
   }
 
+  renderChecksChips(file?: NormalizedFileInfo) {
+    if (!this.checkResults || !this.patchRange || !file?.__path) {
+      return nothing;
+    }
+
+    const iconsByName: Record<string, ChecksIcon[]> = {};
+    for (const result of this.checkResults ?? []) {
+      if (
+        result.codePointers === undefined ||
+        !result.codePointers.some(pointer => pointer.path === file.__path)
+      ) {
+        continue;
+      }
+      const icon = iconFor(result.category);
+      iconsByName[icon.name] ??= [];
+      iconsByName[icon.name].push(icon);
+    }
+
+    return Object.values(iconsByName).map(
+      icons =>
+        html`
+          <div class="checkChip ${icons[0].name}">
+            <gr-icon icon=${icons[0].name} ?filled=${icons[0].filled}></gr-icon>
+            <div>${icons.length}</div>
+          </div>
+        `
+    );
+  }
+
   protected override firstUpdated(): void {
     this.detectChromiteButler();
     this.reporting.fileListDisplayed();
@@ -1756,8 +1840,10 @@
       f => f.path === file.path
     );
     if (indexInExpanded === -1) {
+      this.reporting.reportInteraction(Interaction.FILE_LIST_DIFF_EXPANDED);
       this.expandedFiles = this.expandedFiles.concat([file]);
     } else {
+      this.reporting.reportInteraction(Interaction.FILE_LIST_DIFF_COLLAPSED);
       this.expandedFiles = this.expandedFiles.filter(
         (_val, idx) => idx !== indexInExpanded
       );
@@ -1800,10 +1886,12 @@
       }
     }
 
+    this.reporting.reportInteraction(Interaction.FILE_LIST_ALL_DIFFS_EXPANDED);
     this.expandedFiles = newFiles.concat(this.expandedFiles);
   }
 
   collapseAllDiffs() {
+    this.reporting.reportInteraction(Interaction.FILE_LIST_ALL_DIFFS_COLLAPSED);
     this.expandedFiles = [];
   }
 
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 0f9cf6a..1cfbb83 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
@@ -196,6 +196,7 @@
                   emptywhennocomments=""
                 ></gr-comments-summary
               ></span>
+              <span></span>
               <span class="noCommentsScreenReaderText"> No comments </span>
             </div>
             <div class="comments mobile">
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index df04f68..68fbe8b 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -33,7 +33,12 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {PaperToggleButtonElement} from '@polymer/paper-toggle-button';
 import {testResolver} from '../../../test/common-test-setup';
-import {commentsModelToken} from '../../../models/comments/comments-model';
+import {TEST_PROJECT_NAME} from '../../../test/test-data-generators';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
 
 const author = {
   _account_id: 42 as AccountId,
@@ -138,9 +143,12 @@
       element = await fixture<GrMessagesList>(
         html`<gr-messages-list></gr-messages-list>`
       );
-      await testResolver(commentsModelToken).reloadComments(
-        0 as NumericChangeId
-      );
+      testResolver(changeViewModelToken).setState({
+        view: GerritView.CHANGE,
+        childView: ChangeChildView.OVERVIEW,
+        changeNum: 123 as NumericChangeId,
+        repo: TEST_PROJECT_NAME,
+      });
       element.messages = messages;
       await element.updateComplete;
     });
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index a0ad2f0..0bf49db 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
@@ -49,7 +49,6 @@
   isDetailedLabelInfo,
   isReviewerAccountSuggestion,
   isReviewerGroupSuggestion,
-  ParsedJSON,
   ReviewerInput,
   ReviewInput,
   ReviewResult,
@@ -131,6 +130,10 @@
 import {formStyles} from '../../../styles/form-styles';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getDocUrl} from '../../../utils/url-util';
+import {
+  readJSONResponsePayload,
+  ResponsePayload,
+} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 export enum FocusTarget {
   ANY = 'any',
@@ -1367,6 +1370,9 @@
     // timer will be ended.
     this.reporting.time(Timing.SEND_REPLY);
     const labels = this.getLabelScores().getLabelValues();
+    if (labels[StandardLabels.CODE_REVIEW] === 2) {
+      this.reporting.reportInteraction(Interaction.CODE_REVIEW_APPROVAL);
+    }
 
     const reviewInput: ReviewInput = {
       drafts: includeComments
@@ -1435,7 +1441,8 @@
     if (this.patchsetLevelGrComment) {
       this.patchsetLevelGrComment.disableAutoSaving = true;
       await this.restApiService.awaitPendingDiffDrafts();
-      const comment = this.patchsetLevelGrComment.convertToCommentInput();
+      const comment =
+        await this.patchsetLevelGrComment.convertToCommentInputAndOrDiscard();
       if (comment && comment.path && comment.message) {
         reviewInput.comments ??= {};
         reviewInput.comments[comment.path] ??= [];
@@ -1445,6 +1452,7 @@
 
     assertIsDefined(this.change, 'change');
     reviewInput.reviewers = this.computeReviewers();
+    this.reportStartReview(reviewInput);
 
     const errFn = (r?: Response | null) => this.handle400Error(r);
     this.getNavigation().blockNavigation('sending review');
@@ -1460,6 +1468,7 @@
         this.includeComments = true;
         fireNoBubble(this, 'send', {});
         fireIronAnnounce(this, 'Reply sent');
+        this.getPluginLoader().jsApiService.handleReplySent();
         return;
       })
       .then(result => result)
@@ -1474,6 +1483,31 @@
       });
   }
 
+  private reportStartReview(reviewInput: ReviewInput) {
+    const changeHasReviewers =
+      (this.change?.reviewers.REVIEWER ?? []).length > 0;
+    const newReviewersAdded =
+      (this.reviewersList?.additions() ?? []).length > 0;
+
+    // A review starts if either a WIP change is set to active with reviewers ...
+    const setActiveWithReviewers =
+      this.change?.work_in_progress &&
+      reviewInput.ready &&
+      // Setting a change active and *removing* all reviewers at the same time
+      // is an obscure corner case that we don't care about. :-)
+      (changeHasReviewers || newReviewersAdded);
+    // ... or if reviewers are added to an already active change that has no reviewers yet.
+    const isActiveAddReviewers =
+      !this.change?.work_in_progress &&
+      !reviewInput.work_in_progress &&
+      !changeHasReviewers &&
+      newReviewersAdded;
+
+    if (setActiveWithReviewers || isActiveAddReviewers) {
+      this.reporting.reportInteraction(Interaction.START_REVIEW);
+    }
+  }
+
   focusOn(section?: FocusTarget) {
     // Safeguard- always want to focus on something.
     if (!section || section === FocusTarget.ANY) {
@@ -1514,26 +1548,29 @@
     //
     this.disabled = false;
 
-    // Using response.clone() here, because getResponseObject() and
+    // Using response.clone() here, because readJSONResponsePayload() and
     // potentially the generic error handler will want to call text() on the
     // response object, which can only be done once per object.
-    const jsonPromise = this.restApiService.getResponseObject(response.clone());
-    return jsonPromise.then((parsed: ParsedJSON) => {
-      const result = parsed as ReviewResult;
-      // Only perform custom error handling for 400s and a parsable
-      // ReviewResult response.
-      if (response.status === 400 && result && result.reviewers) {
-        const errors: string[] = [];
-        const addReviewers = Object.values(result.reviewers);
-        addReviewers.forEach(r => errors.push(r.error ?? 'no explanation'));
-        response = {
-          ...response,
-          ok: false,
-          text: () => Promise.resolve(errors.join(', ')),
-        };
-      }
-      fireServerError(response);
-    });
+    const jsonPromise = readJSONResponsePayload(response.clone());
+    return jsonPromise
+      .then((payload: ResponsePayload) => {
+        const result = payload.parsed as ReviewResult;
+        // Only perform custom error handling for 400s and a parsable
+        // ReviewResult response.
+        if (response.status === 400 && result.reviewers) {
+          const errors: string[] = [];
+          const addReviewers = Object.values(result.reviewers);
+          addReviewers.forEach(r => errors.push(r.error ?? 'no explanation'));
+          response = {
+            ...response,
+            ok: false,
+            text: () => Promise.resolve(errors.join(', ')),
+          };
+        }
+      })
+      .finally(() => {
+        fireServerError(response);
+      });
   }
 
   computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 3bc2770..af018c3 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -93,6 +93,9 @@
           --gr-vote-chip-width: 14px;
           --gr-vote-chip-height: 14px;
         }
+        .reviewersAndControls {
+          text-wrap: pretty;
+        }
       `,
     ];
   }
@@ -103,7 +106,7 @@
       this.reviewers.length - this.displayedReviewers.length;
     return html`
       <div class="container">
-        <div>
+        <div class="reviewersAndControls">
           ${repeat(
             this.displayedReviewers,
             reviewer => accountKey(reviewer),
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
index 1f1bef3..f050ab50 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -28,7 +28,7 @@
       element,
       /* HTML */ `
         <div class="container">
-          <div>
+          <div class="reviewersAndControls">
             <div class="controlsContainer" hidden="">
               <gr-button
                 aria-disabled="false"
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 29a22a3..830a35d 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -23,10 +23,13 @@
 } from '../../../api/rest-api';
 import {
   extractAssociatedLabels,
+  extractLabelsWithCountFrom,
   getAllUniqueApprovals,
   getRequirements,
   getTriggerVotes,
+  hasApprovedVote,
   hasNeutralStatus,
+  hasRejectedVote,
   hasVotes,
   iconForRequirement,
   orderSubmitRequirements,
@@ -286,6 +289,7 @@
         label =>
           html`<div class="votes-line">
             ${this.renderLabelVote(label, allLabels)}
+            ${this.renderVoteCountHelpLabel(requirement, label, allLabels)}
             ${this.renderOverrideLabels(
               requirement,
               label,
@@ -297,6 +301,30 @@
     </div> `;
   }
 
+  // Help when submit requirement needs more votes and there is already 1 vote
+  renderVoteCountHelpLabel(
+    requirement: SubmitRequirementResultInfo,
+    label: string,
+    labels: LabelNameToInfoMap
+  ) {
+    if (requirement.status !== SubmitRequirementStatus.UNSATISFIED) {
+      return nothing;
+    }
+
+    const labelInfo = labels[label];
+    if (!hasApprovedVote(labelInfo) || hasRejectedVote(labelInfo)) {
+      return nothing;
+    }
+
+    const count = extractLabelsWithCountFrom(
+      requirement.submittability_expression_result.expression
+    ).find(labelWithCount => labelWithCount.label === label)?.count;
+
+    if (!count || count === 1) return nothing;
+
+    return html`Requires ${count} votes`;
+  }
+
   renderLabelVote(label: string, labels: LabelNameToInfoMap) {
     const labelInfo = labels[label];
     if (isDetailedLabelInfo(labelInfo)) {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 1057664..72d3268 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -241,6 +241,20 @@
           display: inline-block;
           margin-left: var(--spacing-s);
         }
+        /* actions-shown-on-collapsed are shown only when .actions is hidden
+          and vice versa. */
+        tr.container td .summary-cell .actions-shown-on-collapsed,
+        tr.container.collapsed:focus-within
+          td
+          .summary-cell
+          .actions-shown-on-collapsed,
+        tr.container.collapsed:hover
+          td
+          .summary-cell
+          .actions-shown-on-collapsed,
+        :host(.dropdown-open) tr td .summary-cell .actions-shown-on-collapsed {
+          display: none;
+        }
         tr.container.collapsed td .summary-cell .message {
           color: var(--deemphasized-text-color);
         }
@@ -248,6 +262,10 @@
         tr.container.collapsed td .summary-cell .actions {
           display: none;
         }
+        tr.container.collapsed td .summary-cell .actions-shown-on-collapsed {
+          display: inline-block;
+          margin-left: var(--spacing-s);
+        }
         tr.detailsRow.collapsed {
           display: none;
         }
@@ -278,6 +296,7 @@
         td .summary-cell .tag.brown {
           background-color: var(--tag-brown);
         }
+        .actions-shown-on-collapsed gr-checks-action,
         .actions gr-checks-action,
         .actions gr-dropdown {
           /* Fitting a 28px button into 20px line-height. */
@@ -463,7 +482,7 @@
   }
 
   renderSummary(text?: string) {
-    if (!text) return;
+    text = text ?? '';
     return html`
       <!-- The &nbsp; is for being able to shrink a tiny amount without
        the text itself getting shrunk with an ellipsis. -->
@@ -549,24 +568,31 @@
     const disabledItems = overflowItems
       .filter(action => action.disabled)
       .map(action => action.id);
-    return html`<div class="actions">
-      ${this.renderAction(actions[0])} ${this.renderAction(actions[1])}
-      <gr-dropdown
-        id="moreActions"
-        link=""
-        vertical-offset="32"
-        horizontal-align="right"
-        @tap-item=${this.handleAction}
-        @opened-changed=${(e: ValueChangedEvent<boolean>) =>
-          this.classList.toggle('dropdown-open', e.detail.value)}
-        ?hidden=${overflowItems.length === 0}
-        .items=${overflowItems}
-        .disabledIds=${disabledItems}
-      >
-        <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
-        <span id="moreMessage">More</span>
-      </gr-dropdown>
-    </div>`;
+    return html` ${when(
+        fixAction,
+        () =>
+          html`<div class="actions-shown-on-collapsed">
+            ${this.renderAction(fixAction)}
+          </div> `
+      )}
+      <div class="actions">
+        ${this.renderAction(actions[0])} ${this.renderAction(actions[1])}
+        <gr-dropdown
+          id="moreActions"
+          link=""
+          vertical-offset="32"
+          horizontal-align="right"
+          @tap-item=${this.handleAction}
+          @opened-changed=${(e: ValueChangedEvent<boolean>) =>
+            this.classList.toggle('dropdown-open', e.detail.value)}
+          ?hidden=${overflowItems.length === 0}
+          .items=${overflowItems}
+          .disabledIds=${disabledItems}
+        >
+          <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
+          <span id="moreMessage">More</span>
+        </gr-dropdown>
+      </div>`;
   }
 
   private handleAction(e: CustomEvent<Action>) {
@@ -585,24 +611,6 @@
     ></gr-checks-action>`;
   }
 
-  renderPrimaryActions() {
-    const primaryActions = (this.result?.actions ?? []).slice(0, 2);
-    if (primaryActions.length === 0) return;
-    return html`
-      <div class="primaryActions">${primaryActions.map(this.renderAction)}</div>
-    `;
-  }
-
-  renderSecondaryActions() {
-    const secondaryActions = (this.result?.actions ?? []).slice(2);
-    if (secondaryActions.length === 0) return;
-    return html`
-      <div class="secondaryActions">
-        ${secondaryActions.map(this.renderAction)}
-      </div>
-    `;
-  }
-
   renderTag(tag: Tag) {
     return html`<button
       class="tag ${tag.color}"
@@ -1500,6 +1508,7 @@
         <tbody @checks-results-filter=${this.handleFilter}>
           ${repeat(
             filtered,
+            // @ts-ignore: temporarily unblock typescript 5.3 migration
             result => result.internalResultId,
             (result?: RunResult) => html`
               <gr-result-row
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index 58b939c..89c7fd7 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -211,7 +211,6 @@
   }
 
   private renderActions() {
-    if (!this.isExpanded) return nothing;
     return html`<div class="actions">
       ${this.renderShowFixButton()}${this.renderPleaseFixButton()}
     </div>`;
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index b913c87..5aa266c 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -31,31 +31,42 @@
     assert.shadowDom.equal(
       element,
       `
-      <div class="container font-normal warning">
-        <div class="header">
-          <div class="icon">
-            <gr-icon icon="warning" filled></gr-icon>
-          </div>
-          <div class="name">
-            <gr-hovercard-run> </gr-hovercard-run>
-            <div class="name" role="button" tabindex="0">FAKE Super Check</div>
-          </div>
-          <div class="summary">We think that you could improve this.</div>
-          <div class="message">
-            There is a lot to be said. A lot. I say, a lot.
+        <div class="container font-normal warning">
+          <div class="header">
+            <div class="icon">
+              <gr-icon icon="warning" filled></gr-icon>
+            </div>
+            <div class="name">
+              <gr-hovercard-run> </gr-hovercard-run>
+              <div class="name" role="button" tabindex="0">
+                FAKE Super Check
+              </div>
+            </div>
+            <div class="summary">We think that you could improve this.</div>
+            <div class="message">
+              There is a lot to be said. A lot. I say, a lot.
                 So please keep reading.
+            </div>
+            <div
+              aria-checked="false"
+              aria-label="Expand result row"
+              class="show-hide"
+              role="switch"
+              tabindex="0"
+            >
+              <gr-icon icon="expand_more"></gr-icon>
+            </div>
           </div>
-          <div aria-checked="false"
-               aria-label="Expand result row"
-               class="show-hide"
-               role="switch"
-               tabindex="0">
-            <gr-icon icon="expand_more"></gr-icon>
+          <div class="details">
+            <div class="actions">
+              <gr-checks-action
+                id="please-fix"
+                context="diff-fix"
+              ></gr-checks-action>
+            </div>
           </div>
         </div>
-        <div class="details"></div>
-      </div>
-    `
+      `
     );
   });
 
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 94241ea..3d21e07 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -10,7 +10,12 @@
 export interface NavigationService {
   /**
    * This is similar to letting the browser navigate to this URL when the user
-   * clicks it, or to just setting `window.location.href` directly.
+   * clicks it, or to just calling `window.location.assign()` directly.
+   *
+   * CAUTION: You should actually use `window.location.assign()` directly for
+   * URLs that are not handled by gr-router. Otherwise we will call
+   * `pushState()` and then `window.location.reload()` from the router, which
+   * will break the browser's back button.
    *
    * This adds a new entry to the browser location history. Consier using
    * `replaceUrl()`, if you want to avoid that.
@@ -23,6 +28,11 @@
    * Navigate to this URL, but replace the current URL in the history instead of
    * adding a new one (which is what `setUrl()` would do).
    *
+   * CAUTION: You should actually use `window.location.replace()` directly for
+   * URLs that are not handled by gr-router. Otherwise we will call
+   * `replaceState()` and then `window.location.reload()` from the router, which
+   * will break the browser's back button.
+   *
    * page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string): void;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
index 264c6e0..cb96d77 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
@@ -46,12 +46,18 @@
 /**
  * The browser `History` API allows `pushState()` to contain an arbitrary state
  * object. Our router only sets `path` on the state and inspects it when
- * handling `popstate` events. This interface is internal only.
+ * handling `popstate` events. This interface is for internal use within the
+ * router.
  */
 interface PageState {
   path?: string;
 }
 
+export const UNHANDLED_URL_PATTERNS = [
+  /^\/log(in|out)(\/(.+))?$/,
+  /^\/plugins\/(.+)$/,
+];
+
 const clickEvent = document.ontouchstart ? 'touchstart' : 'click';
 
 export class Page {
@@ -238,6 +244,18 @@
     if (this.base && orig === path && window.location.protocol !== 'file:') {
       return;
     }
+
+    // See issue 40015337: We have to make sure that we only use
+    // show()/pushState() for URLs that gr-router will actually handle.
+    // Calling pushState() tells the browser that both the previous and the
+    // next URL are handled by the same single page application with a
+    // popstate event handler. But if we call pushState() and then
+    // later `window.location.reload()` from the router and a separate page
+    // and document are loaded, then the BACK button will stop working.
+    if (UNHANDLED_URL_PATTERNS.find(pattern => pattern.test(path))) {
+      return;
+    }
+
     e.preventDefault();
     this.show(orig);
   };
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
index d194bf55..729a15b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
@@ -20,14 +20,49 @@
     page.stop();
   });
 
-  test('click handler', async () => {
-    const spy = sinon.spy();
-    page.registerRoute(/\/settings/, spy);
-    const link = await fixture<HTMLAnchorElement>(
-      html`<a href="/settings"></a>`
-    );
-    link.click();
-    assert.isTrue(spy.calledOnce);
+  suite('click handler', () => {
+    const clickListener = (e: Event) => e.preventDefault();
+    let spy: sinon.SinonSpy;
+    let link: HTMLAnchorElement;
+
+    setup(async () => {
+      spy = sinon.spy();
+      link = await fixture<HTMLAnchorElement>(html`<a href="/settings"></a>`);
+
+      document.addEventListener('click', clickListener);
+    });
+
+    teardown(() => {
+      document.removeEventListener('click', clickListener);
+    });
+
+    test('click handled by specific route', async () => {
+      page.registerRoute(/\/settings/, spy);
+      link.href = '/settings';
+      link.click();
+      assert.isTrue(spy.calledOnce);
+    });
+
+    test('click handled by default route', async () => {
+      page.registerRoute(/.*/, spy);
+      link.href = '/something';
+      link.click();
+      assert.isTrue(spy.called);
+    });
+
+    test('click not handled for /plugins/... links', async () => {
+      page.registerRoute(/.*/, spy);
+      link.href = '/plugins/gitiles';
+      link.click();
+      assert.isFalse(spy.called);
+    });
+
+    test('click not handled for /login/... links', async () => {
+      page.registerRoute(/.*/, spy);
+      link.href = '/login';
+      link.click();
+      assert.isFalse(spy.called);
+    });
   });
 
   test('register route and exit', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 20585ff..ad3e15c 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -106,6 +106,7 @@
   timeoutPromise,
 } from '../../../utils/async-util';
 import {Finalizable} from '../../../types/types';
+import {assign} from '../../../utils/location-util';
 
 // TODO: Move all patterns to view model files and use the `Route` interface,
 // which will enforce using `RegExp` in its `urlPattern` property.
@@ -120,12 +121,6 @@
   NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
   REGISTER: /^\/register(\/.*)?$/,
 
-  // Pattern for login and logout URLs intended to be passed-through. May
-  // include a return URL.
-  // TODO: Maybe this pattern and its handler can just be removed, because
-  // passing through is what the default router would eventually do anyway.
-  LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
-
   // Pattern for a catchall route when no other pattern is matched.
   DEFAULT: /.*/,
 
@@ -171,8 +166,6 @@
   // Matches /admin/repos/<repos>,access.
   REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
 
-  PLUGINS: /^\/plugins\/(.+)$/,
-
   // Matches /admin/plugins with optional filter and offset.
   PLUGIN_LIST: /^\/admin\/plugins\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
   // Matches /admin/groups with optional filter and offset.
@@ -197,6 +190,10 @@
 
   CHANGE_ID_QUERY: /^\/id\/(I[0-9a-f]{40})$/,
 
+  // Matches /c/<changeNum>/[*][/].
+  CHANGE_LEGACY: /^\/c\/(\d+)\/?(.*)$/,
+  CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
+
   // Matches
   // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..][<patchNum|edit>].
   // TODO(kaspern): Migrate completely to project based URLs, with backwards
@@ -397,7 +394,7 @@
   setState(state: AppElementParams) {
     // TODO: Move this logic into the change model.
     if ('repo' in state && state.repo !== undefined && 'changeNum' in state)
-      this.restApiService.setInProjectLookup(state.changeNum, state.repo);
+      this.restApiService.addRepoNameToCache(state.changeNum, state.repo);
 
     this.routerModel.setState({view: state.view});
     // We are trying to reset the change (view) model when navigating to other
@@ -456,7 +453,11 @@
    */
   redirectToLogin(returnUrl: string) {
     const basePath = getBaseUrl() || '';
-    this.setUrl(
+    // We are not using `this.getNavigation().setUrl()`, because the login
+    // page is served directly from the backend and is not part of the web
+    // app.
+    assign(
+      window.location,
       '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
     );
   }
@@ -579,6 +580,8 @@
    * page.show() eventually just calls `window.history.pushState()`.
    */
   setUrl(url: string) {
+    // TODO: Use window.location.assign() instead of page.show(), if the URL is
+    // external, i.e. not handled by the router.
     this.page.show(url);
   }
 
@@ -589,6 +592,8 @@
    * this.page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string) {
+    // TODO: Use window.location.replace() instead of page.redirect(), if the
+    // URL is external, i.e. not handled by the router.
     this.redirect(url);
   }
 
@@ -805,10 +810,6 @@
       this.handleRepoRoute(ctx)
     );
 
-    this.mapRoute(RoutePattern.PLUGINS, 'handlePassThroughRoute', () =>
-      this.handlePassThroughRoute()
-    );
-
     this.mapRoute(
       RoutePattern.PLUGIN_LIST,
       'handlePluginListFilterRoute',
@@ -846,6 +847,12 @@
     );
 
     this.mapRoute(
+      RoutePattern.CHANGE_NUMBER_LEGACY,
+      'handleChangeNumberLegacyRoute',
+      ctx => this.handleChangeNumberLegacyRoute(ctx)
+    );
+
+    this.mapRoute(
       RoutePattern.DIFF_EDIT,
       'handleDiffEditRoute',
       ctx => this.handleDiffEditRoute(ctx),
@@ -875,6 +882,10 @@
       this.handleChangeRoute(ctx)
     );
 
+    this.mapRoute(RoutePattern.CHANGE_LEGACY, 'handleChangeLegacyRoute', ctx =>
+      this.handleChangeLegacyRoute(ctx)
+    );
+
     this.mapRoute(
       RoutePattern.AGREEMENTS,
       'handleAgreementsRoute',
@@ -907,10 +918,6 @@
       this.handleRegisterRoute(ctx)
     );
 
-    this.mapRoute(RoutePattern.LOG_IN_OR_OUT, 'handlePassThroughRoute', () =>
-      this.handlePassThroughRoute()
-    );
-
     this.mapRoute(
       RoutePattern.IMPROPERLY_ENCODED_PLUS,
       'handleImproperlyEncodedPlusRoute',
@@ -1294,6 +1301,14 @@
     this.redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
   }
 
+  handleChangeNumberLegacyRoute(ctx: PageContext) {
+    this.redirect(
+      '/c/' +
+        ctx.params[0] +
+        (ctx.querystring.length > 0 ? `?${ctx.querystring}` : '')
+    );
+  }
+
   handleChangeRoute(ctx: PageContext) {
     // Parameter order is based on the regex group number matched.
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
@@ -1339,7 +1354,7 @@
     const repo = ctx.params[0] as RepoName;
     const commentId = ctx.params[2] as UrlEncodedCommentId;
 
-    this.restApiService.setInProjectLookup(changeNum, repo);
+    this.restApiService.addRepoNameToCache(changeNum, repo);
     const [comments, robotComments, drafts, change] = await Promise.all([
       this.restApiService.getDiffComments(changeNum),
       this.restApiService.getDiffRobotComments(changeNum),
@@ -1446,6 +1461,26 @@
     this.changeViewModel.setState(state);
   }
 
+  handleChangeLegacyRoute(ctx: PageContext) {
+    const changeNum = Number(ctx.params[0]) as NumericChangeId;
+    if (!changeNum) {
+      this.show404();
+      return;
+    }
+    this.restApiService.getRepoName(changeNum).then(project => {
+      // Show a 404 and terminate if the lookup request failed. Attempting
+      // to redirect after failing to get the project loops infinitely.
+      if (!project) {
+        this.show404();
+        return;
+      }
+      this.redirect(
+        `/c/${project}/+/${changeNum}/${ctx.params[1]}` +
+          (ctx.querystring.length > 0 ? `?${ctx.querystring}` : '')
+      );
+    });
+  }
+
   handleLegacyLinenum(ctx: PageContext) {
     this.redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
   }
@@ -1547,14 +1582,6 @@
   }
 
   /**
-   * Handler for routes that should pass through the router and not be caught
-   * by the catchall _handleDefaultRoute handler.
-   */
-  handlePassThroughRoute() {
-    windowLocationReload();
-  }
-
-  /**
    * URL may sometimes have /+/ encoded to / /.
    * Context: Issue 6888, Issue 7100
    */
@@ -1609,10 +1636,15 @@
       this.show404();
     } else {
       // Route can be recognized by server, so we pass it to server.
-      this.handlePassThroughRoute();
+      this.windowReload();
     }
   }
 
+  // Allows stubbing in tests.
+  windowReload() {
+    windowLocationReload();
+  }
+
   private show404() {
     // Note: the app's 404 display is tightly-coupled with catching 404
     // network responses, so we simulate a 404 response status to display it.
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index ae45326..6f4e528 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -169,17 +169,18 @@
     const unauthenticatedHandlers = [
       'handleBranchListRoute',
       'handleChangeIdQueryRoute',
+      'handleChangeNumberLegacyRoute',
       'handleChangeRoute',
       'handleCommentRoute',
       'handleCommentsRoute',
       'handleDiffRoute',
       'handleDefaultRoute',
+      'handleChangeLegacyRoute',
       'handleDocumentationRedirectRoute',
       'handleDocumentationSearchRoute',
       'handleDocumentationSearchRedirectRoute',
       'handleLegacyLinenum',
       'handleImproperlyEncodedPlusRoute',
-      'handlePassThroughRoute',
       'handleProjectDashboardRoute',
       'handleLegacyProjectDashboardRoute',
       'handleProjectsOldRoute',
@@ -261,7 +262,7 @@
     let urlPromise: MockPromise<string>;
 
     setup(() => {
-      stubRestApi('setInProjectLookup');
+      stubRestApi('addRepoNameToCache');
       urlPromise = mockPromise<string>();
       redirectStub = sinon
         .stub(router, 'redirect')
@@ -332,7 +333,7 @@
   suite('route handlers', () => {
     let redirectStub: sinon.SinonStub;
     let setStateStub: sinon.SinonStub;
-    let handlePassThroughRoute: sinon.SinonStub;
+    let windowReloadStub: sinon.SinonStub;
     let redirectToLoginStub: sinon.SinonStub;
 
     async function checkUrlToState<T extends ViewState>(
@@ -365,18 +366,12 @@
       assert.equal(redirectToLoginStub.lastCall.firstArg, toUrl);
     }
 
-    async function checkUrlNotMatched(url: string) {
-      handlePassThroughRoute.reset();
-      router.page.show(url);
-      await waitUntilCalled(handlePassThroughRoute, 'handlePassThroughRoute');
-    }
-
     setup(() => {
-      stubRestApi('setInProjectLookup');
+      stubRestApi('addRepoNameToCache');
       redirectStub = sinon.stub(router, 'redirect');
       redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
       setStateStub = sinon.stub(router, 'setState');
-      handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
+      windowReloadStub = sinon.stub(router, 'windowReload');
       router._testOnly_startRouter();
     });
 
@@ -443,7 +438,7 @@
       onExit!('', () => {}); // we left page;
 
       router.handleDefaultRoute();
-      assert.isTrue(handlePassThroughRoute.calledOnce);
+      assert.isTrue(windowReloadStub.calledOnce);
     });
 
     test('IMPROPERLY_ENCODED_PLUS', async () => {
@@ -854,6 +849,21 @@
     });
 
     suite('CHANGE* / DIFF*', () => {
+      test('CHANGE_NUMBER_LEGACY', async () => {
+        // CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
+        await checkRedirect('/12345', '/c/12345');
+      });
+
+      test('CHANGE_LEGACY', async () => {
+        // CHANGE_LEGACY: /^\/c\/(\d+)\/?(.*)$/,
+        stubRestApi('getRepoName').resolves('project' as RepoName);
+        await checkRedirect('/c/1234', '/c/project/+/1234/');
+        await checkRedirect(
+          '/c/1234/comment/6789',
+          '/c/project/+/1234/comment/6789'
+        );
+      });
+
       test('DIFF_LEGACY_LINENUM', async () => {
         await checkRedirect(
           '/c/1234/3..8/foo/bar@321',
@@ -1022,12 +1032,6 @@
       });
     });
 
-    test('LOG_IN_OR_OUT pass through', async () => {
-      // LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
-      await checkUrlNotMatched('/login/asdf');
-      await checkUrlNotMatched('/logout/asdf');
-    });
-
     test('PLUGIN_SCREEN', async () => {
       // PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
       await checkUrlToState('/x/foo/bar', {
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 98e9eba..368eb22 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -126,7 +126,11 @@
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 
-const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
+// 3 types of tokens
+// 1. predicate:expression (?:[^\s":]+:\s*[^\s"]+)
+// 2. quotes with anything inside "[^"]*"
+// 3. anything else like unfinished predicate [^\s"]+
+const TOKENIZE_REGEX = /(?:(?:[^\s":]+:\s*[^\s"]+)|[^\s"]+|"[^"]*")+\s*/g;
 
 export type SuggestionProvider = (
   predicate: string,
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
index f67024f..bc8da05 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
@@ -256,6 +256,18 @@
       const s = await element.getSearchSuggestions('is:mergeab');
       assert.isEmpty(s);
     });
+
+    test('Autocompletes correctly second condition', async () => {
+      const s = await element.getSearchSuggestions('is:open me');
+      assert.equal(s[0].value, 'mergedafter:');
+    });
+
+    test('Autocomplete handles space before expression correctly', async () => {
+      // This previously suggested "mergedafter" (incorrectly) due to the
+      // leading space.
+      const s = await element.getSearchSuggestions('author: me');
+      assert.isEmpty(s);
+    });
   });
 
   [
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
index 8b4b52f..48f5a05 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -172,7 +172,7 @@
       return Promise.resolve([]);
     }
     return this.restApiService
-      .getSuggestedAccounts(
+      .queryAccounts(
         expression,
         MAX_AUTOCOMPLETE_RESULTS,
         /* canSee=*/ undefined,
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
index 7e3b896..0551f23 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
@@ -25,7 +25,7 @@
   });
 
   test('Autocompletes accounts', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
+    stubRestApi('queryAccounts').callsFake(() =>
       Promise.resolve([
         {
           name: 'fred',
@@ -39,7 +39,7 @@
   });
 
   test('Inserts self as option when valid', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
+    stubRestApi('queryAccounts').callsFake(() =>
       Promise.resolve([
         {
           name: 'fred',
@@ -60,7 +60,7 @@
   });
 
   test('Inserts me as option when valid', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
+    stubRestApi('queryAccounts').callsFake(() =>
       Promise.resolve([
         {
           name: 'fred',
@@ -118,7 +118,7 @@
   });
 
   test('Autocompletes accounts with no email', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
+    stubRestApi('queryAccounts').callsFake(() =>
       Promise.resolve([{name: 'fred'}])
     );
     return element.fetchAccounts('owner', 'fr').then(s => {
@@ -127,7 +127,7 @@
   });
 
   test('Autocompletes accounts with email', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
+    stubRestApi('queryAccounts').callsFake(() =>
       Promise.resolve([{email: 'fred@goog.co' as EmailAddress}])
     );
     return element.fetchAccounts('owner', 'fr').then(s => {
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 7e6e23b..0a31677 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
@@ -315,6 +315,8 @@
           fixSuggestion.replacements
         );
       } else {
+        // TODO(b/227463363) Remove once Robot Comments are deprecated.
+        // We don't use this for user suggestions or comments.fix_suggestions.
         res = await this.restApiService.getRobotCommentFixPreview(
           this.changeNum,
           this.patchNum,
@@ -421,7 +423,7 @@
         this.currentFix.fix_id
       );
     }
-    if (res && res.ok) {
+    if (res?.ok) {
       this.getNavigation().setUrl(
         createChangeUrl({
           change,
@@ -432,7 +434,10 @@
       this.close(true);
     }
     this.isApplyFixLoading = false;
-    this.reporting.timeEnd(Timing.APPLY_FIX_LOAD);
+    this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, {
+      method: 'apply-fix-dialog',
+      description: this.fixSuggestions?.[0].description,
+    });
   }
 }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
index 7fc1044..739468b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
@@ -7,9 +7,13 @@
 import './gr-diff-preferences-dialog';
 import {GrDiffPreferencesDialog} from './gr-diff-preferences-dialog';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
-import {queryAndAssert, stubRestApi, waitUntil} from '../../../test/test-utils';
+import {
+  makePrefixedJSON,
+  queryAndAssert,
+  stubRestApi,
+  waitUntil,
+} from '../../../test/test-utils';
 import {DiffPreferencesInfo} from '../../../api/diff';
-import {ParsedJSON} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fixture, html, assert} from '@open-wc/testing';
 
@@ -95,11 +99,13 @@
     assert.isTrue(element.diffPrefsChanged);
     assert.isTrue(originalDiffPrefs.line_wrapping);
 
-    stubRestApi('getResponseObject').returns(
-      Promise.resolve({
-        ...originalDiffPrefs,
-        line_wrapping: false,
-      } as unknown as ParsedJSON)
+    stubRestApi('saveDiffPreferences').resolves(
+      new Response(
+        makePrefixedJSON({
+          ...originalDiffPrefs,
+          line_wrapping: false,
+        })
+      )
     );
 
     queryAndAssert<GrButton>(element, '#saveButton').click();
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 acb1ef3..bc2d3b5 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
@@ -49,6 +49,7 @@
   RevisionPatchSetNum,
   Comment,
   CommentMap,
+  DropdownLink,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo, WebLinkInfo} from '../../../types/diff';
 import {ParsedChangeInfo} from '../../../types/types';
@@ -105,13 +106,18 @@
 } from '../../../models/change/files-model';
 import {isImageDiff} from '../../../utils/diff-util';
 import {formStyles} from '../../../styles/form-styles';
+import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
+import {configModelToken} from '../../../models/config/config-model';
 
-const LOADING_BLAME = 'Loading blame...';
+const LOADING_BLAME = 'Loading blame information. This may take a while ...';
 const LOADED_BLAME = 'Blame loaded';
 
 // Time in which pressing n key again after the toast navigates to next file
 const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
 
+// Files larger than this cannot be downloaded.
+const FILE_DOWNLOAD_LIMIT_BYTES = 50 * 1000 * 1000;
+
 // visible for testing
 export interface Files {
   /** All file paths sorted by `specialFilePathCompare`. */
@@ -195,12 +201,18 @@
 
   @state() path?: string;
 
+  @state() file?: NormalizedFileInfo;
+
   @state() private shownSidebar?: string;
 
   /** Allows us to react when the user switches to the DIFF view. */
   // Private but used in tests.
   @state() isActiveChildView = false;
 
+  // Whether to allow the "Show Blame button"
+  @state()
+  allowBlame = false;
+
   // Private but used in tests.
   @state()
   loggedIn = false;
@@ -254,6 +266,8 @@
 
   private readonly getViewModel = resolve(this, changeViewModelToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   private throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
 
   @state()
@@ -415,6 +429,11 @@
     );
     subscribe(
       this,
+      () => this.getFilesModel().file$(this.getViewModel().diffPath$),
+      file => (this.file = file)
+    );
+    subscribe(
+      this,
       () => this.getViewModel().diffLine$,
       line => (this.focusLineNum = line)
     );
@@ -445,6 +464,13 @@
       }
     );
 
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      serverConfig =>
+        (this.allowBlame = serverConfig?.change.allow_blame ?? false)
+    );
+
     // When user initially loads the diff view, we want to automatically mark
     // the file as reviewed if they have it enabled. We can't observe these
     // properties since the method will be called anytime a property updates
@@ -924,13 +950,12 @@
           <gr-endpoint-param
             name="onTrigger"
             .value=${(pluginName: string) => {
-              this.shownSidebar =
-                this.shownSidebar === pluginName ? undefined : pluginName;
+              const closeSidebar = this.shownSidebar === pluginName;
+              this.shownSidebar = closeSidebar ? undefined : pluginName;
               this.getUserModel().updatePreferences({
-                diff_page_sidebar:
-                  this.shownSidebar === pluginName
-                    ? 'NONE'
-                    : `plugin-${pluginName}`,
+                diff_page_sidebar: closeSidebar
+                  ? 'NONE'
+                  : `plugin-${pluginName}`,
               });
             }}
           ></gr-endpoint-param>
@@ -1022,6 +1047,7 @@
       <gr-patch-range-select
         id="rangeSelect"
         .filesWeblinks=${this.filesWeblinks}
+        .path=${this.path}
         @patch-range-change=${this.handlePatchChange}
       >
       </gr-patch-range-select>
@@ -1031,6 +1057,9 @@
           link=""
           down-arrow=""
           .items=${this.computeDownloadDropdownLinks()}
+          .disabledIds=${this.isTooLargeForDownload()
+            ? ['left-content', 'right-content']
+            : []}
           horizontal-align="left"
         >
           <span class="downloadTitle"> Download </span>
@@ -1040,26 +1069,9 @@
   }
 
   private renderRightControls() {
-    const blameLoaderClass =
-      !isMagicPath(this.path) && !isImageDiff(this.diff) ? 'show' : '';
-    const blameToggleLabel =
-      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=""
-          id="toggleBlame"
-          title=${this.createTitle(
-            Shortcut.TOGGLE_BLAME,
-            ShortcutSection.DIFFS
-          )}
-          ?disabled=${this.isBlameLoading}
-          @click=${this.toggleBlame}
-          >${blameToggleLabel}</gr-button
-        >
-      </span>
+      ${this.renderSidebarTriggers()} ${this.renderBlameButton()}
       ${when(
         this.computeCanEdit(),
         () => html`
@@ -1129,6 +1141,26 @@
     </div>`;
   }
 
+  private renderBlameButton() {
+    if (!this.allowBlame) return;
+    const blameLoaderClass =
+      !isMagicPath(this.path) && !isImageDiff(this.diff) ? 'show' : '';
+    let blameToggleLabel = 'Loading blame ...';
+    if (!this.isBlameLoading) {
+      blameToggleLabel = this.isBlameLoaded ? 'Hide blame' : 'Show blame';
+    }
+    return html` <span class="blameLoader ${blameLoaderClass}">
+      <gr-button
+        link=""
+        id="toggleBlame"
+        title=${this.createTitle(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS)}
+        ?disabled=${this.isBlameLoading}
+        @click=${this.toggleBlame}
+        >${blameToggleLabel}</gr-button
+      >
+    </span>`;
+  }
+
   private renderDialogs() {
     return html`
       <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog>
@@ -1611,14 +1643,18 @@
     this.updateUrlToDiffUrl(lineNumber as number, e.detail.side === Side.LEFT);
   }
 
+  private isTooLargeForDownload() {
+    return (this.file?.size ?? 0) > FILE_DOWNLOAD_LIMIT_BYTES;
+  }
+
   // Private but used in tests.
-  computeDownloadDropdownLinks() {
+  computeDownloadDropdownLinks(): DropdownLink[] {
     if (!this.change?.project) return [];
     if (!this.changeNum) return [];
     if (!this.patchRange) return [];
     if (!this.path) return [];
 
-    const links = [
+    const links: DropdownLink[] = [
       {
         url: this.computeDownloadPatchLink(
           this.change.project,
@@ -1630,34 +1666,45 @@
       },
     ];
 
-    if (this.diff && this.diff.meta_a) {
-      let leftPath = this.path;
-      if (this.diff.change_type === 'RENAMED') {
-        leftPath = this.diff.meta_a.name;
+    if (this.isTooLargeForDownload()) {
+      links.push({
+        id: 'left-content',
+        name: 'Left Content (Too Large)',
+      });
+      links.push({
+        id: 'right-content',
+        name: 'Right Content (Too Large)',
+      });
+    } else {
+      if (this.diff && this.diff.meta_a) {
+        let leftPath = this.path;
+        if (this.diff.change_type === 'RENAMED') {
+          leftPath = this.diff.meta_a.name;
+        }
+        links.push({
+          url: this.computeDownloadFileLink(
+            this.change.project,
+            this.changeNum,
+            this.patchRange,
+            leftPath,
+            true
+          ),
+          name: 'Left Content',
+        });
       }
-      links.push({
-        url: this.computeDownloadFileLink(
-          this.change.project,
-          this.changeNum,
-          this.patchRange,
-          leftPath,
-          true
-        ),
-        name: 'Left Content',
-      });
-    }
 
-    if (this.diff && this.diff.meta_b) {
-      links.push({
-        url: this.computeDownloadFileLink(
-          this.change.project,
-          this.changeNum,
-          this.patchRange,
-          this.path,
-          false
-        ),
-        name: 'Right Content',
-      });
+      if (this.diff && this.diff.meta_b) {
+        links.push({
+          url: this.computeDownloadFileLink(
+            this.change.project,
+            this.changeNum,
+            this.patchRange,
+            this.path,
+            false
+          ),
+          name: 'Right Content',
+        });
+      }
     }
 
     return links;
@@ -1742,12 +1789,13 @@
    * Otherwise hide it.
    */
   private toggleBlame() {
+    if (!this.allowBlame) return;
     assertIsDefined(this.diffHost, 'diffHost');
     if (this.isBlameLoaded) {
       this.diffHost.clearBlame();
-      return;
+    } else {
+      this.loadBlame();
     }
-    this.loadBlame();
   }
 
   private handleToggleHideAllCommentThreads() {
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 6bf1adc..28fcfb8 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -98,6 +98,9 @@
   @state()
   changeNum?: NumericChangeId;
 
+  @property()
+  path?: string;
+
   @property({type: Object})
   filesWeblinks?: FilesWebLinks;
 
@@ -349,6 +352,7 @@
       value: patchNum,
       commentThreads: this.changeComments?.computeCommentThreads(
         {
+          path: this.path,
           patchNum,
         },
         true
@@ -429,6 +433,7 @@
 
     const commentThreadCount = this.changeComments.computeCommentThreads(
       {
+        path: this.path,
         patchNum,
       },
       true
@@ -436,7 +441,10 @@
     const commentThreadString = pluralize(commentThreadCount, 'comment');
 
     const unresolvedCount = this.changeComments.computeUnresolvedNum(
-      {patchNum},
+      {
+        path: this.path,
+        patchNum,
+      },
       true
     );
     const unresolvedString =
@@ -487,10 +495,12 @@
     if (target === this.patchNumDropdown) {
       if (detail.patchNum === patchSetValue) return;
       this.reporting.reportInteraction('right-patchset-changed', {
+        path: this.path,
         previous: detail.patchNum,
         current: patchSetValue,
         latest: latestPatchNum,
         commentCount: this.changeComments?.computeCommentThreads({
+          path: this.path,
           patchNum: patchSetValue,
         }).length,
       });
@@ -498,9 +508,11 @@
     } else {
       if (detail.basePatchNum === patchSetValue) return;
       this.reporting.reportInteraction('left-patchset-changed', {
+        path: this.path,
         previous: detail.basePatchNum,
         current: patchSetValue,
         commentCount: this.changeComments?.computeCommentThreads({
+          path: this.path,
           patchNum: patchSetValue,
         }).length,
       });
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index e7ed97b..65c6f1a 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -384,6 +384,14 @@
       ' (3 comments, 1 unresolved)'
     );
 
+    // Test string for specific file path.
+    element.path = 'foo';
+    assert.equal(
+      element.computePatchSetCommentsString(1 as PatchSetNum),
+      ' (1 comment, 1 unresolved)'
+    );
+    element.path = undefined;
+
     // Test string with no unresolved comments.
     delete comments['foo'];
     element.changeComments = new ChangeComments(comments);
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index dc3bf7b..a54c8bc 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -479,8 +479,8 @@
 
       this.showAlert(PUBLISHING_EDIT_MSG);
 
-      // restApiService has some quirks where it will still call .then() with
-      // undefined or Response status 429 when it hits an error.
+      // restApiService return undefined if server response with non-200 error
+      // code.
       this.restApiService
         .executeChangeAction(
           changeNum,
@@ -491,10 +491,7 @@
           handleError
         )
         .then(res => {
-          if (
-            res === undefined ||
-            (res instanceof Response && res.status === 429)
-          ) {
+          if (res === undefined) {
             // In an error case we should not navigate and lose edits.
             return;
           }
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index c86f02f..e20d66d 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -291,7 +291,7 @@
     test('file modification and publish', async () => {
       const saveSpy = sinon.spy(element, 'saveEdit');
       const alertStub = sinon.stub(element, 'showAlert');
-      const changeActionsStub = stubRestApi('executeChangeAction');
+      const changeActionsStub = stubRestApi('executeChangeAction').resolves();
       saveFileStub.returns(Promise.resolve({ok: true}));
       element.newContent = newText;
       await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
index d1a9932..e1641b0 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -47,7 +47,7 @@
 
   @state() private originalEditPrefs?: EditPreferencesInfo;
 
-  private readonly getUserModel = resolve(this, userModelToken);
+  readonly getUserModel = resolve(this, userModelToken);
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts
index bd682b8..d45ff59 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.ts
@@ -5,9 +5,14 @@
  */
 import '../../../test/common-test-setup';
 import './gr-edit-preferences';
-import {queryAll, stubRestApi} from '../../../test/test-utils';
+import {
+  makePrefixedJSON,
+  queryAll,
+  stubRestApi,
+  waitUntil,
+} from '../../../test/test-utils';
 import {GrEditPreferences} from './gr-edit-preferences';
-import {EditPreferencesInfo, ParsedJSON} from '../../../types/common';
+import {EditPreferencesInfo} from '../../../types/common';
 import {IronInputElement} from '@polymer/iron-input';
 import {createDefaultEditPrefs} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
@@ -194,16 +199,18 @@
 
     assert.isTrue(element.hasUnsavedChanges());
 
-    const getResponseObjStub = stubRestApi('getResponseObject').returns(
-      Promise.resolve(element.editPrefs! as unknown as ParsedJSON)
+    const savePrefStub = stubRestApi('saveEditPreferences').resolves(
+      new Response(makePrefixedJSON(element.editPrefs))
     );
 
     await element.save();
+    // Wait for model state update, since this is not awaited by element.save()
+    await waitUntil(
+      () => !element.getUserModel().getState().editPreferences?.show_tabs
+    );
 
-    assert.isTrue(getResponseObjStub.called);
-
+    assert.isTrue(savePrefStub.called);
     assert.isFalse(element.editPrefs?.show_tabs);
-
     assert.isFalse(element.hasUnsavedChanges());
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
index 47d992f..985f9be 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
@@ -24,13 +24,16 @@
   generatedPasswordModal?: HTMLDialogElement;
 
   @property({type: String})
-  _username?: string;
+  username?: string;
 
   @property({type: String})
-  _generatedPassword?: string;
+  generatedPassword?: string;
 
   @property({type: String})
-  _passwordUrl: string | null = null;
+  status?: string;
+
+  @property({type: String})
+  passwordUrl: string | null = null;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -45,7 +48,7 @@
     promises.push(
       this.restApiService.getAccount().then(account => {
         if (account) {
-          this._username = account.username;
+          this.username = account.username;
         }
       })
     );
@@ -53,9 +56,9 @@
     promises.push(
       this.restApiService.getConfig().then(info => {
         if (info) {
-          this._passwordUrl = info.auth.http_password_url || null;
+          this.passwordUrl = info.auth.http_password_url || null;
         } else {
-          this._passwordUrl = null;
+          this.passwordUrl = null;
         }
       })
     );
@@ -104,18 +107,18 @@
 
   override render() {
     return html` <div class="gr-form-styles">
-        <div ?hidden=${!!this._passwordUrl}>
+        <div ?hidden=${!!this.passwordUrl}>
           <section>
             <span class="title">Username</span>
-            <span class="value">${this._username ?? ''}</span>
+            <span class="value">${this.username ?? ''}</span>
           </section>
           <gr-button id="generateButton" @click=${this._handleGenerateTap}
             >Generate new password</gr-button
           >
         </div>
-        <span ?hidden=${!this._passwordUrl}>
+        <span ?hidden=${!this.passwordUrl}>
           <a
-            href=${this._passwordUrl!}
+            href=${this.passwordUrl!}
             target="_blank"
             rel="noopener noreferrer"
           >
@@ -132,12 +135,12 @@
         <div class="gr-form-styles">
           <section id="generatedPasswordDisplay">
             <span class="title">New Password:</span>
-            <span class="value">${this._generatedPassword}</span>
+            <span class="value">${this.status || this.generatedPassword}</span>
             <gr-copy-clipboard
               hasTooltip=""
               buttonTitle="Copy password to clipboard"
               hideInput=""
-              .text=${this._generatedPassword}
+              .text=${this.status ? '' : this.generatedPassword}
             >
             </gr-copy-clipboard>
           </section>
@@ -153,10 +156,15 @@
   }
 
   _handleGenerateTap() {
-    this._generatedPassword = 'Generating...';
+    this.status = 'Generating...';
     this.generatedPasswordModal?.showModal();
     this.restApiService.generateAccountHttpPassword().then(newPassword => {
-      this._generatedPassword = newPassword;
+      if (newPassword) {
+        this.generatedPassword = newPassword;
+        this.status = undefined;
+      } else {
+        this.status = 'Failed to generate';
+      }
     });
   }
 
@@ -165,6 +173,7 @@
   }
 
   _generatedPasswordModalClosed() {
-    this._generatedPassword = '';
+    this.status = undefined;
+    this.generatedPassword = '';
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
index 7fe3be6..1abf8a7 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
@@ -102,27 +102,27 @@
         })
     );
 
-    assert.isNotOk(element._generatedPassword);
+    assert.isNotOk(element.generatedPassword);
 
     button.click();
 
     assert.isTrue(generateStub.called);
-    assert.equal(element._generatedPassword, 'Generating...');
+    assert.equal(element.status, 'Generating...');
 
     generateStub.lastCall.returnValue.then(() => {
       generateResolve(nextPassword);
-      assert.equal(element._generatedPassword, nextPassword);
+      assert.equal(element.generatedPassword, nextPassword);
     });
   });
 
   test('without http_password_url', () => {
-    assert.isNull(element._passwordUrl);
+    assert.isNull(element.passwordUrl);
   });
 
   test('with http_password_url', async () => {
     config.auth.http_password_url = 'http://example.com/';
     await element.loadData();
-    assert.isNotNull(element._passwordUrl);
-    assert.equal(element._passwordUrl, config.auth.http_password_url);
+    assert.isNotNull(element.passwordUrl);
+    assert.equal(element.passwordUrl, config.auth.http_password_url);
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 4d4834e..29ece75 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -71,6 +71,8 @@
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getDocUrl, rootUrl} from '../../../utils/url-util';
 import {configModelToken} from '../../../models/config/config-model';
+import {SuggestionsProvider} from '../../../api/suggestions';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 const HTTP_AUTH = ['HTTP', 'HTTP_LDAP'];
 
@@ -118,6 +120,9 @@
   @query('#allowBrowserNotifications')
   allowBrowserNotifications?: HTMLInputElement;
 
+  @query('#allowSuggestCodeWhileCommenting')
+  allowSuggestCodeWhileCommenting?: HTMLInputElement;
+
   @query('#disableKeyboardShortcuts')
   disableKeyboardShortcuts!: HTMLInputElement;
 
@@ -196,6 +201,9 @@
 
   @state() private docsBaseUrl = '';
 
+  @state()
+  suggestionsProvider?: SuggestionsProvider;
+
   // private but used in test
   public _testOnly_loadingPromise?: Promise<void>;
 
@@ -212,6 +220,8 @@
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
   constructor() {
     super();
     subscribe(
@@ -265,6 +275,14 @@
     // we need to manually calling scrollIntoView when hash changed
     document.addEventListener('location-change', this.handleLocationChange);
     fireTitleChange('Settings');
+    this.getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        const suggestionsPlugins =
+          this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
+        // We currently support results from only 1 provider.
+        this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
+      });
   }
 
   override firstUpdated() {
@@ -451,6 +469,7 @@
             ${this.renderTheme()} ${this.renderChangesPerPages()}
             ${this.renderDateTimeFormat()} ${this.renderEmailNotification()}
             ${this.renderEmailFormat()} ${this.renderBrowserNotifications()}
+            ${this.renderGenerateSuggestionWhenCommenting()}
             ${this.renderDefaultBaseForMerges()}
             ${this.renderRelativeDateInChangeTable()} ${this.renderDiffView()}
             ${this.renderShowSizeBarsInFileList()}
@@ -867,6 +886,45 @@
     `;
   }
 
+  private renderGenerateSuggestionWhenCommenting() {
+    if (
+      !this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
+      !this.suggestionsProvider
+    )
+      return nothing;
+    return html`
+      <section id="allowSuggestCodeWhileCommentingSection">
+        <div class="title">
+          <label for="allowSuggestCodeWhileCommenting"
+            >Allow generating suggestions while commenting</label
+          >
+          <a
+            href=${getDocUrl(
+              this.docsBaseUrl,
+              'user-suggest-edits.html#_generate_suggestion'
+            )}
+            target="_blank"
+            rel="noopener noreferrer"
+          >
+            <gr-icon icon="help" title="read documentation"></gr-icon>
+          </a>
+        </div>
+        <span class="value">
+          <input
+            id="allowSuggestCodeWhileCommenting"
+            type="checkbox"
+            ?checked=${this.localPrefs.allow_suggest_code_while_commenting}
+            @change=${() => {
+              this.localPrefs.allow_suggest_code_while_commenting =
+                this.allowSuggestCodeWhileCommenting!.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
   private renderDefaultBaseForMerges() {
     if (!this.localPrefs.default_base_for_merges) return nothing;
     return nothing;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index d7c6c8b..9709df2 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -119,6 +119,9 @@
         gr-icon {
           font-size: 1.2rem;
         }
+        gr-icon[icon='close'] {
+          margin-top: var(--spacing-xxs);
+        }
         .container gr-account-label::part(gr-account-label-text) {
           color: var(--deemphasized-text-color);
         }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index cf7ff2209..4c73fcf 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -192,7 +192,12 @@
   override async updated() {
     assertIsDefined(this.account, 'account');
     const account = await this.getAccountsModel().fillDetails(this.account);
-    if (account) this.account = account;
+    // AccountInfo returned by fillDetails has the email property set
+    // to the primary email of the account. This poses a problem in
+    // cases where a secondary email is used as the committer or author
+    // email. Therefore, only fill in the missing details to avoid
+    // displaying incorrect author or committer email.
+    if (account) this.account = Object.assign(account, this.account);
   }
 
   override render() {
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 49d3c7c..5ec33d9 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -232,6 +232,7 @@
 
     this.reporting.reportInteraction(Interaction.BUTTON_CLICK, {
       path: getEventPath(e),
+      text: this.innerText,
     });
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
index 1cf05ab..145e39d 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
@@ -206,13 +206,14 @@
       assert.equal(reportStub.lastCall.args[0], 'button-click');
       assert.deepEqual(reportStub.lastCall.args[1], {
         path: 'html>body>div>gr-button',
+        text: '',
       });
     });
 
     test('report event after click on nested', async () => {
       const nestedElement = await fixture<HTMLDivElement>(html`
         <div id="test">
-          <gr-button class="testBtn"></gr-button>
+          <gr-button class="testBtn">Click Me</gr-button>
         </div>
       `);
       queryAndAssert<GrButton>(nestedElement, 'gr-button').click();
@@ -220,6 +221,7 @@
       assert.equal(reportStub.lastCall.args[0], 'button-click');
       assert.deepEqual(reportStub.lastCall.args[1], {
         path: 'html>body>div>div#test>gr-button.testBtn',
+        text: 'CLICK ME',
       });
     });
   });
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 1e91345..e9629b4 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -15,6 +15,7 @@
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import '../gr-account-label/gr-account-label';
 import '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import '../gr-fix-suggestions/gr-fix-suggestions';
 import {getAppContext} from '../../../services/app-context';
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
@@ -63,7 +64,11 @@
 import {Subject} from 'rxjs';
 import {debounceTime} from 'rxjs/operators';
 import {changeModelToken} from '../../../models/change/change-model';
-import {isBase64FileContent} from '../../../api/rest-api';
+import {
+  ChangeInfo,
+  FixSuggestionInfo,
+  isBase64FileContent,
+} from '../../../api/rest-api';
 import {createDiffUrl} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {modalStyles} from '../../../styles/gr-modal-styles';
@@ -75,11 +80,19 @@
 } from '../gr-comment-model/gr-comment-model';
 import {formStyles} from '../../../styles/form-styles';
 import {Interaction} from '../../../constants/reporting';
-import {Suggestion} from '../../../api/suggestions';
+import {Suggestion, SuggestionsProvider} from '../../../api/suggestions';
+import {when} from 'lit/directives/when.js';
+import {getDocUrl} from '../../../utils/url-util';
+import {configModelToken} from '../../../models/config/config-model';
+import {getFileExtension} from '../../../utils/file-util';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {deepEqual} from '../../../utils/deep-util';
 
 // visible for testing
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
-export const GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS = 1500;
+export const GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS = 500;
+export const ENABLE_GENERATE_SUGGESTION_STORAGE_KEY =
+  'enableGenerateSuggestionStorageKeyForCommentWithId-';
 
 declare global {
   interface HTMLElementEventMap {
@@ -87,6 +100,7 @@
     'comment-unresolved-changed': ValueChangedEvent<boolean>;
     'comment-text-changed': ValueChangedEvent<string>;
     'comment-anchor-tap': CustomEvent<CommentAnchorTapEventDetail>;
+    'apply-user-suggestion': CustomEvent;
   }
 }
 
@@ -211,7 +225,20 @@
   generatedSuggestion?: Suggestion;
 
   @state()
-  generatedReplacementId?: string;
+  generatedFixSuggestion: FixSuggestionInfo | undefined =
+    this.comment?.fix_suggestions?.[0];
+
+  @state()
+  generatedSuggestionId?: string;
+
+  @state()
+  addedGeneratedSuggestion?: string;
+
+  @state()
+  suggestionsProvider?: SuggestionsProvider;
+
+  @state()
+  suggestionLoading = false;
 
   @property({type: Boolean, attribute: 'show-patchset'})
   showPatchset = false;
@@ -231,6 +258,8 @@
   @state()
   commentedText?: string;
 
+  @state() private docsBaseUrl = '';
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
@@ -243,6 +272,10 @@
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getStorage = resolve(this, storageServiceToken);
+
   private readonly flagsService = getAppContext().flagsService;
 
   private readonly shortcuts = new ShortcutController(this);
@@ -284,8 +317,16 @@
     for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
       this.shortcuts.addLocal(
         {key: Key.ENTER, modifiers: [modifier]},
-        () => {
+        e => {
           this.save();
+          // We don't stop propagation for patchset comment
+          // (this.permanentEditingMode = true), but we stop it for normal
+          // comments. This prevents accidentally sending a reply when
+          // editing/saving them in the reply dialog.
+          if (!this.permanentEditingMode) {
+            e.preventDefault();
+            e.stopPropagation();
+          }
         },
         {preventDefault: false}
       );
@@ -297,6 +338,9 @@
         this.save();
       });
     }
+    this.addEventListener('apply-user-suggestion', () => {
+      this.handleAppliedFix();
+    });
     this.addEventListener('open-user-suggest-preview', e => {
       this.handleShowFix(e.detail.code);
     });
@@ -338,7 +382,15 @@
         this.autoSave();
       }
     );
-    if (this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT)) {
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
+    if (
+      this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
+      this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
+    ) {
       subscribe(
         this,
         () =>
@@ -346,14 +398,47 @@
             debounceTime(GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS)
           ),
         () => {
-          if (this.generateSuggestion) {
-            this.generateSuggestEdit();
+          this.generateSuggestEdit();
+        }
+      );
+      subscribe(
+        this,
+        () => this.getUserModel().preferences$,
+        prefs => {
+          if (
+            this.generateSuggestion !==
+            !!prefs.allow_suggest_code_while_commenting
+          ) {
+            this.generateSuggestion =
+              !!prefs.allow_suggest_code_while_commenting;
           }
         }
       );
     }
   }
 
+  override connectedCallback() {
+    super.connectedCallback();
+    this.getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        const suggestionsPlugins =
+          this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
+        // We currently support results from only 1 provider.
+        this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
+      });
+
+    if (this.comment?.id) {
+      const generateSuggestionStoredContent =
+        this.getStorage().getEditableContentItem(
+          ENABLE_GENERATE_SUGGESTION_STORAGE_KEY + this.comment.id
+        );
+      if (generateSuggestionStoredContent?.message === 'false') {
+        this.generateSuggestion = false;
+      }
+    }
+  }
+
   override disconnectedCallback() {
     // Clean up emoji dropdown.
     if (this.textarea) this.textarea.closeDropdown();
@@ -550,6 +635,16 @@
           color: var(--selected-foreground);
           margin-right: var(--spacing-xl);
         }
+        /* The basics of .loadingSpin are defined in shared styles. */
+        .loadingSpin {
+          width: calc(var(--line-height-normal) - 2px);
+          height: calc(var(--line-height-normal) - 2px);
+          display: inline-block;
+          vertical-align: top;
+          position: relative;
+          /* Making up for the 2px reduced height above. */
+          top: 1px;
+        }
       `,
     ];
   }
@@ -580,7 +675,8 @@
             <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
             ${this.renderHumanActions()} ${this.renderRobotActions()}
           </div>
-          ${this.renderGeneratedSuggestionPreview()}
+          ${/* if this.editing */ this.renderGeneratedSuggestionPreview()}
+          ${/* if !this.editing */ this.renderFixSuggestionPreview()}
         </div>
       </gr-endpoint-decorator>
       ${this.renderConfirmDialog()}
@@ -824,7 +920,7 @@
               <input
                 type="checkbox"
                 id="resolvedCheckbox"
-                ?checked=${!this.unresolved}
+                .checked=${!this.unresolved}
                 @change=${this.handleToggleResolved}
               />
               Resolved
@@ -916,61 +1012,98 @@
     `;
   }
 
-  private showGeneratedSuggestion() {
+  private renderFixSuggestionPreview() {
+    if (
+      !this.comment?.fix_suggestions ||
+      this.editing ||
+      isRobot(this.comment) ||
+      this.collapsed
+    )
+      return nothing;
+    return html`<gr-fix-suggestions
+      .comment=${this.comment}
+    ></gr-fix-suggestions>`;
+  }
+
+  // private but used in test
+  showGeneratedSuggestion() {
     return (
-      this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) &&
+      (this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
+        this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)) &&
+      this.suggestionsProvider &&
       this.editing &&
       !this.permanentEditingMode &&
       this.comment &&
+      this.comment.path &&
       this.comment.path !== SpecialFilePath.PATCHSET_LEVEL_COMMENTS &&
       this.comment.path !== SpecialFilePath.COMMIT_MESSAGE &&
+      (!this.suggestionsProvider.supportedFileExtensions ||
+        this.suggestionsProvider.supportedFileExtensions.includes(
+          getFileExtension(this.comment.path)
+        )) &&
       this.comment === this.comments?.[0] && // Is first comment
       (this.comment.range || this.comment.line) && // Disabled for File comments
-      !hasUserSuggestion(this.comment)
+      !hasUserSuggestion(this.comment) &&
+      this.getChangeModel().getChange()?.is_private !== true
     );
   }
 
   private renderGeneratedSuggestionPreview() {
     if (
+      !this.editing ||
       !this.showGeneratedSuggestion() ||
-      !this.generateSuggestion ||
-      !this.generatedSuggestion
+      !this.generateSuggestion
     )
       return nothing;
-    // TODO(milutin): This is temporary warning, will be removed, once we are
-    // able to change range of a comment
-    if (this.generatedSuggestion.newRange) {
-      const range = this.generatedSuggestion.newRange;
-      return html`<div class="info">
-        <gr-icon icon="info" filled></gr-icon>
-        There is a suggestion in range (${range.start_line}, ${range.end_line})
-      </div>`;
+    if (!isDraft(this.comment)) return nothing;
+
+    if (this.generatedFixSuggestion) {
+      return html`<gr-suggestion-diff-preview
+        .fixSuggestionInfo=${this.generatedFixSuggestion}
+      ></gr-suggestion-diff-preview>`;
+    } else if (this.generatedSuggestion) {
+      return html`<gr-suggestion-diff-preview
+        .showAddSuggestionButton=${true}
+        .suggestion=${this.generatedSuggestion?.replacement}
+        .uuid=${this.generatedSuggestionId}
+      ></gr-suggestion-diff-preview>`;
+    } else {
+      return nothing;
     }
-    return html`<gr-suggestion-diff-preview
-      .showAddSuggestionButton=${true}
-      .suggestion=${this.generatedSuggestion?.replacement}
-      .uuid=${this.generatedReplacementId}
-    ></gr-suggestion-diff-preview>`;
   }
 
   private renderGenerateSuggestEditButton() {
     if (!this.showGeneratedSuggestion()) {
       return nothing;
     }
-    const numberOfSuggestions = !this.generatedSuggestion ? '' : ' (1)';
+    const tooltip =
+      'Select to show a generated suggestion based on your comment for commented text. This suggestion can be inserted as a code block in your comment.';
     return html`
       <div class="action">
-        <label>
+        <label title=${tooltip}>
           <input
             type="checkbox"
             id="generateSuggestCheckbox"
             ?checked=${this.generateSuggestion}
             @change=${() => {
               this.generateSuggestion = !this.generateSuggestion;
-              if (!this.generateSuggestion) {
-                this.generatedSuggestion = undefined;
-              } else {
+              if (this.comment?.id) {
+                this.getStorage().setEditableContentItem(
+                  ENABLE_GENERATE_SUGGESTION_STORAGE_KEY + this.comment.id,
+                  this.generateSuggestion.toString()
+                );
+              }
+              if (this.generateSuggestion) {
                 this.generateSuggestionTrigger$.next();
+              } else {
+                if (
+                  this.flagsService.isEnabled(
+                    KnownExperimentId.ML_SUGGESTED_EDIT_V2
+                  )
+                ) {
+                  this.generatedFixSuggestion = undefined;
+                  this.autoSaveTrigger$.next();
+                }
               }
               this.reporting.reportInteraction(
                 this.generateSuggestion
@@ -979,60 +1112,166 @@
               );
             }}
           />
-          Generate Suggestion${numberOfSuggestions}
+          ${this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
+            ? 'Attach ML-suggested edit'
+            : 'Generate Suggestion'}
+          ${when(
+            this.suggestionLoading,
+            () => html`<span class="loadingSpin"></span>`,
+            () => html`${this.getNumberOfSuggestions()}`
+          )}
         </label>
+        <a
+          href=${this.suggestionsProvider?.getDocumentationLink?.() ||
+          getDocUrl(
+            this.docsBaseUrl,
+            'user-suggest-edits.html$_generate_suggestion'
+          )}
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          <gr-icon
+            icon="help"
+            title="About Generated Suggested Edits"
+          ></gr-icon>
+        </a>
       </div>
     `;
   }
 
-  private handleAddGeneratedSuggestion(code: string) {
-    const addNewLine = this.messageText.length !== 0;
-    this.messageText += `${
-      addNewLine ? '\n' : ''
-    }${USER_SUGGESTION_START_PATTERN}${code}${'\n```'}`;
+  private getNumberOfSuggestions() {
+    if (!this.generateSuggestion) {
+      return '';
+    }
+    if (this.generatedSuggestion || this.generatedFixSuggestion) {
+      return '(1)';
+    } else {
+      return '(0)';
+    }
   }
 
-  private async generateSuggestEdit() {
-    const suggestionsPlugins =
-      this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
-    if (suggestionsPlugins.length === 0) return;
+  private handleAddGeneratedSuggestion(code: string) {
+    const addNewLine = this.messageText.length !== 0;
+    this.addedGeneratedSuggestion = `${
+      addNewLine ? '\n' : ''
+    }${USER_SUGGESTION_START_PATTERN}${code}${'\n```'}`;
+    this.messageText += this.addedGeneratedSuggestion;
+  }
+
+  private generateSuggestEdit() {
+    if (this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)) {
+      this.generateSuggestEdit_v2();
+    } else if (
+      this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT)
+    ) {
+      this.generateSuggestEdit_v1();
+    }
+  }
+
+  private async generateSuggestEdit_v1() {
+    const suggestionsProvider = this.suggestionsProvider;
+    const changeInfo = this.getChangeModel().getChange();
     if (
+      !suggestionsProvider?.suggestCode ||
       !this.showGeneratedSuggestion() ||
-      !this.changeNum ||
+      !this.generateSuggestion ||
+      !changeInfo ||
       !this.comment ||
       !this.comment.patch_set ||
       !this.comment.path ||
       this.messageText.length === 0
     )
       return;
-    this.generatedReplacementId = uuid();
+    this.generatedSuggestionId = uuid();
     this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_REQUEST, {
-      uuid: this.generatedReplacementId,
+      uuid: this.generatedSuggestionId,
+      type: 'suggest-code',
+      commentId: this.comment.id,
     });
-    const suggestionResponse = await suggestionsPlugins[0].provider.suggestCode(
-      {
+    this.suggestionLoading = true;
+    let suggestionResponse;
+    try {
+      suggestionResponse = await suggestionsProvider.suggestCode({
         prompt: this.messageText,
-        changeNumber: this.changeNum,
+        changeInfo: changeInfo as ChangeInfo,
         patchsetNumber: this.comment?.patch_set,
         filePath: this.comment.path,
         range: this.comment.range,
         lineNumber: this.comment.line,
-      }
-    );
+      });
+    } finally {
+      this.suggestionLoading = false;
+    }
+
+    if (!suggestionResponse) return;
     // TODO(milutin): The suggestionResponse can contain multiple suggestion
     // options. We pick the first one for now. In future we shouldn't ignore
     // other suggestions.
     this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
-      uuid: this.generatedReplacementId,
+      uuid: this.generatedSuggestionId,
+      type: 'suggest-code',
       response: suggestionResponse.responseCode,
       numSuggestions: suggestionResponse.suggestions.length,
       hasNewRange: suggestionResponse.suggestions?.[0]?.newRange !== undefined,
     });
     const suggestion = suggestionResponse.suggestions?.[0];
-    if (!suggestion) return;
+    if (!suggestion?.replacement) return;
     this.generatedSuggestion = suggestion;
   }
 
+  private async generateSuggestEdit_v2() {
+    const suggestionsProvider = this.suggestionsProvider;
+    const changeInfo = this.getChangeModel().getChange();
+    if (
+      !suggestionsProvider?.suggestFix ||
+      !this.showGeneratedSuggestion() ||
+      !this.generateSuggestion ||
+      !changeInfo ||
+      !this.comment ||
+      !this.comment.patch_set ||
+      !this.comment.path ||
+      this.messageText.length === 0
+    )
+      return;
+    this.generatedSuggestionId = uuid();
+    this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_REQUEST, {
+      uuid: this.generatedSuggestionId,
+      type: 'suggest-fix',
+      commentId: this.comment.id,
+    });
+    this.suggestionLoading = true;
+    let suggestionResponse;
+    try {
+      suggestionResponse = await suggestionsProvider.suggestFix({
+        prompt: this.messageText,
+        changeInfo: changeInfo as ChangeInfo,
+        patchsetNumber: this.comment?.patch_set,
+        filePath: this.comment.path,
+        range: this.comment.range,
+        lineNumber: this.comment.line,
+      });
+    } finally {
+      this.suggestionLoading = false;
+    }
+
+    if (!suggestionResponse) return;
+    // TODO(milutin): The suggestionResponse can contain multiple suggestion
+    // options. We pick the first one for now. In future we shouldn't ignore
+    // other suggestions.
+    this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
+      uuid: this.generatedSuggestionId,
+      type: 'suggest-fix',
+      response: suggestionResponse.responseCode,
+      numSuggestions: suggestionResponse.fix_suggestions.length,
+    });
+    const suggestion = suggestionResponse.fix_suggestions?.[0];
+    if (!suggestion?.replacements || suggestion.replacements.length === 0) {
+      return;
+    }
+    this.generatedFixSuggestion = suggestion;
+    this.autoSaveTrigger$.next();
+  }
+
   private renderRobotActions() {
     if (!this.account || !isRobot(this.comment)) return;
     const endpoint = html`
@@ -1130,7 +1369,7 @@
     if (
       changed.has('changeNum') ||
       changed.has('comment') ||
-      changed.has('generatedReplacement')
+      changed.has('generatedSuggestion')
     ) {
       if (
         !this.changeNum ||
@@ -1231,7 +1470,11 @@
         ],
       };
     }
-    if (isRobot(this.comment) && this.comment.fix_suggestions.length > 0) {
+    if (
+      isRobot(this.comment) &&
+      this.comment.fix_suggestions &&
+      this.comment.fix_suggestions.length > 0
+    ) {
       const id = this.comment.robot_id;
       return {
         fixSuggestions: this.comment.fix_suggestions.map(s => {
@@ -1373,7 +1616,7 @@
     assert(isDraft(this.comment), 'only drafts are editable');
     const messageToSave = this.messageText.trimEnd();
     if (messageToSave === '') return;
-    if (messageToSave === this.comment.message) return;
+    if (!this.somethingToSave()) return;
 
     try {
       this.autoSaving = this.rawSave({showToast: false});
@@ -1388,13 +1631,19 @@
     await this.save();
   }
 
-  convertToCommentInput(): CommentInput | undefined {
+  async convertToCommentInputAndOrDiscard(): Promise<CommentInput | undefined> {
     if (!this.somethingToSave() || !this.comment) return;
-    return convertToCommentInput({
-      ...this.comment,
-      message: this.messageText.trimEnd(),
-      unresolved: this.unresolved,
-    });
+    const messageToSave = this.messageText.trimEnd();
+    if (messageToSave === '') {
+      await this.getCommentsModel().discardDraft(id(this.comment));
+      return undefined;
+    } else {
+      return convertToCommentInput({
+        ...this.comment,
+        message: this.messageText.trimEnd(),
+        unresolved: this.unresolved,
+      });
+    }
   }
 
   async save() {
@@ -1420,6 +1669,7 @@
     } else {
       // No need to make a backend call when nothing has changed.
       while (this.somethingToSave()) {
+        this.trackGeneratedSuggestionEdit();
         this.comment = await this.rawSave({showToast: true});
         if (isError(this.comment)) return;
       }
@@ -1431,7 +1681,8 @@
     return (
       isError(this.comment) ||
       this.messageText.trimEnd() !== this.comment?.message ||
-      this.unresolved !== this.comment.unresolved
+      this.unresolved !== this.comment.unresolved ||
+      !deepEqual(this.comment?.fix_suggestions, this.getFixSuggestions())
     );
   }
 
@@ -1444,11 +1695,20 @@
         ...this.comment,
         message: this.messageText.trimEnd(),
         unresolved: this.unresolved,
+        fix_suggestions: this.getFixSuggestions(),
       },
       options.showToast
     );
   }
 
+  getFixSuggestions(): FixSuggestionInfo[] | undefined {
+    if (!this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2))
+      return undefined;
+    if (!this.generateSuggestion) return undefined;
+    if (!this.generatedFixSuggestion) return undefined;
+    return [this.generatedFixSuggestion];
+  }
+
   private handleToggleResolved() {
     this.unresolved = !this.unresolved;
     if (!this.editing) {
@@ -1491,6 +1751,23 @@
     );
     this.closeDeleteCommentModal();
   }
+
+  private trackGeneratedSuggestionEdit() {
+    const hasUserSuggestion = this.messageText.includes(
+      USER_SUGGESTION_START_PATTERN
+    );
+    const wasGeneratedSuggestionEdited =
+      this.addedGeneratedSuggestion &&
+      hasUserSuggestion &&
+      !this.messageText.includes(this.addedGeneratedSuggestion);
+    if (wasGeneratedSuggestionEdited) {
+      this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_EDITED, {
+        uuid: this.generatedSuggestionId,
+        commentId: this.comment?.id ?? '',
+      });
+      this.addedGeneratedSuggestion = undefined;
+    }
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index a01d3a4..098b79a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -28,6 +28,7 @@
   PatchSetNum,
   Timestamp,
   UrlEncodedCommentId,
+  FixId,
 } from '../../../types/common';
 import {
   createComment,
@@ -38,7 +39,7 @@
 import {ReplyToCommentEvent} from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {assertIsDefined} from '../../../utils/common-util';
-import {Modifier} from '../../../utils/dom-util';
+import {Key, Modifier} from '../../../utils/dom-util';
 import {SinonStubbedMember} from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../gr-button/gr-button';
@@ -47,6 +48,7 @@
   CommentsModel,
   commentsModelToken,
 } from '../../../models/comments/comments-model';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 suite('gr-comment tests', () => {
   let element: GrComment;
@@ -316,11 +318,7 @@
                   <div class="leftActions">
                     <div class="action resolve">
                       <label>
-                        <input
-                          checked=""
-                          id="resolvedCheckbox"
-                          type="checkbox"
-                        />
+                        <input id="resolvedCheckbox" type="checkbox" />
                         Resolved
                       </label>
                     </div>
@@ -422,11 +420,7 @@
                   <div class="leftActions">
                     <div class="action resolve">
                       <label>
-                        <input
-                          checked=""
-                          id="resolvedCheckbox"
-                          type="checkbox"
-                        />
+                        <input id="resolvedCheckbox" type="checkbox" />
                         Resolved
                       </label>
                     </div>
@@ -602,6 +596,46 @@
       assert.isTrue(spy.called);
     });
 
+    suite('ctrl+ENTER  ', () => {
+      test('saves comment', async () => {
+        const spy = sinon.stub(element, 'save');
+        element.messageText = 'is that the horse from horsing around??';
+        element.editing = true;
+        await element.updateComplete;
+        pressKey(
+          element.textarea!.textarea!.textarea,
+          Key.ENTER,
+          Modifier.CTRL_KEY
+        );
+        assert.isTrue(spy.called);
+      });
+      test('propagates on patchset comment', async () => {
+        const event = new KeyboardEvent('keydown', {
+          key: Key.ENTER,
+          ctrlKey: true,
+        });
+        const stopPropagationStub = sinon.stub(event, 'stopPropagation');
+        element.permanentEditingMode = true;
+        element.messageText = 'is that the horse from horsing around??';
+        element.editing = true;
+        await element.updateComplete;
+        element.dispatchEvent(event);
+        assert.isFalse(stopPropagationStub.called);
+      });
+      test('does not propagate on normal comment', async () => {
+        const event = new KeyboardEvent('keydown', {
+          key: Key.ENTER,
+          ctrlKey: true,
+        });
+        const stopPropagationStub = sinon.stub(event, 'stopPropagation');
+        element.messageText = 'is that the horse from horsing around??';
+        element.editing = true;
+        await element.updateComplete;
+        element.dispatchEvent(event);
+        assert.isTrue(stopPropagationStub.called);
+      });
+    });
+
     test('save', async () => {
       const savePromise = mockPromise<DraftInfo>();
       const stub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
@@ -734,6 +768,21 @@
       assert.isFalse(saveStub.called);
     });
 
+    test('converting to input for empty text calls discard()', async () => {
+      const saveStub = sinon.stub(commentsModel, 'saveDraft');
+      const discardStub = sinon.stub(commentsModel, 'discardDraft');
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+
+      element.messageText = '';
+      await element.updateComplete;
+
+      await element.convertToCommentInputAndOrDiscard();
+      assert.isTrue(discardStub.called);
+      assert.isFalse(saveStub.called);
+    });
+
     test('handlePleaseFix fires reply-to-comment event', async () => {
       const listener = listenOnce<ReplyToCommentEvent>(
         element,
@@ -886,4 +935,154 @@
       );
     });
   });
+
+  suite('suggested fix', () => {
+    let element: GrComment;
+    const generatedFixSuggestion = {
+      description: 'prompt_to_edit',
+      fix_id: 'ml' as FixId,
+      replacements: [
+        {
+          path: 'google3/ts',
+          range: {
+            start_line: 83,
+            start_character: 0,
+            end_line: 83,
+            end_character: 0,
+          },
+          replacement: "import {useUtil} from '../../../utils/use_util';\n",
+        },
+        {
+          path: 'google3/ts',
+          range: {
+            start_line: 985,
+            start_character: 0,
+            end_line: 988,
+            end_character: 0,
+          },
+          replacement:
+            '        this.suggestionsProvider.supportedFileExtensions.includes(useUtil.getExtension(this.comment.path))) &&\n',
+        },
+      ],
+    };
+    setup(async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
+        .returns(true);
+    });
+
+    test('renders suggestions in comment', async () => {
+      const comment = {
+        ...createComment(),
+        author: {
+          name: 'MitoMr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        savingState: SavingState.OK,
+        message: 'hello world',
+        fix_suggestions: [generatedFixSuggestion],
+      };
+      element = await fixture(
+        html`<gr-comment
+          .account=${account}
+          .showPatchset=${true}
+          .comment=${comment}
+          .initiallyCollapsed=${false}
+        ></gr-comment>`
+      );
+      element.editing = false;
+      await element.updateComplete;
+      assert.dom.equal(
+        queryAndAssert(element, 'gr-fix-suggestions'),
+        /* HTML */ '<gr-fix-suggestions> </gr-fix-suggestions>'
+      );
+    });
+
+    test('renders suggestions in draft', async () => {
+      const comment: DraftInfo = {
+        ...createDraft(),
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        savingState: SavingState.OK,
+        message: 'hello world',
+        fix_suggestions: [generatedFixSuggestion],
+      };
+      element = await fixture(
+        html`<gr-comment
+          .account=${account}
+          .showPatchset=${true}
+          .comment=${comment}
+          .initiallyCollapsed=${false}
+        ></gr-comment>`
+      );
+      element.editing = false;
+      await element.updateComplete;
+      assert.dom.equal(
+        queryAndAssert(element, 'gr-fix-suggestions'),
+        /* HTML */ '<gr-fix-suggestions> </gr-fix-suggestions>'
+      );
+    });
+
+    test('doesn`t render fix_suggestion when not in draft', async () => {
+      const comment: DraftInfo = {
+        ...createDraft(),
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        savingState: SavingState.OK,
+        message: 'hello world',
+      };
+      element = await fixture(
+        html`<gr-comment
+          .account=${account}
+          .showPatchset=${true}
+          .comment=${comment}
+          .initiallyCollapsed=${false}
+        ></gr-comment>`
+      );
+      element.editing = true;
+      await element.updateComplete;
+      assert.isUndefined(query(element, 'gr-suggestion-diff-preview'));
+    });
+
+    test('render suggestions in draft is in editing & generatedFixSuggestions is not empty', async () => {
+      const comment: DraftInfo = {
+        ...createDraft(),
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        savingState: SavingState.OK,
+        message: 'hello world',
+      };
+      element = await fixture(
+        html`<gr-comment
+          .account=${account}
+          .showPatchset=${true}
+          .comment=${comment}
+          .initiallyCollapsed=${false}
+        ></gr-comment>`
+      );
+      element.editing = true;
+      sinon.stub(element, 'showGeneratedSuggestion').returns(true);
+      element.generateSuggestion = true;
+      element.generatedFixSuggestion = generatedFixSuggestion;
+      await element.updateComplete;
+      assert.dom.equal(
+        queryAndAssert(element, 'gr-suggestion-diff-preview'),
+        /* HTML */ '<gr-suggestion-diff-preview> </gr-suggestion-diff-preview>'
+      );
+    });
+  });
 });
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 f71c390..1a536d7 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
@@ -22,6 +22,7 @@
 import {Timing} from '../../../constants/reporting';
 import {when} from 'lit/directives/when.js';
 import {formStyles} from '../../../styles/form-styles';
+import {fire} from '../../../utils/event-util';
 
 const COPY_TIMEOUT_MS = 1000;
 
@@ -189,6 +190,7 @@
     e.preventDefault();
     e.stopPropagation();
 
+    fire(this, 'item-copied', {});
     this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
     assertIsDefined(this.text, 'text');
     this.iconEl.icon = 'check';
@@ -201,3 +203,10 @@
     setTimeout(() => (this.iconEl.icon = 'content_copy'), COPY_TIMEOUT_MS);
   }
 }
+
+declare global {
+  interface HTMLElementEventMap {
+    /** Fired when an item has been copied. */
+    'item-copied': CustomEvent<{}>;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 828672b..35567be 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -391,10 +391,12 @@
   }
 
   _targetIsVisible(top: number) {
+    // Targets near the top are often covered by sticky header UI, so we
+    // consider it not-visible if it is within 100px of the top.
     return (
       this.scrollMode === ScrollMode.KEEP_VISIBLE &&
-      top > window.pageYOffset &&
-      top < window.pageYOffset + window.innerHeight
+      top > window.scrollY + 100 &&
+      top < window.scrollY + window.innerHeight
     );
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 74ba635..408efbe 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -52,7 +52,7 @@
 
   @state() private originalDiffPrefs?: DiffPreferencesInfo;
 
-  private readonly getUserModel = resolve(this, userModelToken);
+  readonly getUserModel = resolve(this, userModelToken);
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
index 05f5ea1..97e4221 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
@@ -6,12 +6,16 @@
 import '../../../test/common-test-setup';
 import './gr-diff-preferences';
 import {GrDiffPreferences} from './gr-diff-preferences';
-import {queryAll, stubRestApi} from '../../../test/test-utils';
+import {
+  makePrefixedJSON,
+  queryAll,
+  stubRestApi,
+  waitUntil,
+} from '../../../test/test-utils';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {IronInputElement} from '@polymer/iron-input';
 import {GrSelect} from '../gr-select/gr-select';
-import {ParsedJSON} from '../../../types/common';
 import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-diff-preferences tests', () => {
@@ -223,17 +227,20 @@
 
     assert.isTrue(element.hasUnsavedChanges());
 
-    const getResponseObjStub = stubRestApi('getResponseObject').returns(
-      Promise.resolve(element.diffPrefs! as unknown as ParsedJSON)
+    const savePrefStub = stubRestApi('saveDiffPreferences').resolves(
+      new Response(makePrefixedJSON(element.diffPrefs))
     );
 
-    // Save the change.
     await element.save();
+    // Wait for model state update, since this is not awaited by element.save()
+    await waitUntil(
+      () =>
+        !element.getUserModel().getState().diffPreferences
+          ?.show_whitespace_errors
+    );
 
-    assert.isTrue(getResponseObjStub.called);
-
+    assert.isTrue(savePrefStub.called);
     assert.isFalse(element.diffPrefs!.show_whitespace_errors);
-
     assert.isFalse(element.hasUnsavedChanges());
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 886894e..babe5e2 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -36,9 +36,8 @@
 
 @customElement('gr-download-commands')
 export class GrDownloadCommands extends LitElement {
-  // TODO(TS): maybe default to [] as only used in dom-repeat
   @property({type: Array})
-  commands?: Command[];
+  commands: Command[] = [];
 
   // private but used in test
   @state() loggedIn = false;
@@ -49,6 +48,10 @@
   @property({type: String})
   selectedScheme?: string;
 
+  // description of selected scheme
+  @property({type: String})
+  description?: string;
+
   @property({type: Boolean, attribute: 'show-keyboard-shortcut-tooltips'})
   showKeyboardShortcutTooltips = false;
 
@@ -121,6 +124,9 @@
           display: flex;
           justify-content: space-between;
         }
+        .description {
+          margin-bottom: var(--spacing-m);
+        }
         .commands {
           display: flex;
           flex-direction: column;
@@ -138,7 +144,7 @@
   override render() {
     return html`
       <div class="schemes">${this.renderDownloadTabs()}</div>
-      ${this.renderCommands()}
+      ${this.renderDescription()} ${this.renderCommands()}
     `;
   }
 
@@ -157,6 +163,11 @@
     `;
   }
 
+  private renderDescription() {
+    if (!this.description) return;
+    return html`<div class="description">${this.description}</div>`;
+  }
+
   private renderPaperTab(scheme: string) {
     return html` <paper-tab data-scheme=${scheme}>${scheme}</paper-tab> `;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 7dee49f..92505d2 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -10,6 +10,8 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
+import {subscribe} from '../../lit/subscription-controller';
+import {changeModelToken} from '../../../models/change/change-model';
 import {fire, fireAlert} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {debounce, DelayedTask} from '../../../utils/async-util';
@@ -26,6 +28,7 @@
   EditableContentSaveEvent,
   ValueChangedEvent,
 } from '../../../types/events';
+import {EmailInfo, GitPersonInfo} from '../../../types/common';
 import {nothing} from 'lit';
 import {classMap} from 'lit/directives/class-map.js';
 import {when} from 'lit/directives/when.js';
@@ -89,6 +92,19 @@
 
   @state() newContent = '';
 
+  @state()
+  emails: EmailInfo[] = [];
+
+  @state()
+  committerEmail?: string;
+
+  @state()
+  latestCommitter?: GitPersonInfo;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   private readonly getStorage = resolve(this, storageServiceToken);
 
   private readonly reporting = getAppContext().reportingService;
@@ -96,6 +112,15 @@
   // Tests use this so needs to be non private
   storeTask?: DelayedTask;
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().latestCommitter$,
+      x => (this.latestCommitter = x)
+    );
+  }
+
   override disconnectedCallback() {
     this.storeTask?.flush();
     super.disconnectedCallback();
@@ -148,7 +173,6 @@
         .show-all-container {
           background-color: var(--view-background-color);
           display: flex;
-          justify-content: flex-end;
           border: 1px solid transparent;
           border-top-color: var(--border-color);
           border-radius: 0 0 4px 4px;
@@ -162,6 +186,14 @@
         :host([editing]) .show-all-container {
           box-shadow: none;
           border: 1px solid var(--border-color);
+          justify-content: space-between;
+        }
+        :host(:not([editing])) .show-all-container {
+          justify-content: flex-end;
+        }
+        div:only-child {
+          align-self: flex-end;
+          margin-left: auto;
         }
         .flex-space {
           flex-grow: 1;
@@ -169,6 +201,10 @@
         .show-all-container gr-icon {
           color: inherit;
         }
+        .email-dropdown {
+          margin-left: var(--spacing-s);
+          align-self: center;
+        }
         .cancel-button {
           margin-right: var(--spacing-l);
         }
@@ -273,22 +309,33 @@
         )}
         ${when(
           this.editing,
-          () => html` <div class="editButtons">
-            <gr-button
-              link
-              class="cancel-button"
-              @click=${this.handleCancel}
-              ?disabled=${this.disabled}
-              >Cancel</gr-button
+          () => html` ${when(
+              this.canShowEmailDropdown(),
+              () => html` <div class="email-dropdown" id="editMessageEmailDropdown">Committer Email
+            <gr-dropdown-list
+                .items=${this.getEmailDropdownItems()}
+                .value=${this.committerEmail}
+                @value-change=${this.setCommitterEmail}
             >
-            <gr-button
-              class="save-button"
-              primary=""
-              @click=${this.handleSave}
-              ?disabled=${this.computeSaveDisabled()}
-              >Save</gr-button
-            >
-          </div>`
+            </gr-dropdown-list>
+            <span></div>`
+            )}
+            <div class="editButtons">
+              <gr-button
+                link
+                class="cancel-button"
+                @click=${this.handleCancel}
+                ?disabled=${this.disabled}
+                >Cancel</gr-button
+              >
+              <gr-button
+                class="save-button"
+                primary=""
+                @click=${this.handleSave}
+                ?disabled=${this.computeSaveDisabled()}
+                >Save</gr-button
+              >
+            </div>`
         )}
         </div>
       </div>
@@ -384,7 +431,10 @@
 
   handleSave(e: Event) {
     e.preventDefault();
-    fire(this, 'editable-content-save', {content: this.newContent});
+    fire(this, 'editable-content-save', {
+      content: this.newContent,
+      committerEmail: this.committerEmail ? this.committerEmail : null,
+    });
     // It would be nice, if we would set this.newContent = undefined here,
     // but we can only do that when we are sure that the save operation has
     // succeeded.
@@ -408,8 +458,43 @@
   }
 
   async handleEditCommitMessage() {
+    await this.loadEmails();
     this.editing = true;
     await this.updateComplete;
     this.focusTextarea();
   }
+
+  async loadEmails() {
+    const accountEmails: EmailInfo[] =
+      (await this.restApiService.getAccountEmails()) ?? [];
+    let selectedEmail: string | undefined;
+    accountEmails.forEach(e => {
+      if (e.preferred) {
+        selectedEmail = e.email;
+      }
+    });
+
+    if (accountEmails.some(e => e.email === this.latestCommitter?.email)) {
+      selectedEmail = this.latestCommitter?.email;
+    }
+    this.emails = accountEmails;
+    this.committerEmail = selectedEmail;
+  }
+
+  private canShowEmailDropdown() {
+    return this.emails.length > 1;
+  }
+
+  private getEmailDropdownItems() {
+    return this.emails.map(e => {
+      return {
+        text: e.email,
+        value: e.email,
+      };
+    });
+  }
+
+  private setCommitterEmail(e: CustomEvent<{value: string}>) {
+    this.committerEmail = e.detail.value;
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index 4a5611b..f8f530d 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -12,6 +12,18 @@
 import {StorageService} from '../../../services/storage/gr-storage';
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {testResolver} from '../../../test/common-test-setup';
+import {GrDropdownList} from '../gr-dropdown-list/gr-dropdown-list';
+
+const emails = [
+  {
+    email: 'primary@example.com',
+    preferred: true,
+  },
+  {
+    email: 'secondary@example.com',
+    preferred: false,
+  },
+];
 
 suite('gr-editable-content tests', () => {
   let element: GrEditableContent;
@@ -233,4 +245,38 @@
       assert.deepEqual([element.storageKey], eraseStub.lastCall.args);
     });
   });
+
+  suite('edit with committer email', () => {
+    test('hide email dropdown when user has one email', async () => {
+      element.emails = emails.slice(0, 1);
+      element.editing = true;
+      await element.updateComplete;
+      assert.notExists(query(element, '#editMessageEmailDropdown'));
+    });
+
+    test('show email dropdown when user has more than one email', async () => {
+      element.emails = emails;
+      element.editing = true;
+      await element.updateComplete;
+      const editMessageEmailDropdown = queryAndAssert(
+        element,
+        '#editMessageEmailDropdown'
+      );
+      assert.dom.equal(
+        editMessageEmailDropdown,
+        `<div class="email-dropdown" id="editMessageEmailDropdown">Committer Email
+        <gr-dropdown-list></gr-dropdown-list>
+        <span></span>
+        </div>`
+      );
+      const emailDropdown = queryAndAssert<GrDropdownList>(
+        editMessageEmailDropdown,
+        'gr-dropdown-list'
+      );
+      assert.deepEqual(
+        emailDropdown.items?.map(e => e.value),
+        emails.map(e => e.email)
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
new file mode 100644
index 0000000..9e78e2a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
@@ -0,0 +1,194 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {css, html, LitElement} from 'lit';
+import {customElement, state, query, property} from 'lit/decorators.js';
+import {fire} from '../../../utils/event-util';
+import {getDocUrl} from '../../../utils/url-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {changeModelToken} from '../../../models/change/change-model';
+import {Comment, isDraft, PatchSetNumber} from '../../../types/common';
+import {OpenFixPreviewEventDetail} from '../../../types/events';
+import {pluginLoaderToken} from '../gr-js-api-interface/gr-plugin-loader';
+import {SuggestionsProvider} from '../../../api/suggestions';
+import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
+
+/**
+ * gr-fix-suggestions is UI for comment.fix_suggestions.
+ * gr-fix-suggestions is wrapper for gr-suggestion-diff-preview with buttons
+ * to preview and apply fix and for giving a context about suggestion.
+ */
+@customElement('gr-fix-suggestions')
+export class GrFixSuggestions extends LitElement {
+  @query('gr-suggestion-diff-preview')
+  suggestionDiffPreview?: GrSuggestionDiffPreview;
+
+  @property({type: Object})
+  comment?: Comment;
+
+  @state() private docsBaseUrl = '';
+
+  @state() private applyingFix = false;
+
+  @state() latestPatchNum?: PatchSetNumber;
+
+  @state()
+  suggestionsProvider?: SuggestionsProvider;
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        const suggestionsPlugins =
+          this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
+        // We currently support results from only 1 provider.
+        this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
+      });
+  }
+
+  static override get styles() {
+    return [
+      css`
+        .header {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--border-color);
+          padding: var(--spacing-xs) var(--spacing-xl);
+          display: flex;
+          align-items: center;
+          border-top-left-radius: var(--border-radius);
+          border-top-right-radius: var(--border-radius);
+        }
+        .header .title {
+          flex: 1;
+        }
+        .copyButton {
+          margin-right: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.comment?.fix_suggestions) return;
+    const fix_suggestions = this.comment.fix_suggestions;
+    return html`<div class="header">
+        <div class="title">
+          <span
+            >${this.suggestionsProvider?.getFixSuggestionTitle?.(
+              fix_suggestions
+            ) || 'Suggested edit'}</span
+          >
+          <a
+            href=${this.suggestionsProvider?.getDocumentationLink?.(
+              fix_suggestions
+            ) || getDocUrl(this.docsBaseUrl, 'user-suggest-edits.html')}
+            target="_blank"
+            rel="noopener noreferrer"
+            ><gr-icon icon="help" title="read documentation"></gr-icon
+          ></a>
+        </div>
+        <div>
+          <gr-button
+            secondary
+            flatten
+            class="action show-fix"
+            @click=${this.handleShowFix}
+          >
+            Show edit
+          </gr-button>
+          <gr-button
+            secondary
+            flatten
+            .loading=${this.applyingFix}
+            .disabled=${this.isApplyEditDisabled()}
+            class="action show-fix"
+            @click=${this.handleApplyFix}
+            .title=${this.computeApplyEditTooltip()}
+          >
+            Apply edit
+          </gr-button>
+        </div>
+      </div>
+      <gr-suggestion-diff-preview
+        .fixSuggestionInfo=${this.comment?.fix_suggestions?.[0]}
+      ></gr-suggestion-diff-preview>`;
+  }
+
+  handleShowFix() {
+    if (!this.comment?.fix_suggestions || !this.comment?.patch_set) return;
+    const eventDetail: OpenFixPreviewEventDetail = {
+      fixSuggestions: this.comment.fix_suggestions.map(s => {
+        return {
+          ...s,
+          fix_id: PROVIDED_FIX_ID,
+          description:
+            this.suggestionsProvider?.getFixSuggestionTitle?.(
+              this.comment?.fix_suggestions
+            ) || 'Suggested edit',
+        };
+      }),
+      patchNum: this.comment.patch_set,
+      onCloseFixPreviewCallbacks: [],
+    };
+    fire(this, 'open-fix-preview', eventDetail);
+  }
+
+  async handleApplyFix() {
+    if (!this.comment?.fix_suggestions) return;
+    this.applyingFix = true;
+    try {
+      await this.suggestionDiffPreview?.applyFixSuggestion();
+    } finally {
+      this.applyingFix = false;
+    }
+  }
+
+  private isApplyEditDisabled() {
+    if (this.comment?.patch_set === undefined) return true;
+    if (isDraft(this.comment)) return true;
+    return this.comment.patch_set !== this.latestPatchNum;
+  }
+
+  private computeApplyEditTooltip() {
+    if (this.comment?.patch_set === undefined) return '';
+    return this.comment.patch_set !== this.latestPatchNum
+      ? 'You cannot apply this fix because it is from a previous patchset'
+      : '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-fix-suggestions': GrFixSuggestions;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index e810637..b57e4ff 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?://((?!&(gt|lt|amp|quot|apos);)\\S)+[\\w/~-])',
           link: '$1',
           enabled: true,
         };
@@ -194,15 +194,25 @@
     // 4. Rewrite plain text ("text") to apply linking and other config-based
     //    rewrites. Text within code blocks is not passed here.
     // 5. Open links in a new tab by rendering with target="_blank" attribute.
+    // 6. Relative links without "/" prefix are assumed to be absolute links.
     function customRenderer(renderer: {[type: string]: Function}) {
-      renderer['link'] = (href: string, title: string, text: string) =>
+      renderer['link'] = (href: string, title: string, text: string) => {
+        if (
+          !href.startsWith('https://') &&
+          !href.startsWith('mailto:') &&
+          !href.startsWith('http://') &&
+          !href.startsWith('/')
+        ) {
+          href = `https://${href}`;
+        }
         /* HTML */
-        `<a
+        return `<a
           href="${href}"
           ${sameOrigin(href) ? '' : 'target="_blank" rel="noopener noreferrer"'}
           ${title ? `title="${title}"` : ''}
           >${text}</a
         >`;
+      };
       renderer['image'] = (href: string, _title: string, text: string) =>
         `![${text}](${href})`;
       renderer['codespan'] = (text: string) =>
@@ -295,8 +305,7 @@
 
   private convertCodeToSuggestions() {
     const marks = this.renderRoot.querySelectorAll('mark');
-    if (marks.length > 0) {
-      const userSuggestionMark = marks[0];
+    for (const userSuggestionMark of marks) {
       const userSuggestion = document.createElement('gr-user-suggestion-fix');
       // Temporary workaround for bug - tabs replacement
       if (this.content.includes('\t')) {
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 a287659..23f1594 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
@@ -510,6 +510,9 @@
       element.content = `[myLink1](https://www.google.com)
         [myLink2](/destiny)
         [myLink3](${origin}/destiny)
+        [myLink4](google.com)
+        [myLink5](http://google.com)
+        [myLink6](mailto:google@google.com)
       `;
       await element.updateComplete;
 
@@ -529,6 +532,27 @@
                 <a href="/destiny">myLink2</a>
                 <br />
                 <a href="${origin}/destiny">myLink3</a>
+                <br />
+                <a
+                  href="https://google.com"
+                  rel="noopener noreferrer"
+                  target="_blank"
+                  >myLink4</a
+                >
+                <br />
+                <a
+                  href="http://google.com"
+                  rel="noopener noreferrer"
+                  target="_blank"
+                  >myLink5</a
+                >
+                <br />
+                <a
+                  href="mailto:google@google.com"
+                  rel="noopener noreferrer"
+                  target="_blank"
+                  >myLink6</a
+                >
               </p>
             </div>
           </marked-element>
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 01e8a87..9ce435f 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
@@ -281,6 +281,12 @@
         }}
         >Dashboard</a
       >
+      <gr-endpoint-decorator name="hovercard-links">
+        <gr-endpoint-param
+          name="account"
+          .value=${this.account}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
     </div>`;
   }
 
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 281d295..5afd53b 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
@@ -86,6 +86,9 @@
           <a href="/q/owner:kermit@gmail.com">Changes</a>
           ·
           <a href="/dashboard/31415926535">Dashboard</a>
+          <gr-endpoint-decorator name="hovercard-links">
+            <gr-endpoint-param name="account"></gr-endpoint-param>
+          </gr-endpoint-decorator>
         </div>
       `
     );
@@ -125,6 +128,9 @@
           <a href="/q/owner:kermit@gmail.com"> Changes </a>
           ·
           <a href="/dashboard/31415926535"> Dashboard </a>
+          <gr-endpoint-decorator name="hovercard-links">
+            <gr-endpoint-param name="account"></gr-endpoint-param>
+          </gr-endpoint-decorator>
         </div>
       `
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
index 7b99660..a1d2dcb 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
@@ -7,6 +7,7 @@
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {readJSONResponsePayload} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
 
@@ -75,7 +76,7 @@
           }
         });
       } else {
-        return restApiService.getResponseObject(response);
+        return readJSONResponsePayload(response).then(obj => obj.parsed);
       }
     })
     .then(response => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index f0143a6..523cc59 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -9,10 +9,8 @@
 import {
   ActionPriority,
   ActionType,
-  ChangeActions,
   ChangeActionsPluginApi,
   PrimaryActionKey,
-  RevisionActions,
 } from '../../../api/change-actions';
 import {PropertyDeclaration} from 'lit';
 import {JsApiService} from './gr-js-api-types';
@@ -28,9 +26,6 @@
 
 // This interface is required to avoid circular dependencies between files;
 export interface GrChangeActionsElement extends Element {
-  RevisionActions?: Record<string, string>;
-  ChangeActions: Record<string, string>;
-  ActionType: Record<string, string>;
   primaryActionKeys: string[];
   hideQuickApproveAction(): void;
   setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
@@ -58,12 +53,6 @@
 export class GrChangeActionsInterface implements ChangeActionsPluginApi {
   private el?: GrChangeActionsElement;
 
-  RevisionActions = RevisionActions;
-
-  ChangeActions = ChangeActions;
-
-  ActionType = ActionType;
-
   private readonly reporting = getAppContext().reportingService;
 
   constructor(
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
index e557ca8..d42dc7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
@@ -57,6 +57,7 @@
         <gr-change-actions></gr-change-actions>
       `);
       element.change = {} as ChangeViewChangeInfo;
+      element.revisionActions = {};
       window.Gerrit.install(
         p => {
           plugin = p;
@@ -69,14 +70,6 @@
       testResolver(pluginLoaderToken).loadPlugins([]);
     });
 
-    test('property existence', () => {
-      const properties = ['ActionType', 'ChangeActions', 'RevisionActions'];
-      for (const p of properties) {
-        // Have to type as any to prevent 'has no index signature.'
-        assert.deepEqual((changeActions as any)[p], (element as any)[p]);
-      }
-    });
-
     test('add/remove primary action keys', () => {
       element.primaryActionKeys = [];
       changeActions.addPrimaryActionKey('foo' as PrimaryActionKey);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 6b9c684..b09502a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -57,11 +57,7 @@
       try {
         return callback(change, revision) === false;
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('canSubmitChange callback error'),
-          err
-        );
+        this.reportError(err, EventType.SUBMIT_CHANGE);
       }
       return false;
     });
@@ -116,11 +112,18 @@
       try {
         cb(change, revision, info, baseRevision ?? PARENT);
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('showChange callback error'),
-          err
-        );
+        this.reportError(err, EventType.SHOW_CHANGE);
+      }
+    }
+  }
+
+  async handleReplySent() {
+    await this.waitForPluginsToLoad();
+    for (const cb of this._getEventCallbacks(EventType.REPLY_SENT)) {
+      try {
+        cb();
+      } catch (err: unknown) {
+        this.reportError(err, EventType.REPLY_SENT);
       }
     }
   }
@@ -134,11 +137,7 @@
       try {
         cb(detail.revisionActions, detail.change);
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('showRevisionActions callback error'),
-          err
-        );
+        this.reportError(err, EventType.SHOW_REVISION_ACTIONS);
       }
     }
   }
@@ -148,11 +147,7 @@
       try {
         cb(change, msg);
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('commitMessage callback error'),
-          err
-        );
+        this.reportError(err, EventType.COMMIT_MSG_EDIT);
       }
     }
   }
@@ -163,11 +158,7 @@
       try {
         cb(detail.change);
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('labelChange callback error'),
-          err
-        );
+        this.reportError(err, EventType.LABEL_CHANGE);
       }
     }
   }
@@ -177,11 +168,7 @@
       try {
         revertMsg = cb(change, revertMsg, origMsg) as string;
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('modifyRevertMsg callback error'),
-          err
-        );
+        this.reportError(err, EventType.REVERT);
       }
     }
     return revertMsg;
@@ -200,11 +187,7 @@
           origMsg
         ) as string;
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('modifyRevertSubmissionMsg callback error'),
-          err
-        );
+        this.reportError(err, EventType.REVERT_SUBMISSION);
       }
     }
     return revertSubmissionMsg;
@@ -230,11 +213,7 @@
           review = {labels: r as LabelNameToValueMap};
         }
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('getReviewPostRevert callback error'),
-          err
-        );
+        this.reportError(err, EventType.POST_REVERT);
       }
     }
     return review;
@@ -246,15 +225,19 @@
       try {
         cb(detail.change, detail.patchRange, detail.fileRange);
       } catch (err: unknown) {
-        this.reporting.error(
-          'GrJsApiInterface',
-          new Error('showDiff callback error'),
-          err
-        );
+        this.reportError(err, EventType.SHOW_DIFF);
       }
     }
   }
 
+  reportError(err: unknown, type: EventType) {
+    this.reporting.error(
+      'GrJsApiInterface',
+      new Error(`plugin event callback error for type "${type}"`),
+      err
+    );
+  }
+
   _getEventCallbacks(type: EventType) {
     return eventCallbacks[type] || [];
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index 6c180d7..dafa434 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -45,7 +45,7 @@
     revertSubmissionMsg: string,
     origMsg: string
   ): string;
-  handleShowChange(detail: ShowChangeDetail): void;
+  handleShowChange(detail: ShowChangeDetail): Promise<void>;
   handleShowRevisionActions(detail: ShowRevisionActionsDetail): void;
   handleLabelChange(detail: {change?: ParsedChangeInfo}): void;
   modifyRevertMsg(
@@ -59,4 +59,5 @@
   canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null): boolean;
   getReviewPostRevert(change?: ChangeInfo): ReviewInput;
   handleShowDiff(detail: ShowDiffDetail): void;
+  handleReplySent(): Promise<void>;
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index b78af2a..7082ec5 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -119,6 +119,9 @@
       this.reportingService
     );
     this.pluginsModel = new PluginsModel();
+    this.awaitPluginsLoaded().finally(() => {
+      this.pluginsModel.updateState({pluginsLoaded: true});
+    });
     this.pluginEndPoints = new GrPluginEndpoints();
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
index 65e4960..e9c132a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -9,6 +9,7 @@
 import {PluginApi} from '../../../api/plugin';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {readJSONResponsePayload} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 async function getErrorMessage(response: Response): Promise<string> {
   const text = await response.text();
@@ -153,7 +154,9 @@
             Promise.reject(new Error(msg))
           );
         } else {
-          return this.restApi.getResponseObject(response) as Promise<T>;
+          return readJSONResponsePayload(response).then(
+            obj => obj.parsed
+          ) as Promise<T>;
         }
       })
       .catch(err => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
index c5bef85..472093e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
@@ -6,7 +6,11 @@
 import '../../../test/common-test-setup';
 import './gr-js-api-interface';
 import {GrPluginRestApi} from './gr-plugin-rest-api';
-import {assertFails, stubRestApi} from '../../../test/test-utils';
+import {
+  assertFails,
+  makePrefixedJSON,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {assert} from '@open-wc/testing';
 import {PluginApi} from '../../../api/plugin';
 import {
@@ -18,12 +22,10 @@
 
 suite('gr-plugin-rest-api tests', () => {
   let instance: GrPluginRestApi;
-  let getResponseObjectStub: sinon.SinonStub;
   let sendStub: sinon.SinonStub;
 
   setup(() => {
     stubRestApi('getAccount').resolves(createAccountDetailWithId());
-    getResponseObjectStub = stubRestApi('getResponseObject').resolves();
     sendStub = stubRestApi('send').resolves({...new Response(), status: 200});
     let pluginApi: PluginApi;
     window.Gerrit.install(
@@ -45,42 +47,41 @@
     const r = await instance.fetch(HttpMethod.POST, '/url', payload);
     assert.isTrue(sendStub.calledWith(HttpMethod.POST, '/url', payload));
     assert.equal(r.status, 200);
-    assert.isFalse(getResponseObjectStub.called);
   });
 
   test('send', async () => {
     const payload = {foo: 'foo'};
     const response = {bar: 'bar'};
-    getResponseObjectStub.resolves(response);
+    sendStub.resolves(new Response(makePrefixedJSON(response)));
     const r = await instance.send(HttpMethod.POST, '/url', payload);
     assert.isTrue(sendStub.calledWith(HttpMethod.POST, '/url', payload));
-    assert.strictEqual(r, response);
+    assert.deepEqual(r, response);
   });
 
   test('get', async () => {
     const response = {foo: 'foo'};
-    getResponseObjectStub.resolves(response);
+    sendStub.resolves(new Response(makePrefixedJSON(response)));
     const r = await instance.get('/url');
     assert.isTrue(sendStub.calledWith('GET', '/url'));
-    assert.strictEqual(r, response);
+    assert.deepEqual(r, response);
   });
 
   test('post', async () => {
     const payload = {foo: 'foo'};
     const response = {bar: 'bar'};
-    getResponseObjectStub.resolves(response);
+    sendStub.resolves(new Response(makePrefixedJSON(response)));
     const r = await instance.post('/url', payload);
     assert.isTrue(sendStub.calledWith('POST', '/url', payload));
-    assert.strictEqual(r, response);
+    assert.deepEqual(r, response);
   });
 
   test('put', async () => {
     const payload = {foo: 'foo'};
     const response = {bar: 'bar'};
-    getResponseObjectStub.resolves(response);
+    sendStub.resolves(new Response(makePrefixedJSON(response)));
     const r = await instance.put('/url', payload);
     assert.isTrue(sendStub.calledWith('PUT', '/url', payload));
-    assert.strictEqual(r, response);
+    assert.deepEqual(r, response);
   });
 
   test('delete works', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 1084512..85f2c06 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -1,17 +1,11 @@
 /**
  * @license
- * Copyright 2019 Google LLC
+ * Copyright 2024 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
 import {getBaseUrl} from '../../../../utils/url-util';
-import {CancelConditionCallback} from '../../../../services/gr-rest-api/gr-rest-api';
 import {AuthService} from '../../../../services/gr-auth/gr-auth';
-import {
-  AccountDetailInfo,
-  EmailInfo,
-  ParsedJSON,
-  RequestPayload,
-} from '../../../../types/common';
+import {ParsedJSON, RequestPayload} from '../../../../types/common';
 import {HttpMethod} from '../../../../constants/constants';
 import {RpcLogEventDetail} from '../../../../types/events';
 import {
@@ -19,7 +13,10 @@
   fireNetworkError,
   fireServerError,
 } from '../../../../utils/event-util';
-import {AuthRequestInit, FetchRequest} from '../../../../types/types';
+import {
+  AuthRequestInit,
+  FetchRequest as FetchRequestBase,
+} from '../../../../types/types';
 import {ErrorCallback} from '../../../../api/rest';
 import {Scheduler, Task} from '../../../../services/scheduler/scheduler';
 import {RetryError} from '../../../../services/scheduler/retry-scheduler';
@@ -27,32 +24,21 @@
 export const JSON_PREFIX = ")]}'";
 
 export interface ResponsePayload {
-  // TODO(TS): readResponsePayload can assign null to the parsed property if
-  // it can't parse input data. However polygerrit assumes in many places
-  // that the parsed property can't be null. We should update
-  // readResponsePayload method and reject a promise instead of assigning
-  // null to the parsed property
-  parsed: ParsedJSON; // Can be null!!! See comment above
+  parsed: ParsedJSON;
   raw: string;
 }
 
-export function readResponsePayload(
+export async function readJSONResponsePayload(
   response: Response
 ): Promise<ResponsePayload> {
-  return response.text().then(text => {
-    let result;
-    try {
-      result = parsePrefixedJSON(text);
-    } catch (_) {
-      result = null;
-    }
-    // TODO(TS): readResponsePayload can assign null to the parsed property if
-    // it can't parse input data. However polygerrit assumes in many places
-    // that the parsed property can't be null. We should update
-    // readResponsePayload method and reject a promise instead of assigning
-    // null to the parsed property
-    return {parsed: result!, raw: text};
-  });
+  const text = await response.text();
+  let result: ParsedJSON;
+  try {
+    result = parsePrefixedJSON(text);
+  } catch (_) {
+    throw new Error(`Response payload is not prefixed json. Payload: ${text}`);
+  }
+  return {parsed: result!, raw: text};
 }
 
 export function parsePrefixedJSON(jsonWithPrefix: string): ParsedJSON {
@@ -63,20 +49,22 @@
  * Wrapper around Map for caching server responses. Site-based so that
  * changes to CANONICAL_PATH will result in a different cache going into
  * effect.
+ *
+ * All methods operate on the cache for the current CANONICAL_PATH.
+ * Accessing cache entries for older CANONICAL_PATH not supported.
  */
+// TODO(kamilm): Seems redundant to have both this and FetchPromisesCache
+//   consider joining their functionality into a single cache.
 export class SiteBasedCache {
-  // TODO(TS): Type looks unusual. Fix it.
-  // Container of per-canonical-path caches.
-  private readonly data = new Map<
-    string | undefined,
-    unknown | Map<string, ParsedJSON | null>
-  >();
+  private readonly data = new Map<string, Map<string, ParsedJSON>>();
 
   constructor() {
     if (window.INITIAL_DATA) {
       // Put all data shipped with index.html into the cache. This makes it
       // so that we spare more round trips to the server when the app loads
       // initially.
+      // TODO(kamilm): This implies very strict format of what is stored in
+      //   INITIAL_DATA which is not clear from the name, consider renaming.
       Object.entries(window.INITIAL_DATA).forEach(e =>
         this._cache().set(e[0], e[1] as unknown as ParsedJSON)
       );
@@ -84,40 +72,23 @@
   }
 
   // Returns the cache for the current canonical path.
-  _cache(): Map<string, unknown> {
-    if (!this.data.has(window.CANONICAL_PATH)) {
-      this.data.set(
-        window.CANONICAL_PATH,
-        new Map<string, ParsedJSON | null>()
-      );
+  _cache(): Map<string, ParsedJSON> {
+    const canonical_path = window.CANONICAL_PATH ?? '';
+    if (!this.data.has(canonical_path)) {
+      this.data.set(canonical_path, new Map<string, ParsedJSON>());
     }
-    return this.data.get(window.CANONICAL_PATH) as Map<
-      string,
-      ParsedJSON | null
-    >;
+    return this.data.get(canonical_path)!;
   }
 
   has(key: string) {
     return this._cache().has(key);
   }
 
-  get(key: '/accounts/self/emails'): EmailInfo[] | null;
-
-  get(key: '/accounts/self/detail'): AccountDetailInfo | null;
-
-  get(key: string): ParsedJSON | null;
-
-  get(key: string): unknown {
+  get(key: string): ParsedJSON | undefined {
     return this._cache().get(key);
   }
 
-  set(key: '/accounts/self/emails', value: EmailInfo[]): void;
-
-  set(key: '/accounts/self/detail', value: AccountDetailInfo): void;
-
-  set(key: string, value: ParsedJSON | null): void;
-
-  set(key: string, value: unknown) {
+  set(key: string, value: ParsedJSON) {
     this._cache().set(key, value);
   }
 
@@ -126,13 +97,13 @@
   }
 
   invalidatePrefix(prefix: string) {
-    const newMap = new Map<string, unknown>();
+    const newMap = new Map<string, ParsedJSON>();
     for (const [key, value] of this._cache().entries()) {
       if (!key.startsWith(prefix)) {
         newMap.set(key, value);
       }
     }
-    this.data.set(window.CANONICAL_PATH, newMap);
+    this.data.set(window.CANONICAL_PATH ?? '', newMap);
   }
 }
 
@@ -140,6 +111,9 @@
   [url: string]: Promise<ParsedJSON | undefined> | undefined;
 };
 
+/**
+ * Stores promises for inflight requests, by url.
+ */
 export class FetchPromisesCache {
   private data: FetchPromisesCacheData;
 
@@ -180,6 +154,7 @@
     this.data = newData;
   }
 }
+
 export type FetchParams = {
   [name: string]: string[] | string | number | boolean | undefined | null;
 };
@@ -213,46 +188,57 @@
   });
 }
 
-interface SendRequestBase {
-  method: HttpMethod | undefined;
+export interface FetchRequest extends FetchRequestBase {
+  /**
+   * If neither this or anonymizedUrl specified no 'gr-rpc-log' event is fired.
+   */
+  reportUrlAsIs?: boolean;
+  /** Extra url params to be encoded and added to the url. */
+  params?: FetchParams;
+  /**
+   * Callback that is called, if an error was caught during fetch or if the
+   * response was returned with a non-2xx status.
+   */
+  errFn?: ErrorCallback;
+  /**
+   * If true, response with non-200 status will cause an error to be reported
+   * via server-error event or errFn, if provided.
+   */
+  // TODO(kamilm): Consider changing the default to true. It makes more sense to
+  //   only skip the check if the caller wants to prosess status themselves.
+  reportServerError?: boolean;
+}
+
+export interface FetchOptionsInit {
+  method?: HttpMethod;
   body?: RequestPayload;
   contentType?: string;
   headers?: Record<string, string>;
-  url: string;
-  reportUrlAsIs?: boolean;
-  anonymizedUrl?: string;
-  errFn?: ErrorCallback;
 }
 
-export interface SendRawRequest extends SendRequestBase {
-  parseResponse?: false | null;
+export function getFetchOptions(init: FetchOptionsInit): AuthRequestInit {
+  const options: AuthRequestInit = {
+    method: init.method,
+  };
+  if (init.body) {
+    options.headers = new Headers();
+    options.headers.set('Content-Type', init.contentType || 'application/json');
+    options.body =
+      typeof init.body === 'string' ? init.body : JSON.stringify(init.body);
+  }
+  // Copy headers after processing body, so that explicit headers can override
+  // if necessary.
+  if (init.headers) {
+    if (!options.headers) {
+      options.headers = new Headers();
+    }
+    for (const [name, value] of Object.entries(init.headers)) {
+      options.headers.set(name, value);
+    }
+  }
+  return options;
 }
 
-export interface SendJSONRequest extends SendRequestBase {
-  parseResponse: true;
-}
-
-export type SendRequest = SendRawRequest | SendJSONRequest;
-
-export interface FetchJSONRequest extends FetchRequest {
-  reportUrlAsIs?: boolean;
-  params?: FetchParams;
-  cancelCondition?: CancelConditionCallback;
-  errFn?: ErrorCallback;
-}
-
-// export function isRequestWithCancel<T extends FetchJSONRequest>(
-//   x: T
-// ): x is T & RequestWithCancel {
-//   return !!(x as RequestWithCancel).cancelCondition;
-// }
-//
-// export function isRequestWithErrFn<T extends FetchJSONRequest>(
-//   x: T
-// ): x is T & RequestWithErrFn {
-//   return !!(x as RequestWithErrFn).errFn;
-// }
-
 export class GrRestApiHelper {
   constructor(
     private readonly _cache: SiteBasedCache,
@@ -262,7 +248,7 @@
     private readonly writeScheduler: Scheduler<Response>
   ) {}
 
-  private schedule(method: string, task: Task<Response>) {
+  private schedule(method: string, task: Task<Response>): Promise<Response> {
     if (method === 'PUT' || method === 'POST' || method === 'DELETE') {
       return this.writeScheduler.schedule(task);
     } else {
@@ -273,20 +259,19 @@
   /**
    * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
    * with timing and logging.
-s   */
-  fetch(req: FetchRequest): Promise<Response> {
-    const method =
-      req.fetchOptions && req.fetchOptions.method
-        ? req.fetchOptions.method
-        : 'GET';
-    const start = Date.now();
+   */
+  private fetchImpl(req: FetchRequest): Promise<Response> {
+    const method = req.fetchOptions?.method ?? HttpMethod.GET;
+    const startTime = Date.now();
     const task = async () => {
       const res = await this._auth.fetch(req.url, req.fetchOptions);
+      // Check for "too many requests" error and throw RetryError to cause a
+      // retry in this case, if the scheduler attempts retries.
       if (!res.ok && res.status === 429) throw new RetryError<Response>(res);
       return res;
     };
 
-    const xhr = this.schedule(method, task).catch((err: unknown) => {
+    const resPromise = this.schedule(method, task).catch((err: unknown) => {
       if (err instanceof RetryError) {
         return err.payload;
       } else {
@@ -295,9 +280,9 @@
     });
 
     // Log the call after it completes.
-    xhr.then(res => this._logCall(req, start, res ? res.status : null));
-    // Return the XHR directly (without the log).
-    return xhr;
+    resPromise.then(res => this.logCall(req, startTime, res.status));
+    // Return the response directly (without the log).
+    return resPromise;
   }
 
   /**
@@ -312,7 +297,7 @@
    *     is used here rather than the response object so there is no way this
    *     method can read the body stream.
    */
-  _logCall(req: FetchRequest, startTime: number, status: number | null) {
+  logCall(req: FetchRequest, startTime: number, status: number) {
     const method =
       req.fetchOptions && req.fetchOptions.method
         ? req.fetchOptions.method
@@ -343,89 +328,104 @@
   }
 
   /**
-   * Fetch JSON from url provided.
-   * Returns a Promise that resolves to a native Response.
-   * Doesn't do error checking. Supports cancel condition. Performs auth.
-   * Validates auth expiry errors.
+   * Fetch from url provided.
    *
-   * @return Promise which resolves to undefined if cancelCondition returns true
-   *     and resolves to Response otherwise
+   * Performs auth. Validates auth expiry errors.
+   * Will report any errors (by firing a corresponding event or calling errFn)
+   * that happen during the request, but doesn't inspect the status of the
+   * received response unless req.reportServerError = true.
+   *
+   * @return Promise resolves to a native Response.
+   *     If an error occurs when performing a request, promise rejects.
    */
-  fetchRawJSON(req: FetchJSONRequest): Promise<Response | undefined> {
+  async fetch(req: FetchRequest): Promise<Response> {
     const urlWithParams = this.urlWithParams(req.url, req.params);
     const fetchReq: FetchRequest = {
       url: urlWithParams,
       fetchOptions: req.fetchOptions,
       anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
     };
-    return this.fetch(fetchReq)
-      .then((res: Response) => {
-        if (req.cancelCondition && req.cancelCondition()) {
-          if (res.body) {
-            res.body.cancel();
-          }
-          return;
-        }
-        return res;
-      })
-      .catch(err => {
-        if (req.errFn) {
-          req.errFn.call(undefined, null, err);
-        } else {
-          fireNetworkError(err);
-        }
-        throw err;
-      });
+    let resp: Response;
+    try {
+      resp = await this.fetchImpl(fetchReq);
+    } catch (err) {
+      if (req.errFn) {
+        await req.errFn.call(undefined, null, err as Error);
+      } else {
+        fireNetworkError(err as Error);
+      }
+      throw err;
+    }
+    if (req.reportServerError && !resp.ok) {
+      if (req.errFn) {
+        await req.errFn.call(undefined, resp);
+      } else {
+        fireServerError(resp, req);
+      }
+    }
+    return resp;
   }
 
   /**
    * Fetch JSON from url provided.
-   * Returns a Promise that resolves to a parsed response.
-   * Same as {@link fetchRawJSON}, plus error handling.
+   *
+   * Returned promise rejects if an error occurs when performing a request or
+   * if the response payload doesn't contain a valid prefixed JSON.
+   *
+   * If response status is not 2xx, promise resolves to undefined and error is
+   * reported, through errFn callback or via 'sever-error' event. The error can
+   * be suppressed with req.reportServerError = false.
+   *
+   * If JSON parsing fails the promise rejects.
    *
    * @param noAcceptHeader - don't add default accept json header
+   * @return Promise that resolves to a parsed response.
    */
   async fetchJSON(
-    req: FetchJSONRequest,
+    req: FetchRequest,
     noAcceptHeader?: boolean
   ): Promise<ParsedJSON | undefined> {
     if (!noAcceptHeader) {
       req = this.addAcceptJsonHeader(req);
     }
-    const response = await this.fetchRawJSON(req);
-    if (!response) {
-      return;
-    }
+    req.reportServerError ??= true;
+    const response = await this.fetch(req);
     if (!response.ok) {
-      if (req.errFn) {
-        await req.errFn.call(undefined, response);
-        return;
-      }
-      fireServerError(response, req);
-      return;
+      return undefined;
     }
-    return this.getResponseObject(response);
+    // TODO(kamilm): The parsing error should likely be reported via errFn or
+    // gr-error-manager as well.
+    return (await readJSONResponsePayload(response)).parsed;
   }
 
+  /**
+   * Add extra url params to the url.
+   *
+   * Params with values (not undefined) added as <key>=<value>. If value is an
+   * array a separate <key>=<value> param is added for every value.
+   */
   urlWithParams(url: string, fetchParams?: FetchParams): string {
     if (!fetchParams) {
       return getBaseUrl() + url;
     }
 
     const params: Array<string | number | boolean> = [];
-    for (const [p, paramValue] of Object.entries(fetchParams)) {
+    for (const [paramKey, paramValue] of Object.entries(fetchParams)) {
       if (paramValue === null || paramValue === undefined) {
-        params.push(this.encodeRFC5987(p));
+        params.push(this.encodeRFC5987(paramKey));
         continue;
       }
-      // TODO(TS): Unclear, why do we need the following code.
-      // If paramValue can be array - we should either fix FetchParams type
-      // or convert the array to a string before calling urlWithParams method.
-      const paramValueAsArray = ([] as Array<string | number | boolean>).concat(
-        paramValue
-      );
-      for (const value of paramValueAsArray) {
-        params.push(`${this.encodeRFC5987(p)}=${this.encodeRFC5987(value)}`);
+
+      if (Array.isArray(paramValue)) {
+        for (const value of paramValue) {
+          params.push(
+            `${this.encodeRFC5987(paramKey)}=${this.encodeRFC5987(value)}`
+          );
+        }
+      } else {
+        params.push(
+          `${this.encodeRFC5987(paramKey)}=${this.encodeRFC5987(paramValue)}`
+        );
       }
     }
     return getBaseUrl() + url + '?' + params.join('&');
@@ -440,11 +440,7 @@
     );
   }
 
-  getResponseObject(response: Response): Promise<ParsedJSON> {
-    return readResponsePayload(response).then(payload => payload.parsed);
-  }
-
-  addAcceptJsonHeader(req: FetchJSONRequest) {
+  addAcceptJsonHeader(req: FetchRequest) {
     if (!req.fetchOptions) req.fetchOptions = {};
     if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
     if (!req.fetchOptions.headers.has('Accept')) {
@@ -453,97 +449,40 @@
     return req;
   }
 
-  fetchCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
-    if (this._fetchPromisesCache.has(req.url)) {
-      return this._fetchPromisesCache.get(req.url)!;
+  /**
+   * Fetch JSON using cached value if available.
+   *
+   * If there is an in-flight request with the same url returns the promise for
+   * the in-flight request. If previous call for the same url resulted in the
+   * successful response it is returned. Otherwise a new request is sent.
+   *
+   * Only req.url with req.params is considered for the caching key;
+   * headers or request body are not included in cache key.
+   */
+  fetchCacheJSON(req: FetchRequest): Promise<ParsedJSON | undefined> {
+    const urlWithParams = this.urlWithParams(req.url, req.params);
+    if (this._fetchPromisesCache.has(urlWithParams)) {
+      return this._fetchPromisesCache.get(urlWithParams)!;
     }
-    // TODO(andybons): Periodic cache invalidation.
-    if (this._cache.has(req.url)) {
-      return Promise.resolve(this._cache.get(req.url)!);
+    if (this._cache.has(urlWithParams)) {
+      return Promise.resolve(this._cache.get(urlWithParams)!);
     }
     this._fetchPromisesCache.set(
-      req.url,
+      urlWithParams,
       this.fetchJSON(req)
         .then(response => {
           if (response !== undefined) {
-            this._cache.set(req.url, response);
+            this._cache.set(urlWithParams, response);
           }
-          this._fetchPromisesCache.set(req.url, undefined);
+          this._fetchPromisesCache.set(urlWithParams, undefined);
           return response;
         })
         .catch(err => {
-          this._fetchPromisesCache.set(req.url, undefined);
+          this._fetchPromisesCache.set(urlWithParams, undefined);
           throw err;
         })
     );
-    return this._fetchPromisesCache.get(req.url)!;
-  }
-
-  // if errFn is not set, then only Response possible
-  send(req: SendRawRequest & {errFn?: undefined}): Promise<Response>;
-
-  send(req: SendRawRequest): Promise<Response | undefined>;
-
-  send(req: SendJSONRequest): Promise<ParsedJSON>;
-
-  send(req: SendRequest): Promise<Response | ParsedJSON | undefined>;
-
-  /**
-   * Send an XHR.
-   *
-   * @return Promise resolves to Response/ParsedJSON only if the request is successful
-   *     (i.e. no exception and response.ok is true). If response fails then
-   *     promise resolves either to void if errFn is set or rejects if errFn
-   *     is not set   */
-  async send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
-    const options: AuthRequestInit = {method: req.method};
-    if (req.body) {
-      options.headers = new Headers();
-      options.headers.set(
-        'Content-Type',
-        req.contentType || 'application/json'
-      );
-      options.body =
-        typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
-    }
-    if (req.headers) {
-      if (!options.headers) {
-        options.headers = new Headers();
-      }
-      for (const [name, value] of Object.entries(req.headers)) {
-        options.headers.set(name, value);
-      }
-    }
-    const url = req.url.startsWith('http') ? req.url : getBaseUrl() + req.url;
-    const fetchReq: FetchRequest = {
-      url,
-      fetchOptions: options,
-      anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
-    };
-    let xhr;
-    try {
-      xhr = await this.fetch(fetchReq);
-    } catch (err) {
-      fireNetworkError(err as Error);
-      if (req.errFn) {
-        await req.errFn.call(undefined, null, err as Error);
-        xhr = undefined;
-      } else {
-        throw err;
-      }
-    }
-    if (xhr && !xhr.ok) {
-      if (req.errFn) {
-        await req.errFn.call(undefined, xhr);
-      } else {
-        fireServerError(xhr, fetchReq);
-      }
-    }
-
-    if (req.parseResponse) {
-      xhr = xhr && this.getResponseObject(xhr);
-    }
-    return xhr;
+    return this._fetchPromisesCache.get(urlWithParams)!;
   }
 
   invalidateFetchPromisesPrefix(prefix: string) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
index 9f0319e..0f94a4b 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright 2019 Google LLC
+ * Copyright 2024 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../../test/common-test-setup';
@@ -8,16 +8,25 @@
   SiteBasedCache,
   FetchPromisesCache,
   GrRestApiHelper,
+  JSON_PREFIX,
+  readJSONResponsePayload,
+  parsePrefixedJSON,
 } from './gr-rest-api-helper';
-import {assertFails, waitEventLoop} from '../../../../test/test-utils';
+import {
+  addListenerForTest,
+  assertFails,
+  makePrefixedJSON,
+  waitEventLoop,
+} from '../../../../test/test-utils';
 import {FakeScheduler} from '../../../../services/scheduler/fake-scheduler';
 import {RetryScheduler} from '../../../../services/scheduler/retry-scheduler';
-import {ParsedJSON} from '../../../../types/common';
 import {HttpMethod} from '../../../../api/rest-api';
 import {SinonFakeTimers} from 'sinon';
 import {assert} from '@open-wc/testing';
 import {AuthService} from '../../../../services/gr-auth/gr-auth';
 import {GrAuthMock} from '../../../../services/gr-auth/gr-auth_mock';
+import {ParsedJSON} from '../../../../types/common';
+import {getBaseUrl} from '../../../../utils/url-util';
 
 function makeParsedJSON<T>(val: T): ParsedJSON {
   return val as unknown as ParsedJSON;
@@ -83,7 +92,7 @@
     await waitEventLoop();
   }
 
-  suite('send()', () => {
+  suite('fetch()', () => {
     setup(() => {
       authFetchStub.returns(
         Promise.resolve({
@@ -97,10 +106,11 @@
     });
 
     test('GET are sent to readScheduler', async () => {
-      const promise = helper.send({
-        method: HttpMethod.GET,
+      const promise = helper.fetch({
+        fetchOptions: {
+          method: HttpMethod.GET,
+        },
         url: '/dummy/url',
-        parseResponse: false,
       });
       assert.equal(writeScheduler.scheduled.length, 0);
       await assertReadRequest();
@@ -109,16 +119,41 @@
     });
 
     test('PUT are sent to writeScheduler', async () => {
-      const promise = helper.send({
-        method: HttpMethod.PUT,
+      const promise = helper.fetch({
+        fetchOptions: {
+          method: HttpMethod.PUT,
+        },
         url: '/dummy/url',
-        parseResponse: false,
       });
       assert.equal(readScheduler.scheduled.length, 0);
       await assertWriteRequest();
       const res: Response = await promise;
       assert.equal(await res.text(), 'Yay');
     });
+
+    test('fetch calls auth fetch and logs', async () => {
+      const logStub = sinon.stub(helper, 'logCall');
+      const response = new Response(undefined, {status: 404});
+      const url = '/my/url';
+      const fetchOptions = {method: 'DELETE'};
+      authFetchStub.resolves(response);
+      const startTime = 123;
+      sinon.stub(Date, 'now').returns(startTime);
+      helper.fetch({url, fetchOptions, anonymizedUrl: url});
+
+      await assertWriteRequest();
+      assert.isTrue(logStub.calledOnce);
+      const expectedReq = {
+        url: getBaseUrl() + url,
+        fetchOptions,
+        anonymizedUrl: url,
+      };
+      assert.deepEqual(logStub.lastCall.args, [
+        expectedReq,
+        startTime,
+        response.status,
+      ]);
+    });
   });
 
   suite('fetchJSON()', () => {
@@ -150,6 +185,111 @@
       const obj = await promise;
       assert.deepEqual(obj, makeParsedJSON({hello: 'bonjour'}));
     });
+
+    suite('error handling', () => {
+      let serverErrorCalled: boolean;
+      let networkErrorCalled: boolean;
+
+      setup(() => {
+        serverErrorCalled = false;
+        networkErrorCalled = false;
+        addListenerForTest(document, 'server-error', () => {
+          serverErrorCalled = true;
+        });
+        addListenerForTest(document, 'network-error', () => {
+          networkErrorCalled = true;
+        });
+      });
+
+      test('network error, promise rejects, event thrown', async () => {
+        authFetchStub.rejects(new Error('No response'));
+        const promise = helper.fetchJSON({url: '/dummy/url'});
+        await assertReadRequest();
+        const err = await assertFails(promise);
+        assert.equal((err as Error).message, 'No response');
+        await waitEventLoop();
+        assert.isTrue(networkErrorCalled);
+        assert.isFalse(serverErrorCalled);
+      });
+
+      test('network error, promise rejects, errFn called, no event', async () => {
+        const errFn = sinon.stub();
+        authFetchStub.rejects(new Error('No response'));
+        const promise = helper.fetchJSON({
+          url: '/dummy/url',
+          errFn,
+        });
+        await assertReadRequest();
+        const err = await assertFails(promise);
+        assert.equal((err as Error).message, 'No response');
+        await waitEventLoop();
+        assert.isTrue(errFn.called);
+        assert.isFalse(networkErrorCalled);
+        assert.isFalse(serverErrorCalled);
+      });
+
+      test('server error, promise resolves undefined, event thrown', async () => {
+        authFetchStub.returns(
+          Promise.resolve({
+            ...new Response(),
+            status: 400,
+            ok: false,
+            text() {
+              return Promise.resolve('Nope');
+            },
+          })
+        );
+        const promise = helper.fetchJSON({url: '/dummy/url'});
+        await assertReadRequest();
+        const resp = await promise;
+        assert.isUndefined(resp);
+        await waitEventLoop();
+        assert.isFalse(networkErrorCalled);
+        assert.isTrue(serverErrorCalled);
+      });
+
+      test('server error, promise resolves undefined, errFn called, no event', async () => {
+        authFetchStub.returns(
+          Promise.resolve({
+            ...new Response(),
+            status: 400,
+            ok: false,
+            text() {
+              return Promise.resolve('Nope');
+            },
+          })
+        );
+        const errFn = sinon.stub();
+        const promise = helper.fetchJSON({url: '/dummy/url', errFn});
+        await assertReadRequest();
+        const resp = await promise;
+        assert.isUndefined(resp);
+        await waitEventLoop();
+        assert.isTrue(errFn.called);
+        assert.isFalse(networkErrorCalled);
+        assert.isFalse(serverErrorCalled);
+      });
+
+      test('parsing error, promise rejects', async () => {
+        authFetchStub.returns(
+          Promise.resolve({
+            ...new Response(),
+            ok: true,
+            text() {
+              return Promise.resolve('not a prefixed json');
+            },
+          })
+        );
+        const errFn = sinon.stub();
+        const promise = helper.fetchJSON({url: '/dummy/url', errFn});
+        await assertReadRequest();
+        await assertFails(promise);
+        await waitEventLoop();
+        assert.isFalse(errFn.called);
+        assert.isFalse(networkErrorCalled);
+        assert.isFalse(serverErrorCalled);
+      });
+    });
   });
 
   test('cached results', () => {
@@ -158,9 +298,9 @@
       .stub(helper, 'fetchJSON')
       .callsFake(() => Promise.resolve(makeParsedJSON(++n)));
     const promises = [];
-    promises.push(helper.fetchCacheURL({url: '/foo'}));
-    promises.push(helper.fetchCacheURL({url: '/foo'}));
-    promises.push(helper.fetchCacheURL({url: '/foo'}));
+    promises.push(helper.fetchCacheJSON({url: '/foo'}));
+    promises.push(helper.fetchCacheJSON({url: '/foo'}));
+    promises.push(helper.fetchCacheJSON({url: '/foo'}));
 
     return Promise.all(promises).then(results => {
       assert.deepEqual(results, [
@@ -168,12 +308,41 @@
         makeParsedJSON(1),
         makeParsedJSON(1),
       ]);
-      return helper.fetchCacheURL({url: '/foo'}).then(foo => {
+      return helper.fetchCacheJSON({url: '/foo'}).then(foo => {
         assert.equal(foo, makeParsedJSON(1));
       });
     });
   });
 
+  test('cached results with param', () => {
+    let n = 0;
+    sinon
+      .stub(helper, 'fetchJSON')
+      .callsFake(() => Promise.resolve(makeParsedJSON(++n)));
+    const promises = [];
+    promises.push(
+      helper.fetchCacheJSON({url: '/foo', params: {hello: 'world'}})
+    );
+    promises.push(helper.fetchCacheJSON({url: '/foo'}));
+    promises.push(
+      helper.fetchCacheJSON({url: '/foo', params: {hello: 'world'}})
+    );
+
+    return Promise.all(promises).then(results => {
+      assert.deepEqual(results, [
+        makeParsedJSON(1),
+        // The url without params is queried again, since it has different url.
+        makeParsedJSON(2),
+        makeParsedJSON(1),
+      ]);
+      return helper
+        .fetchCacheJSON({url: '/foo', params: {hello: 'world'}})
+        .then(foo => {
+          assert.equal(foo, makeParsedJSON(1));
+        });
+    });
+  });
+
   test('cache invalidation', async () => {
     cache.set('/foo/bar', makeParsedJSON(1));
     cache.set('/bar', makeParsedJSON(2));
@@ -191,10 +360,11 @@
       sp: 'hola',
       gr: 'guten tag',
       noval: null,
+      novaltoo: undefined,
     });
     assert.equal(
       url,
-      `${window.CANONICAL_PATH}/path/?sp=hola&gr=guten%20tag&noval`
+      `${window.CANONICAL_PATH}/path/?sp=hola&gr=guten%20tag&noval&novaltoo`
     );
 
     url = helper.urlWithParams('/path/', {
@@ -210,25 +380,6 @@
     assert.equal(url, `${window.CANONICAL_PATH}/path/?l=c&l=b&l=a`);
   });
 
-  test('request callbacks can be canceled', async () => {
-    let cancelCalled = false;
-    authFetchStub.returns(
-      Promise.resolve({
-        body: {
-          cancel() {
-            cancelCalled = true;
-          },
-        },
-      })
-    );
-    const cancelCondition = () => true;
-    const promise = helper.fetchJSON({url: '/dummy/url', cancelCondition});
-    await assertReadRequest();
-    const obj = await promise;
-    assert.isUndefined(obj);
-    assert.isTrue(cancelCalled);
-  });
-
   suite('throwing in errFn', () => {
     function throwInPromise(response?: Response | null, _?: Error) {
       return response?.text().then(text => {
@@ -253,19 +404,6 @@
       );
     });
 
-    test('errFn with Promise throw cause send to reject on error', async () => {
-      const promise = helper.send({
-        method: HttpMethod.GET,
-        url: '/dummy/url',
-        parseResponse: false,
-        errFn: throwInPromise,
-      });
-      await assertReadRequest();
-
-      const err = await assertFails(promise);
-      assert.equal((err as Error).message, 'Nope');
-    });
-
     test('errFn with Promise throw cause fetchJSON to reject on error', async () => {
       const promise = helper.fetchJSON({
         url: '/dummy/url',
@@ -277,20 +415,7 @@
       assert.equal((err as Error).message, 'Nope');
     });
 
-    test('errFn with immediate throw cause send to reject on error', async () => {
-      const promise = helper.send({
-        method: HttpMethod.GET,
-        url: '/dummy/url',
-        parseResponse: false,
-        errFn: throwImmediately,
-      });
-      await assertReadRequest();
-
-      const err = await assertFails(promise);
-      assert.equal((err as Error).message, 'Error Callback error');
-    });
-
-    test('errFn with immediate Promise cause fetchJSON to reject on error', async () => {
+    test('errFn with immediate throw cause fetchJSON to reject on error', async () => {
       const promise = helper.fetchJSON({
         url: '/dummy/url',
         errFn: throwImmediately,
@@ -313,12 +438,10 @@
       );
     });
 
-    test('still call errFn when not retried', async () => {
+    test('non-retry scheduler errFn is called on 429 error', async () => {
       const errFn = sinon.stub();
-      const promise = helper.send({
-        method: HttpMethod.GET,
+      const promise = helper.fetchJSON({
         url: '/dummy/url',
-        parseResponse: false,
         errFn,
       });
       await assertReadRequest();
@@ -329,21 +452,21 @@
       assert.isTrue(errFn.called);
     });
 
-    test('still pass through correctly when not retried', async () => {
-      const promise = helper.send({
-        method: HttpMethod.GET,
+    test('non-retry scheduler 429 error is returned without retrying', async () => {
+      const promise = helper.fetch({
         url: '/dummy/url',
-        parseResponse: false,
       });
       await assertReadRequest();
 
-      // But we expect the result from the network to return a 429 error when
-      // it's no longer being retried.
+      // With RetryScheduler we retry if the server returns response with 429
+      // status.
+      // If we are not using RetryScheduler the response with 429 should simply
+      // be returned from fetch without retrying.
       const res: Response = await promise;
       assert.equal(res.status, 429);
     });
 
-    test('are retried', async () => {
+    test('With RetryScheduler 429 errors are retried', async () => {
       helper = new GrRestApiHelper(
         cache,
         authService,
@@ -351,10 +474,8 @@
         new RetryScheduler<Response>(readScheduler, 1, 50),
         writeScheduler
       );
-      const promise = helper.send({
-        method: HttpMethod.GET,
+      const promise = helper.fetch({
         url: '/dummy/url',
-        parseResponse: false,
       });
       await assertReadRequest();
       authFetchStub.returns(
@@ -375,4 +496,44 @@
       assert.equal(await res.text(), 'Yay');
     });
   });
+
+  suite('reading responses', () => {
+    test('readResponsePayload', async () => {
+      const mockObject = {foo: 'bar', baz: 'foo'} as unknown as ParsedJSON;
+      const serial = makePrefixedJSON(mockObject);
+      const response = new Response(serial);
+      const payload = await readJSONResponsePayload(response);
+      assert.deepEqual(payload.parsed, mockObject);
+      assert.equal(payload.raw, serial);
+    });
+
+    test('parsePrefixedJSON', () => {
+      const obj = {x: 3, y: {z: 4}, w: 23} as unknown as ParsedJSON;
+      const serial = JSON_PREFIX + JSON.stringify(obj);
+      const result = parsePrefixedJSON(serial);
+      assert.deepEqual(result, obj);
+    });
+
+    test('parsing error', async () => {
+      const response = new Response('[');
+      const err: Error = await assertFails(readJSONResponsePayload(response));
+      assert.equal(
+        err.message,
+        'Response payload is not prefixed json. Payload: ['
+      );
+    });
+  });
+
+  test('logCall only reports requests with anonymized URLs', async () => {
+    sinon.stub(Date, 'now').returns(200);
+    const handler = sinon.stub();
+    addListenerForTest(document, 'gr-rpc-log', handler);
+
+    helper.logCall({url: 'url'}, 100, 200);
+    assert.isFalse(handler.called);
+
+    helper.logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
+    await waitEventLoop();
+    assert.isTrue(handler.calledOnce);
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 69cdedd..923a00e 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -7,7 +7,7 @@
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {getAppContext} from '../../../services/app-context';
-import {Comment} from '../../../types/common';
+import {Comment, EDIT, BasePatchSetNum, RepoName} from '../../../types/common';
 import {anyLineTooLong} from '../../../utils/diff-util';
 import {
   DiffLayer,
@@ -19,15 +19,17 @@
 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 {FixSuggestionInfo, NumericChangeId} from '../../../api/rest-api';
 import {changeModelToken} from '../../../models/change/change-model';
 import {subscribe} from '../../lit/subscription-controller';
 import {FilePreview} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {userModelToken} from '../../../models/user/user-model';
 import {createUserFixSuggestion} from '../../../utils/comment-util';
 import {commentModelToken} from '../gr-comment-model/gr-comment-model';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {fire} from '../../../utils/event-util';
 import {Interaction, Timing} from '../../../constants/reporting';
+import {createChangeUrl} from '../../../models/views/change';
 
 declare global {
   interface HTMLElementEventMap {
@@ -41,11 +43,22 @@
   code: string;
 }
 
+/**
+ * Diff preview for
+ * 1. code block suggestion vs commented Text
+ * or 2. fixSuggestionInfo that are attached to a comment.
+ *
+ * It shouldn't be created with both 1. and 2. but if it is
+ * it shows just for 1. (code block suggestion)
+ */
 @customElement('gr-suggestion-diff-preview')
 export class GrSuggestionDiffPreview extends LitElement {
   @property({type: String})
   suggestion?: string;
 
+  @property({type: Object})
+  fixSuggestionInfo?: FixSuggestionInfo;
+
   @property({type: Boolean})
   showAddSuggestionButton = false;
 
@@ -62,7 +75,9 @@
   layers: DiffLayer[] = [];
 
   @state()
-  previewLoadedFor?: string;
+  previewLoadedFor?: string | FixSuggestionInfo;
+
+  @state() repo?: RepoName;
 
   @state()
   changeNum?: NumericChangeId;
@@ -90,6 +105,8 @@
 
   private readonly getCommentModel = resolve(this, commentModelToken);
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
     resolve(this, highlightServiceToken),
     () => getAppContext().reportingService
@@ -121,6 +138,11 @@
       () => this.getCommentModel().commentedText$,
       commentedText => (this.commentedText = commentedText)
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repo = x)
+    );
   }
 
   static override get styles() {
@@ -156,10 +178,16 @@
         this.fetchFixPreview();
       }
     }
+
+    if (changed.has('changeNum') || changed.has('comment')) {
+      if (this.previewLoadedFor !== this.fixSuggestionInfo) {
+        this.fetchfixSuggestionInfoPreview();
+      }
+    }
   }
 
   override render() {
-    if (!this.suggestion) return nothing;
+    if (!this.suggestion && !this.fixSuggestionInfo) return nothing;
     const code = this.suggestion;
     return html`
       ${when(
@@ -233,6 +261,97 @@
     return res;
   }
 
+  private async fetchfixSuggestionInfoPreview() {
+    if (
+      this.suggestion ||
+      !this.changeNum ||
+      !this.comment?.patch_set ||
+      !this.fixSuggestionInfo
+    )
+      return;
+
+    this.reporting.time(Timing.PREVIEW_FIX_LOAD);
+    const res = await this.restApiService.getFixPreview(
+      this.changeNum,
+      this.comment?.patch_set,
+      this.fixSuggestionInfo.replacements
+    );
+
+    if (!res) return;
+    const currentPreviews = Object.keys(res).map(key => {
+      return {filepath: key, preview: res[key]};
+    });
+    this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD, {
+      uuid: this.uuid,
+    });
+    if (currentPreviews.length > 0) {
+      this.preview = currentPreviews[0];
+      this.previewLoadedFor = this.fixSuggestionInfo;
+    }
+
+    return res;
+  }
+
+  /**
+   * Applies a fix (fix_suggestion in comment) previewed in
+   * `suggestion-diff-preview`, navigating to the new change URL with the EDIT
+   * patchset.
+   *
+   * Similar code flow is in gr-apply-fix-dialog.handleApplyFix
+   * Used in gr-fix-suggestions
+   */
+  public applyFixSuggestion() {
+    if (this.suggestion || !this.fixSuggestionInfo) return;
+    this.applyFix(this.fixSuggestionInfo);
+  }
+
+  /**
+   * Applies a fix (codeblock in comment message) previewed in
+   * `suggestion-diff-preview`, navigating to the new change URL with the EDIT
+   * patchset.
+   *
+   * Similar code flow is in gr-apply-fix-dialog.handleApplyFix
+   * Used in gr-user-suggestion-fix
+   */
+  public applyUserSuggestedFix() {
+    if (!this.comment || !this.suggestion || !this.commentedText) return;
+
+    const fixSuggestions = createUserFixSuggestion(
+      this.comment,
+      this.commentedText,
+      this.suggestion
+    );
+    this.applyFix(fixSuggestions[0]);
+  }
+
+  private async applyFix(fixSuggestion: FixSuggestionInfo) {
+    const changeNum = this.changeNum;
+    const basePatchNum = this.comment?.patch_set as BasePatchSetNum;
+    if (!changeNum || !basePatchNum || !fixSuggestion) return;
+
+    this.reporting.time(Timing.APPLY_FIX_LOAD);
+    const res = await this.restApiService.applyFixSuggestion(
+      changeNum,
+      basePatchNum,
+      fixSuggestion.replacements
+    );
+    this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, {
+      method: '1-click',
+      description: fixSuggestion.description,
+    });
+    if (res?.ok) {
+      this.getNavigation().setUrl(
+        createChangeUrl({
+          changeNum,
+          repo: this.repo!,
+          patchNum: EDIT,
+          basePatchNum,
+        })
+      );
+      fire(this, 'apply-user-suggestion', {});
+    }
+  }
+
   private overridePartialDiffPrefs() {
     if (!this.diffPrefs) return undefined;
     return {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 1501205..7f70911 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -329,7 +329,12 @@
   }
 
   private handleEscKey(e: KeyboardEvent) {
-    if (!this.isDropdownVisible()) {
+    // Esc should have normal behavior if the picker is closed, or "open" but
+    // with no results.
+    if (
+      !this.isDropdownVisible() ||
+      this.getVisibleDropdown().getCurrentText() === ''
+    ) {
       return;
     }
     e.preventDefault();
@@ -338,7 +343,12 @@
   }
 
   private handleUpKey(e: KeyboardEvent) {
-    if (!this.isDropdownVisible()) {
+    // Up should have normal behavior if the picker is closed, or "open" but
+    // with no results.
+    if (
+      !this.isDropdownVisible() ||
+      this.getVisibleDropdown().getCurrentText() === ''
+    ) {
       return;
     }
     e.preventDefault();
@@ -348,7 +358,12 @@
   }
 
   private handleDownKey(e: KeyboardEvent) {
-    if (!this.isDropdownVisible()) {
+    // Down should have normal behavior if the picker is closed, or "open" but
+    // with no results.
+    if (
+      !this.isDropdownVisible() ||
+      this.getVisibleDropdown().getCurrentText() === ''
+    ) {
       return;
     }
     e.preventDefault();
@@ -358,8 +373,12 @@
   }
 
   private handleTabKey(e: KeyboardEvent) {
-    // Tab should have normal behavior if the picker is closed.
-    if (!this.isDropdownVisible()) {
+    // Tab should have normal behavior if the picker is closed, or "open" but
+    // with no results.
+    if (
+      !this.isDropdownVisible() ||
+      this.getVisibleDropdown().getCurrentText() === ''
+    ) {
       return;
     }
     e.preventDefault();
@@ -587,7 +606,7 @@
   // TODO(dhruvsri): merge with getAccountSuggestions in account-util
   async computeReviewerSuggestions(): Promise<Item[]> {
     return (
-      (await this.restApiService.getSuggestedAccounts(
+      (await this.restApiService.queryAccounts(
         this.currentSearchString ?? '',
         /* number= */ 15,
         this.changeNum,
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 4aef66e..d84f5a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -59,7 +59,7 @@
       // updated.
       const listenerStub = sinon.stub();
       element.addEventListener('text-changed', listenerStub);
-      stubRestApi('getSuggestedAccounts').returns(
+      stubRestApi('queryAccounts').returns(
         Promise.resolve([
           createAccountWithEmail('abc@google.com'),
           createAccountWithEmail('abcdef@google.com'),
@@ -93,7 +93,7 @@
     });
 
     test('mention selector opens when previous char is \n', async () => {
-      stubRestApi('getSuggestedAccounts').returns(
+      stubRestApi('queryAccounts').returns(
         Promise.resolve([
           {
             ...createAccountWithEmail('abc@google.com'),
@@ -130,7 +130,7 @@
 
     test('mention suggestions cleared before request returns', async () => {
       const promise = mockPromise<Item[]>();
-      stubRestApi('getSuggestedAccounts').returns(promise);
+      stubRestApi('queryAccounts').returns(promise);
       element.textarea!.focus();
       await waitUntil(() => element.textarea!.focused === true);
 
@@ -164,7 +164,7 @@
     test('mention dropdown shows suggestion for latest text', async () => {
       const promise1 = mockPromise<Item[]>();
       const promise2 = mockPromise<Item[]>();
-      const suggestionStub = stubRestApi('getSuggestedAccounts');
+      const suggestionStub = stubRestApi('queryAccounts');
       suggestionStub.returns(promise1);
       element.textarea!.focus();
       await waitUntil(() => element.textarea!.focused === true);
@@ -221,7 +221,7 @@
     });
 
     test('selecting mentions from dropdown', async () => {
-      stubRestApi('getSuggestedAccounts').returns(
+      stubRestApi('queryAccounts').returns(
         Promise.resolve([
           createAccountWithEmail('abc@google.com'),
           createAccountWithEmail('abcdef@google.com'),
@@ -254,7 +254,7 @@
       const listenerStub = sinon.stub();
       element.addEventListener('text-changed', listenerStub);
       const resetSpy = sinon.spy(element, 'resetDropdown');
-      stubRestApi('getSuggestedAccounts').returns(
+      stubRestApi('queryAccounts').returns(
         Promise.resolve([
           createAccountWithEmail('abc@google.com'),
           createAccountWithEmail('abcdef@google.com'),
@@ -348,7 +348,7 @@
     });
 
     test('mention dropdown is cleared if @ is deleted', async () => {
-      stubRestApi('getSuggestedAccounts').returns(
+      stubRestApi('queryAccounts').returns(
         Promise.resolve([
           createAccountWithEmail('abc@google.com'),
           createAccountWithEmail('abcdef@google.com'),
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
index 4ccc635..343de2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
@@ -9,7 +9,7 @@
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 
-const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
+const ARROW_HEIGHT = 7.2; // Height of the arrow in tooltip.
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -141,7 +141,8 @@
     // Set visibility to hidden before appending to the DOM so that
     // calculations can be made based on the element’s size.
     tooltip.style.visibility = 'hidden';
-    document.body.appendChild(tooltip);
+    const parent = this.getTooltipParent(this);
+    parent.appendChild(tooltip);
     await tooltip.updateComplete;
     this._positionTooltip(tooltip);
     tooltip.style.visibility = 'initial';
@@ -154,6 +155,22 @@
     }
   }
 
+  getTooltipParent(el: Node): Node {
+    if (el === document.body) {
+      return el;
+    }
+    if (el instanceof HTMLDialogElement) {
+      return el;
+    }
+    if (el instanceof ShadowRoot) {
+      return this.getTooltipParent(el.host);
+    }
+    if (el.parentNode) {
+      return this.getTooltipParent(el.parentNode);
+    }
+    return document.body;
+  }
+
   _handleHideTooltip(e?: Event) {
     if (this.isTouchDevice) {
       return;
@@ -197,27 +214,62 @@
   // private but used in tests.
   _positionTooltip(tooltip: GrTooltip | null) {
     if (tooltip === null) return;
-    const rect = this.getBoundingClientRect();
-    const boxRect = tooltip.getBoundingClientRect();
+    const hoveredRect = this.getBoundingClientRect();
+    const tooltipRect = tooltip.getBoundingClientRect();
     if (!tooltip.parentElement) {
       return;
     }
     const parentRect = tooltip.parentElement.getBoundingClientRect();
-    const top = rect.top - parentRect.top;
-    const left = rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
-    const right = parentRect.width - left - boxRect.width;
-    if (left < 0) {
-      tooltip.arrowCenterOffset = `${left}px`;
-    } else if (right < 0) {
-      tooltip.arrowCenterOffset = `${-0.5 * right}px`;
-    }
-    tooltip.style.left = `${Math.max(0, left)}px`;
+    // Use clientWidth to not include the scrollbars
+    const parentWidth = tooltip.parentElement.clientWidth;
 
-    if (!this.positionBelow) {
-      tooltip.style.top = `${Math.max(0, top)}px`;
-      tooltip.style.transform = `translateY(calc(-100% - ${BOTTOM_OFFSET}px))`;
-    } else {
-      tooltip.style.top = `${top + rect.height + BOTTOM_OFFSET}px`;
+    const hoveredCenter =
+      0.5 * (hoveredRect.left + hoveredRect.right) - parentRect.left;
+    const left = this.computeLeft(tooltipRect, hoveredCenter, parentWidth);
+    const {isBelow, top} = this.computeTop(
+      tooltipRect,
+      hoveredRect,
+      parentRect
+    );
+    const tooltipCenter = left + 0.5 * tooltipRect.width;
+
+    tooltip.arrowCenterOffset = `${hoveredCenter - tooltipCenter}px`;
+    tooltip.positionBelow = isBelow;
+    tooltip.style.top = `${top}px`;
+    tooltip.style.left = `${left}px`;
+  }
+
+  private computeLeft(
+    tooltipRect: DOMRect,
+    hoveredCenter: number,
+    parentWidth: number
+  ) {
+    let left = hoveredCenter - 0.5 * tooltipRect.width;
+    if (left + tooltipRect.width > parentWidth - 1) {
+      // Add 1px of extra padding. Without it on some browser zoom levels
+      // the hovercard is still considered going out of bounds and gets
+      // reshaped.
+      left = parentWidth - tooltipRect.width - 1;
     }
+    return Math.max(0, left);
+  }
+
+  private computeTop(
+    tooltipRect: DOMRect,
+    hoveredRect: DOMRect,
+    parentRect: DOMRect
+  ): {
+    isBelow: boolean;
+    top: number;
+  } {
+    const top =
+      hoveredRect.top - parentRect.top - tooltipRect.height - ARROW_HEIGHT;
+    if (this.positionBelow || top < 0) {
+      return {
+        isBelow: true,
+        top: hoveredRect.bottom - parentRect.top + ARROW_HEIGHT,
+      };
+    }
+    return {isBelow: false, top};
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
index f4dbc3e5..a9080e8 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
@@ -24,6 +24,7 @@
         getBoundingClientRect() {
           return parentRect;
         },
+        clientWidth: parentRect.width,
       },
     };
   }
@@ -67,24 +68,41 @@
   });
 
   test('normal position', () => {
-    sinon
-      .stub(element, 'getBoundingClientRect')
-      .callsFake(() => ({top: 100, left: 100, width: 200} as DOMRect));
+    sinon.stub(element, 'getBoundingClientRect').callsFake(
+      () =>
+        ({
+          top: 100,
+          left: 100,
+          width: 200,
+          right: 300,
+          height: 50,
+          bottom: 150,
+        } as DOMRect)
+    );
     const tooltip = makeTooltip(
       {height: 30, width: 50} as DOMRect,
       {top: 0, left: 0, width: 1000} as DOMRect
     ) as GrTooltip;
 
     element._positionTooltip(tooltip);
-    assert.equal(tooltip.arrowCenterOffset, '0');
+    assert.equal(tooltip.arrowCenterOffset, '0px');
     assert.equal(tooltip.style.left, '175px');
-    assert.equal(tooltip.style.top, '100px');
+    // 100 - tooltip height (30) - arrow height (7.2)
+    assert.equal(tooltip.style.top, '62.8px');
   });
 
   test('left side position', async () => {
-    sinon
-      .stub(element, 'getBoundingClientRect')
-      .callsFake(() => ({top: 100, left: 10, width: 50} as DOMRect));
+    sinon.stub(element, 'getBoundingClientRect').callsFake(
+      () =>
+        ({
+          top: 100,
+          left: 10,
+          width: 50,
+          right: 60,
+          height: 50,
+          bottom: 150,
+        } as DOMRect)
+    );
     const tooltip = makeTooltip(
       {height: 30, width: 120} as DOMRect,
       {top: 0, left: 0, width: 1000} as DOMRect
@@ -92,32 +110,52 @@
 
     element._positionTooltip(tooltip);
     await element.updateComplete;
-    assert.isBelow(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
+    // Aligned with left edge
     assert.equal(tooltip.style.left, '0px');
-    assert.equal(tooltip.style.top, '100px');
+    // element center (35) - tooltip center (60)
+    assert.equal(tooltip.arrowCenterOffset, '-25px');
+    // 100 - tooltip height (30) - arrow height (7.2)
+    assert.equal(tooltip.style.top, '62.8px');
   });
 
   test('right side position', () => {
-    sinon
-      .stub(element, 'getBoundingClientRect')
-      .callsFake(() => ({top: 100, left: 950, width: 50} as DOMRect));
+    sinon.stub(element, 'getBoundingClientRect').callsFake(
+      () =>
+        ({
+          top: 100,
+          left: 950,
+          width: 50,
+          right: 1000,
+          height: 50,
+          bottom: 150,
+        } as DOMRect)
+    );
     const tooltip = makeTooltip(
       {height: 30, width: 120} as DOMRect,
       {top: 0, left: 0, width: 1000} as DOMRect
     ) as GrTooltip;
 
     element._positionTooltip(tooltip);
-    assert.isAbove(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '915px');
-    assert.equal(tooltip.style.top, '100px');
+    // Aligned with right edge: 1000 - tooltip width (120) - 1px pad
+    assert.equal(tooltip.style.left, '879px');
+    // element center (975) - tooltip center (939)
+    assert.equal(tooltip.arrowCenterOffset, '36px');
+    // 100 - tooltip height (30) - arrow height (7.2)
+    assert.equal(tooltip.style.top, '62.8px');
   });
 
   test('position to bottom', () => {
-    sinon
-      .stub(element, 'getBoundingClientRect')
-      .callsFake(
-        () => ({top: 100, left: 950, width: 50, height: 50} as DOMRect)
-      );
+    sinon.stub(element, 'getBoundingClientRect').callsFake(
+      () =>
+        ({
+          top: 100,
+          left: 950,
+          width: 50,
+          height: 50,
+          right: 1000,
+          bottom: 150,
+        } as DOMRect)
+    );
     const tooltip = makeTooltip(
       {height: 30, width: 120} as DOMRect,
       {top: 0, left: 0, width: 1000} as DOMRect
@@ -125,11 +163,42 @@
 
     element.positionBelow = true;
     element._positionTooltip(tooltip);
-    assert.isAbove(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '915px');
+    // Aligned with right edge: 1000 - tooltip width (120) - 1px pad
+    assert.equal(tooltip.style.left, '879px');
+    // element center (975) - tooltip center (939)
+    assert.equal(tooltip.arrowCenterOffset, '36px');
+    // 150 + arrow height (7.2)
     assert.equal(tooltip.style.top, '157.2px');
   });
 
+  test('automatic flip to bottom', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(
+      () =>
+        ({
+          // Not enough space for arrow
+          top: 30,
+          left: 950,
+          width: 50,
+          height: 50,
+          right: 1000,
+          bottom: 80,
+        } as DOMRect)
+    );
+    const tooltip = makeTooltip(
+      {height: 30, width: 120} as DOMRect,
+      {top: 0, left: 0, width: 1000} as DOMRect
+    ) as GrTooltip;
+
+    element.positionBelow = true;
+    element._positionTooltip(tooltip);
+    // Aligned with right edge: 1000 - tooltip width (120) - 1px pad
+    assert.equal(tooltip.style.left, '879px');
+    // element center (975) - tooltip center (939)
+    assert.equal(tooltip.arrowCenterOffset, '36px');
+    // 150 + arrow height (7.2)
+    assert.equal(tooltip.style.top, '87.2px');
+  });
+
   test('hides tooltip when detached', async () => {
     const handleHideTooltipStub = sinon.stub(element, '_handleHideTooltip');
     element.remove();
@@ -181,4 +250,61 @@
     await element.updateComplete;
     assert.isNotOk(element.tooltip);
   });
+
+  suite('getTooltipParent', () => {
+    let divInDialog: HTMLDivElement;
+    let divInDialogShadow: ShadowRoot;
+    let div: HTMLDivElement;
+    let divShadow: ShadowRoot;
+    let dialog: HTMLDialogElement;
+
+    setup(() => {
+      divInDialog = document.createElement('div');
+      divInDialogShadow = divInDialog.attachShadow({mode: 'open'});
+      div = document.createElement('div');
+      divShadow = div.attachShadow({mode: 'open'});
+      dialog = document.createElement('dialog');
+      document.body.appendChild(div);
+      document.body.appendChild(dialog);
+      dialog.appendChild(divInDialog);
+    });
+
+    test('tooltip in the div', () => {
+      const el = document.createElement('gr-tooltip-content');
+      divShadow.appendChild(el);
+      const tooltipParent = el.getTooltipParent(el);
+      assert.strictEqual(
+        tooltipParent,
+        document.body,
+        'Tooltip expected to be attached to body'
+      );
+    });
+
+    test('tooltip in the dialog', () => {
+      const el = document.createElement('gr-tooltip-content');
+      dialog.appendChild(el);
+      const tooltipParent = el.getTooltipParent(el);
+      assert.strictEqual(
+        tooltipParent,
+        dialog,
+        'Tooltip expected to be attached to dialog'
+      );
+    });
+
+    test('tooltip in the div in the dialog', () => {
+      const el = document.createElement('gr-tooltip-content');
+      divInDialogShadow.appendChild(el);
+      const tooltipParent = el.getTooltipParent(el);
+      assert.strictEqual(
+        tooltipParent,
+        dialog,
+        'Tooltip expected to be attached to dialog'
+      );
+    });
+
+    teardown(() => {
+      document.body.removeChild(div);
+      document.body.removeChild(dialog);
+    });
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
index c1ba729..74cc9f7 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -80,7 +80,7 @@
   override render() {
     this.style.maxWidth = this.maxWidth;
 
-    return html` <div class="tooltip">
+    return html` <div class="tooltip" aria-live="polite" role="tooltip">
       <i
         class="arrowPositionBelow arrow"
         style=${styleMap({marginLeft: this.arrowCenterOffset})}
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
index bc0cfba..187e375 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
@@ -24,7 +24,7 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <div class="tooltip">
+        <div class="tooltip" aria-live="polite" role="tooltip">
           <i class="arrow arrowPositionBelow" style="margin-left:0;"> </i>
           <div class="text">tooltipText</div>
           <i class="arrow arrowPositionAbove" style="margin-left:0;"> </i>
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 6322123..c8de209 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
@@ -8,12 +8,16 @@
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
 import {css, html, LitElement, nothing} from 'lit';
-import {customElement, state} from 'lit/decorators.js';
+import {customElement, state, query} from 'lit/decorators.js';
 import {fire} from '../../../utils/event-util';
 import {getDocUrl} from '../../../utils/url-util';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
+import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {changeModelToken} from '../../../models/change/change-model';
+import {Comment, isDraft, PatchSetNumber} from '../../../types/common';
+import {commentModelToken} from '../gr-comment-model/gr-comment-model';
 
 declare global {
   interface HTMLElementEventMap {
@@ -29,10 +33,23 @@
 
 @customElement('gr-user-suggestion-fix')
 export class GrUserSuggestionsFix extends LitElement {
+  @query('gr-suggestion-diff-preview')
+  suggestionDiffPreview?: GrSuggestionDiffPreview;
+
   @state() private docsBaseUrl = '';
 
+  @state() private applyingFix = false;
+
+  @state() latestPatchNum?: PatchSetNumber;
+
+  @state() comment?: Comment;
+
   private readonly getConfigModel = resolve(this, configModelToken);
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getCommentModel = resolve(this, commentModelToken);
+
   constructor() {
     super();
     subscribe(
@@ -40,6 +57,16 @@
       () => this.getConfigModel().docsBaseUrl$,
       docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getCommentModel().comment$,
+      comment => (this.comment = comment)
+    );
   }
 
   static override get styles() {
@@ -94,6 +121,17 @@
           >
             Show edit
           </gr-button>
+          <gr-button
+            secondary
+            flatten
+            .loading=${this.applyingFix}
+            .disabled=${this.isApplyEditDisabled()}
+            class="action show-fix"
+            @click=${this.handleApplyFix}
+            .title=${this.computeApplyEditTooltip()}
+          >
+            Apply edit
+          </gr-button>
         </div>
       </div>
       <gr-suggestion-diff-preview
@@ -105,6 +143,26 @@
     if (!this.textContent) return;
     fire(this, 'open-user-suggest-preview', {code: this.textContent});
   }
+
+  async handleApplyFix() {
+    if (!this.textContent) return;
+    this.applyingFix = true;
+    await this.suggestionDiffPreview?.applyUserSuggestedFix();
+    this.applyingFix = false;
+  }
+
+  private isApplyEditDisabled() {
+    if (this.comment?.patch_set === undefined) return true;
+    if (isDraft(this.comment)) return true;
+    return this.comment.patch_set !== this.latestPatchNum;
+  }
+
+  private computeApplyEditTooltip() {
+    if (this.comment?.patch_set === undefined) return '';
+    return this.comment.patch_set !== this.latestPatchNum
+      ? 'You cannot apply this fix because it is from a previous patchset'
+      : '';
+  }
 }
 
 declare global {
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 b7d73b3..52fd687 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
@@ -67,6 +67,16 @@
               tabindex="0"
               flatten=""
               >Show edit</gr-button
+            ><gr-button
+              aria-disabled="true"
+              disabled=""
+              class="action show-fix"
+              secondary=""
+              role="button"
+              tabindex="-1"
+              flatten=""
+              title="You cannot apply this fix because it is from a previous patchset"
+              >Apply edit</gr-button
             >
           </div>
         </div>
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
index cf507e8..7a368db 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
@@ -6,10 +6,9 @@
 import '../../../elements/shared/gr-button/gr-button';
 import {html, LitElement} from 'lit';
 import {property, state} from 'lit/decorators.js';
-import {DiffInfo, DiffViewMode, RenderPreferences} from '../../../api/diff';
+import {DiffViewMode} from '../../../api/diff';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {diffClasses} from '../gr-diff/gr-diff-utils';
-import {getShowConfig} from './gr-context-controls';
+import {getShowConfig, showAbove, showBelow} from './gr-context-controls';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {when} from 'lit/directives/when.js';
 import {subscribe} from '../../../elements/lit/subscription-controller';
@@ -20,30 +19,24 @@
   NO_COLUMNS,
 } from '../gr-diff-model/gr-diff-model';
 
+/**
+ * This is a <tbody> diff section corresponding to a diff group of type
+ * CONTEXT_CONTROL. It typically contains three table rows: One padding row
+ * each at the top and at the bottom. The middle row contains the table cell
+ * (spanning mostly the entire table) that renders the actual control buttons
+ * in the <gr-context-controls> child component.
+ */
 export class GrContextControlsSection extends LitElement {
-  /** Should context controls be rendered for expanding above the section? */
-  @property({type: Boolean}) showAbove = false;
-
-  /** Should context controls be rendered for expanding below the section? */
-  @property({type: Boolean}) showBelow = false;
-
   /** Must be of type GrDiffGroupType.CONTEXT_CONTROL. */
   @property({type: Object})
   group?: GrDiffGroup;
 
-  @property({type: Object})
-  diff?: DiffInfo;
-
-  @property({type: Object})
-  renderPrefs?: RenderPreferences;
-
   /**
    * Semantic DOM diff testing does not work with just table fragments, so when
    * running such tests the render() method has to wrap the DOM in a proper
    * <table> element.
    */
-  @state()
-  addTableWrapperForTesting = false;
+  @state() addTableWrapperForTesting = false;
 
   @state() viewMode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
 
@@ -51,6 +44,8 @@
 
   @state() columnCount = 0;
 
+  @state() lineCountLeft = 0;
+
   private readonly getDiffModel = resolve(this, diffModelToken);
 
   constructor() {
@@ -70,6 +65,11 @@
       () => this.getDiffModel().columnCount$,
       columnCount => (this.columnCount = columnCount)
     );
+    subscribe(
+      this,
+      () => this.getDiffModel().lineCountLeft$,
+      lineCountLeft => (this.lineCountLeft = lineCountLeft)
+    );
   }
 
   /**
@@ -78,53 +78,48 @@
    * into the light DOM instead of the shadow DOM by overriding this method,
    * which was the recommended workaround by the lit team.
    * See also https://github.com/WICG/webcomponents/issues/79.
+   *
+   * Note that the <gr-context-controls> child component *does* use the shadow
+   * DOM, because the user has no intention to select the content of the context
+   * control buttons.
+   * TODO: That argument probably also applies to this component, so maybe
+   * reconsider making this a shadow DOM component!
    */
   override createRenderRoot() {
     return this;
   }
 
   private renderPaddingRow(whereClass: 'above' | 'below') {
-    if (!this.showAbove && whereClass === 'above') return;
-    if (!this.showBelow && whereClass === 'below') return;
+    if (!showAbove(this.group, this.lineCountLeft) && whereClass === 'above')
+      return;
+    if (!showBelow(this.group, this.lineCountLeft) && whereClass === 'below')
+      return;
     const modeClass = this.isSideBySide() ? 'side-by-side' : 'unified';
     const type = this.isSideBySide()
       ? GrDiffGroupType.CONTEXT_CONTROL
       : undefined;
     return html`
       <tr
-        class=${diffClasses('contextBackground', modeClass, whereClass)}
+        class=${['contextBackground', modeClass, whereClass].join(' ')}
         left-type=${ifDefined(type)}
         right-type=${ifDefined(type)}
       >
         ${when(
           this.columns.blame,
-          () =>
-            html`<td class=${diffClasses('blame')} data-line-number="0"></td>`
+          () => html`<td class="blame" data-line-number="0"></td>`
         )}
         ${when(
           this.columns.leftNumber,
-          () => html`<td class=${diffClasses('contextLineNum')}></td>`
+          () => html`<td class="contextLineNum"></td>`
         )}
-        ${when(
-          this.columns.leftSign,
-          () => html`<td class=${diffClasses('sign')}></td>`
-        )}
-        ${when(
-          this.columns.leftContent,
-          () => html`<td class=${diffClasses()}></td>`
-        )}
+        ${when(this.columns.leftSign, () => html`<td class="sign"></td>`)}
+        ${when(this.columns.leftContent, () => html`<td></td>`)}
         ${when(
           this.columns.rightNumber,
-          () => html`<td class=${diffClasses('contextLineNum')}></td>`
+          () => html`<td class="contextLineNum"></td>`
         )}
-        ${when(
-          this.columns.rightSign,
-          () => html`<td class=${diffClasses('sign')}></td>`
-        )}
-        ${when(
-          this.columns.rightContent,
-          () => html`<td class=${diffClasses()}></td>`
-        )}
+        ${when(this.columns.rightSign, () => html`<td class="sign"></td>`)}
+        ${when(this.columns.rightContent, () => html`<td></td>`)}
       </tr>
     `;
   }
@@ -137,29 +132,22 @@
     // Span all columns, but not the blame column.
     let colspan = this.columnCount;
     if (this.columns.blame) colspan--;
-    const showConfig = getShowConfig(this.showAbove, this.showBelow);
+    const showConfig = getShowConfig(this.group, this.lineCountLeft);
     return html`
-      <tr class=${diffClasses('dividerRow', `show-${showConfig}`)}>
+      <tr class=${['dividerRow', `show-${showConfig}`].join(' ')}>
         ${when(
           this.columns.blame,
-          () =>
-            html`<td class=${diffClasses('blame')} data-line-number="0"></td>`
+          () => html`<td class="blame" data-line-number="0"></td>`
         )}
-        <td class=${diffClasses('dividerCell')} colspan=${colspan}>
-          <gr-context-controls
-            class=${diffClasses()}
-            .diff=${this.diff}
-            .renderPreferences=${this.renderPrefs}
-            .group=${this.group}
-            .showConfig=${showConfig}
-          >
-          </gr-context-controls>
+        <td class="dividerCell" colspan=${colspan}>
+          <gr-context-controls .group=${this.group}> </gr-context-controls>
         </td>
       </tr>
     `;
   }
 
   override render() {
+    if (this.group?.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
     const rows = html`
       ${this.renderPaddingRow('above')} ${this.createContextControlRow()}
       ${this.renderPaddingRow('below')}
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
index 93db66e..be7553e 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
@@ -8,6 +8,10 @@
   wrapInProvider,
 } from '../../../models/di-provider-element';
 import '../../../test/common-test-setup';
+import {
+  createContextGroup,
+  createDiff,
+} from '../../../test/test-data-generators';
 import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
 import './gr-context-controls-section';
 import {GrContextControlsSection} from './gr-context-controls-section';
@@ -27,14 +31,19 @@
         )
       )
     ).querySelector<GrContextControlsSection>('gr-context-controls-section')!;
+    await element.updateComplete;
 
+    diffModel.updateState({diff: createDiff()});
     element.addTableWrapperForTesting = true;
     await element.updateComplete;
   });
 
-  test('render: normal with showAbove and showBelow', async () => {
-    element.showAbove = true;
-    element.showBelow = true;
+  test('render nothing, if group is not set', async () => {
+    assert.lightDom.equal(element, '');
+  });
+
+  test('render above and below', async () => {
+    element.group = createContextGroup({offset: 10, count: 10});
     await element.updateComplete;
     assert.lightDom.equal(
       element,
@@ -42,33 +51,87 @@
         <table>
           <tbody>
             <tr
-              class="above contextBackground gr-diff side-by-side"
+              class="above contextBackground side-by-side"
               left-type="contextControl"
               right-type="contextControl"
             >
-              <td class="blame gr-diff" data-line-number="0"></td>
-              <td class="contextLineNum gr-diff"></td>
-              <td class="gr-diff"></td>
-              <td class="contextLineNum gr-diff"></td>
-              <td class="gr-diff"></td>
+              <td class="contextLineNum"></td>
+              <td></td>
+              <td class="contextLineNum"></td>
+              <td></td>
             </tr>
-            <tr class="dividerRow gr-diff show-both">
-              <td class="blame gr-diff" data-line-number="0"></td>
-              <td class="dividerCell gr-diff" colspan="4">
-                <gr-context-controls class="gr-diff" showconfig="both">
-                </gr-context-controls>
+            <tr class="dividerRow show-both">
+              <td class="dividerCell" colspan="4">
+                <gr-context-controls showconfig="both"> </gr-context-controls>
               </td>
             </tr>
             <tr
-              class="below contextBackground gr-diff side-by-side"
+              class="below contextBackground side-by-side"
               left-type="contextControl"
               right-type="contextControl"
             >
-              <td class="blame gr-diff" data-line-number="0"></td>
-              <td class="contextLineNum gr-diff"></td>
-              <td class="gr-diff"></td>
-              <td class="contextLineNum gr-diff"></td>
-              <td class="gr-diff"></td>
+              <td class="contextLineNum"></td>
+              <td></td>
+              <td class="contextLineNum"></td>
+              <td></td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('render above only', async () => {
+    element.group = createContextGroup({offset: 35, count: 10});
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              class="above contextBackground side-by-side"
+              left-type="contextControl"
+              right-type="contextControl"
+            >
+              <td class="contextLineNum"></td>
+              <td></td>
+              <td class="contextLineNum"></td>
+              <td></td>
+            </tr>
+            <tr class="dividerRow show-above">
+              <td class="dividerCell" colspan="4">
+                <gr-context-controls showconfig="above"> </gr-context-controls>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('render below only', async () => {
+    element.group = createContextGroup({offset: 0, count: 10});
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr class="dividerRow show-below">
+              <td class="dividerCell" colspan="4">
+                <gr-context-controls showconfig="below"> </gr-context-controls>
+              </td>
+            </tr>
+            <tr
+              class="below contextBackground side-by-side"
+              left-type="contextControl"
+              right-type="contextControl"
+            >
+              <td class="contextLineNum"></td>
+              <td></td>
+              <td class="contextLineNum"></td>
+              <td></td>
             </tr>
           </tbody>
         </table>
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index e889b90..e1fc2ed 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -14,24 +14,33 @@
 import '@polymer/paper-tooltip/paper-tooltip';
 import {of, EMPTY, Subject} from 'rxjs';
 import {switchMap, delay} from 'rxjs/operators';
-
 import '../../../elements/shared/gr-button/gr-button';
 import {pluralize} from '../../../utils/string-util';
 import {fire} from '../../../utils/event-util';
-import {DiffInfo} from '../../../types/diff';
 import {assertIsDefined} from '../../../utils/common-util';
-import {css, html, LitElement, TemplateResult} from 'lit';
-import {property} from 'lit/decorators.js';
+import {
+  css,
+  html,
+  LitElement,
+  nothing,
+  PropertyValues,
+  TemplateResult,
+} from 'lit';
+import {property, state} from 'lit/decorators.js';
 import {subscribe} from '../../../elements/lit/subscription-controller';
-
 import {
   ContextButtonType,
   DiffContextButtonHoveredDetail,
   RenderPreferences,
   SyntaxBlock,
 } from '../../../api/diff';
-
-import {GrDiffGroup, hideInContextControl} from '../gr-diff/gr-diff-group';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from '../gr-diff/gr-diff-group';
+import {resolve} from '../../../models/dependency';
+import {diffModelToken} from '../gr-diff-model/gr-diff-model';
 
 declare global {
   interface HTMLElementEventMap {
@@ -67,14 +76,25 @@
   return [containingBlock].concat(innerPathInChild);
 }
 
+/**
+ * 'above': Typically only for the context controls at the end of a file. So
+ *          only show buttons "above" the middle line of the context control
+ *          section.
+ * 'below': Typically only for the context controls at the beginning of a file.
+ *          So only show buttons "below" the middle line of the context control
+ *          section.
+ * 'both': Typically for the context controls in the middle of a file. So show
+ *         two buttons, one for expanding from the top and one for expanding
+ *         from the bottom.
+ */
 export type GrContextControlsShowConfig = 'above' | 'below' | 'both';
 
-export function getShowConfig(
-  showAbove: boolean,
-  showBelow: boolean
-): GrContextControlsShowConfig {
-  if (showAbove && !showBelow) return 'above';
-  if (!showAbove && showBelow) return 'below';
+export function getShowConfig(group?: GrDiffGroup, lineCountLeft = 0) {
+  const above = showAbove(group, lineCountLeft);
+  const below = showBelow(group, lineCountLeft);
+
+  if (above && !below) return 'above';
+  if (!above && below) return 'below';
 
   // Note that !showAbove && !showBelow also intentionally returns 'both'.
   // This means the file is completely collapsed, which is unusual, but at least
@@ -82,16 +102,55 @@
   return 'both';
 }
 
+/** See GrContextControlsShowConfig for explanation of "above". */
+export function showAbove(group?: GrDiffGroup, lineCountLeft = 0) {
+  if (group?.type !== GrDiffGroupType.CONTEXT_CONTROL) return false;
+
+  // Note that we could as well use `right.start_line` here. And below we only
+  // use `left`, because we are comparing with `lineCountLeft`. But that is
+  // just an arbitrary choice.
+  const leftStart = group.lineRange.left.start_line;
+  const firstGroupIsSkipped = !!group.contextGroups[0].skip;
+  if (leftStart > 1 && !firstGroupIsSkipped) return true;
+
+  const leftEnd = group.lineRange.left.end_line;
+  const containsWholeFile = lineCountLeft === leftEnd - leftStart + 1;
+  return containsWholeFile;
+}
+
+/** See GrContextControlsShowConfig for explanation of "below". */
+export function showBelow(group?: GrDiffGroup, lineCountLeft = 0) {
+  if (group?.type !== GrDiffGroupType.CONTEXT_CONTROL) return false;
+
+  // Note that we could as well use `right.start_line` here. But we would then
+  // require a `lineCountRight` parameter for making the comparison.
+  const leftEnd = group.lineRange.left.end_line;
+  const lastGroupIsSkipped =
+    !!group.contextGroups[group.contextGroups.length - 1].skip;
+
+  return leftEnd < lineCountLeft && !lastGroupIsSkipped;
+}
+
+/**
+ * Renders context control buttons such as "+23 lines" or "+Block". It is only
+ * meant to be used to be rendered into a diff table cell of its parent
+ * component <gr-context-controls-section>.
+ */
 export class GrContextControls extends LitElement {
-  @property({type: Object}) renderPreferences?: RenderPreferences;
-
-  @property({type: Object}) diff?: DiffInfo;
-
   @property({type: Object}) group?: GrDiffGroup;
 
+  // This is just a property (and not a state), because we want to "reflect".
   @property({type: String, reflect: true})
   showConfig: GrContextControlsShowConfig = 'both';
 
+  @state() syntaxTreeRight?: SyntaxBlock[];
+
+  @state() renderPreferences?: RenderPreferences;
+
+  @state() lineCountLeft = 0;
+
+  private readonly getDiffModel = resolve(this, diffModelToken);
+
   private expandButtonsHover = new Subject<{
     eventType: 'enter' | 'leave';
     buttonType: ContextButtonType;
@@ -219,6 +278,32 @@
   constructor() {
     super();
     this.setupButtonHoverHandler();
+    subscribe(
+      this,
+      () => this.getDiffModel().syntaxTreeRight$,
+      syntaxTree => (this.syntaxTreeRight = syntaxTree)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().renderPrefs$,
+      renderPrefs => (this.renderPreferences = renderPrefs)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().lineCountLeft$,
+      lineCountLeft => {
+        this.lineCountLeft = lineCountLeft;
+        this.updateShowConfig();
+      }
+    );
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('group')) this.updateShowConfig();
+  }
+
+  private updateShowConfig() {
+    this.showConfig = getShowConfig(this.group, this.lineCountLeft);
   }
 
   private showBoth() {
@@ -265,7 +350,7 @@
   }
 
   private createExpandAllButtonContainer() {
-    return html` <div class="gr-diff aboveBelowButtons fullExpansion">
+    return html` <div class="aboveBelowButtons fullExpansion">
       ${this.createContextButton(ContextButtonType.ALL, this.numLines())}
     </div>`;
   }
@@ -439,14 +524,12 @@
     if (this.showAbove()) {
       aboveBlockButton = this.createBlockButton(
         ContextButtonType.BLOCK_ABOVE,
-        this.numLines(),
         this.group.lineRange.right.start_line - 1
       );
     }
     if (this.showBelow()) {
       belowBlockButton = this.createBlockButton(
         ContextButtonType.BLOCK_BELOW,
-        this.numLines(),
         this.group.lineRange.right.end_line + 1
       );
     }
@@ -476,26 +559,28 @@
     >`;
   }
 
+  /**
+   * Creates a "expand until end of block" button. This is based on syntax tree
+   * information for the *right* side of the diff.
+   */
   private createBlockButton(
     buttonType: ContextButtonType,
-    numLines: number,
-    referenceLine: number
+    referenceLineRight: number
   ) {
-    if (!this.diff?.meta_b) return;
-    const syntaxTree = this.diff.meta_b.syntax_tree;
+    if (this.syntaxTreeRight === undefined) return;
     const outlineSyntaxPath = findBlockTreePathForLine(
-      referenceLine,
-      syntaxTree
+      referenceLineRight,
+      this.syntaxTreeRight
     );
-    let linesToExpand = numLines;
+    let linesToExpand = this.numLines();
     if (outlineSyntaxPath.length) {
       const {range} = outlineSyntaxPath[outlineSyntaxPath.length - 1];
       const targetLine =
         buttonType === ContextButtonType.BLOCK_ABOVE
           ? range.end_line
           : range.start_line;
-      const distanceToTargetLine = Math.abs(targetLine - referenceLine);
-      if (distanceToTargetLine < numLines) {
+      const distanceToTargetLine = Math.abs(targetLine - referenceLineRight);
+      if (distanceToTargetLine < this.numLines()) {
         linesToExpand = distanceToTargetLine;
       }
     }
@@ -507,15 +592,8 @@
     return this.createContextButton(buttonType, linesToExpand, tooltip);
   }
 
-  private hasValidProperties() {
-    return !!(this.diff && this.group?.contextGroups?.length);
-  }
-
   override render() {
-    if (!this.hasValidProperties()) {
-      console.error('Invalid properties for gr-context-controls!');
-      return html`<p>invalid properties</p>`;
-    }
+    if (!this.group) return nothing;
     return html`
       <div class="horizontalFlex">
         ${this.createExpandAllButtonContainer()}
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
index 215dc88..20fc9c4 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
@@ -7,47 +7,23 @@
 import '../gr-diff/gr-diff-group';
 import './gr-context-controls';
 import {GrContextControls} from './gr-context-controls';
-
-import {GrDiffLine} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {
-  DiffFileMetaInfo,
-  DiffInfo,
-  GrDiffLineType,
-  SyntaxBlock,
-} from '../../../api/diff';
+import {SyntaxBlock} from '../../../api/diff';
 import {fixture, html, assert} from '@open-wc/testing';
 import {waitEventLoop} from '../../../test/test-utils';
+import {createContextGroup} from '../../../test/test-data-generators';
 
 suite('gr-context-control tests', () => {
   let element: GrContextControls;
 
   setup(async () => {
     element = document.createElement('gr-context-controls');
-    element.diff = {content: []} as any as DiffInfo;
+    element.lineCountLeft = 50;
     element.renderPreferences = {};
     const div = await fixture(html`<div></div>`);
     div.appendChild(element);
     await waitEventLoop();
   });
 
-  function createContextGroup(options: {offset?: number; count?: number}) {
-    const offset = options.offset || 0;
-    const numLines = options.count || 10;
-    const lines = [];
-    for (let i = 0; i < numLines; i++) {
-      const line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.beforeNumber = offset + i + 1;
-      line.afterNumber = offset + i + 1;
-      line.text = 'lorem upsum';
-      lines.push(line);
-    }
-    return new GrDiffGroup({
-      type: GrDiffGroupType.CONTEXT_CONTROL,
-      contextGroups: [new GrDiffGroup({type: GrDiffGroupType.BOTH, lines})],
-    });
-  }
-
   test('no +10 buttons for 10 or less lines', async () => {
     element.group = createContextGroup({count: 10});
 
@@ -62,7 +38,6 @@
 
   test('context control at the top', async () => {
     element.group = createContextGroup({offset: 0, count: 20});
-    element.showConfig = 'below';
 
     await waitEventLoop();
 
@@ -80,7 +55,6 @@
 
   test('context control in the middle', async () => {
     element.group = createContextGroup({offset: 10, count: 20});
-    element.showConfig = 'both';
 
     await waitEventLoop();
 
@@ -100,7 +74,6 @@
 
   test('context control at the bottom', async () => {
     element.group = createContextGroup({offset: 30, count: 20});
-    element.showConfig = 'above';
 
     await waitEventLoop();
 
@@ -118,15 +91,12 @@
 
   function prepareForBlockExpansion(syntaxTree: SyntaxBlock[]) {
     element.renderPreferences!.use_block_expansion = true;
-    element.diff!.meta_b = {
-      syntax_tree: syntaxTree,
-    } as any as DiffFileMetaInfo;
+    element.syntaxTreeRight = syntaxTree;
   }
 
   test('context control with block expansion at the top', async () => {
     prepareForBlockExpansion([]);
     element.group = createContextGroup({offset: 0, count: 20});
-    element.showConfig = 'below';
 
     await waitEventLoop();
 
@@ -155,7 +125,6 @@
   test('context control with block expansion in the middle', async () => {
     prepareForBlockExpansion([]);
     element.group = createContextGroup({offset: 10, count: 20});
-    element.showConfig = 'both';
 
     await waitEventLoop();
 
@@ -192,7 +161,6 @@
   test('context control with block expansion at the bottom', async () => {
     prepareForBlockExpansion([]);
     element.group = createContextGroup({offset: 30, count: 20});
-    element.showConfig = 'above';
 
     await waitEventLoop();
 
@@ -218,7 +186,7 @@
     );
   });
 
-  test('+ Block tooltip tooltip shows syntax block containing the target lines above and below', async () => {
+  test('+Block tooltip tooltip shows syntax block containing the target lines above and below', async () => {
     prepareForBlockExpansion([
       {
         name: 'aSpecificFunction',
@@ -232,7 +200,6 @@
       },
     ]);
     element.group = createContextGroup({offset: 10, count: 20});
-    element.showConfig = 'both';
 
     await waitEventLoop();
 
@@ -284,7 +251,6 @@
       },
     ]);
     element.group = createContextGroup({offset: 10, count: 20});
-    element.showConfig = 'both';
 
     await waitEventLoop();
 
@@ -330,7 +296,6 @@
       },
     ]);
     element.group = createContextGroup({offset: 10, count: 20});
-    element.showConfig = 'both';
     await waitEventLoop();
 
     const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
@@ -348,7 +313,6 @@
     prepareForBlockExpansion([]);
 
     element.group = createContextGroup({offset: 10, count: 20});
-    element.showConfig = 'both';
     await waitEventLoop();
 
     const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
index a0406be..642610a 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -23,7 +23,7 @@
 import {otherSide} from '../../../utils/diff-util';
 import './gr-diff-text';
 import {
-  diffClasses,
+  findBlame,
   GrDiffCommentThread,
   isLongCommentRange,
   isResponsive,
@@ -71,9 +71,6 @@
   private right$ = new BehaviorSubject<GrDiffLine | undefined>(undefined);
 
   @property({type: Object})
-  blameInfo?: BlameInfo;
-
-  @property({type: Object})
   responsiveMode?: DiffResponsiveMode;
 
   @property({type: Boolean})
@@ -104,6 +101,8 @@
 
   @state() columns: ColumnsToShow = NO_COLUMNS;
 
+  @state() blameInfo?: BlameInfo;
+
   /**
    * Keeps track of whether diff layers have already been applied to the diff
    * row. That happens after the DOM has been created in the `updated()`
@@ -154,6 +153,14 @@
       () => this.getDiffModel().columnsToShow$,
       columnsToShow => (this.columns = columnsToShow)
     );
+    subscribe(
+      this,
+      () => this.getDiffModel().blameInfo$,
+      blameInfos => {
+        const line = this.left?.lineNumber(Side.LEFT);
+        this.blameInfo = findBlame(blameInfos, line);
+      }
+    );
   }
 
   override willUpdate(changedProperties: PropertyValues) {
@@ -220,7 +227,7 @@
     const row = html`
       <tr
         ${ref(this.tableRowRef)}
-        class=${diffClasses('diff-row', ...classes)}
+        class=${['diff-row', ...classes].join(' ')}
         left-type=${ifDefined(this.getType(Side.LEFT))}
         right-type=${ifDefined(this.getType(Side.RIGHT))}
         tabindex="-1"
@@ -292,7 +299,7 @@
     return html`
       <td
         ${ref(this.blameCellRef)}
-        class=${diffClasses('blame')}
+        class="blame"
         data-line-number=${this.left?.beforeNumber ?? 0}
       >${this.renderBlameElement()}</td>
     `;
@@ -312,11 +319,11 @@
 
     // td.blame has `white-space: pre`, so prettier must not add spaces.
     // prettier-ignore
-    return html`<span class=${diffClasses(...extras)}
-        ><a href=${url} class=${diffClasses('blameDate')}>${date}</a
-        ><span class=${diffClasses('blameAuthor')}> ${shortName}</span
-        ><gr-hovercard class=${diffClasses()}>
-          <span class=${diffClasses('blameHoverCard')}>
+    return html`<span class=${extras.join(' ')}
+        ><a href=${url} class="blameDate">${date}</a
+        ><span class="blameAuthor"> ${shortName}</span
+        ><gr-hovercard>
+          <span class="blameHoverCard">
             Commit ${commit.id}<br />
             Author: ${commit.author}<br />
             Date: ${date}<br />
@@ -337,13 +344,13 @@
       const blankClass = isBlank ? 'blankLineNum' : '';
       return html`<td
         ${ref(this.lineNumberRef(side))}
-        class=${diffClasses(side, blankClass)}
+        class=${[side, blankClass].join(' ')}
       ></td>`;
     }
 
     return html`<td
       ${ref(this.lineNumberRef(side))}
-      class=${diffClasses(side, 'lineNum')}
+      class=${[side, 'lineNum'].join(' ')}
       data-value=${lineNumber}
     >
       ${this.renderLineNumberButton(line, lineNumber, side)}
@@ -362,7 +369,7 @@
     return html`
       <button
         id=${this.lineNumberId(side)}
-        class=${diffClasses('lineNumButton', side)}
+        class=${['lineNumButton', side].join(' ')}
         tabindex="-1"
         data-value=${lineNumber}
         aria-label=${ifDefined(
@@ -425,7 +432,7 @@
     return html`
       <td
         ${ref(this.contentCellRef(side))}
-        class=${diffClasses(...extras)}
+        class=${extras.join(' ')}
         @click=${() => {
           if (lineNumber) {
             this.getDiffModel().selectLine(lineNumber, side);
@@ -459,7 +466,7 @@
     if (!line.hasIntralineInfo) extras.push('no-intraline-info');
 
     const sign = isAdd ? '+' : isRemove ? '-' : '';
-    return html`<td class=${diffClasses(...extras)}>${sign}</td>`;
+    return html`<td class=${extras.join(' ')}>${sign}</td>`;
   }
 
   private renderLostMessage(side: Side) {
@@ -578,7 +585,7 @@
     // .content has `white-space: pre`, so prettier must not add spaces.
     // prettier-ignore
     return html`<div
-        class=${diffClasses('contentText')}
+        class="contentText"
         data-side=${ifDefined(side)}
         id=${this.contentId(side)}
       >${textElement}</div>`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
index 95b0357..1526ce3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
@@ -34,16 +34,15 @@
           <tbody>
             <tr
               aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
-              class="diff-row gr-diff side-by-side"
+              class="diff-row side-by-side"
               left-type="both"
               right-type="both"
               tabindex="-1"
             >
-              <td class="blame gr-diff" data-line-number="1"></td>
-              <td class="gr-diff left lineNum" data-value="1">
+              <td class="left lineNum" data-value="1">
                 <button
                   aria-label="1 unmodified"
-                  class="gr-diff left lineNumButton"
+                  class="left lineNumButton"
                   data-value="1"
                   id="left-button-1"
                   tabindex="-1"
@@ -51,9 +50,9 @@
                   1
                 </button>
               </td>
-              <td class="both content gr-diff left no-intraline-info">
+              <td class="both content left no-intraline-info">
                 <div
-                  class="contentText gr-diff"
+                  class="contentText"
                   data-side="left"
                   id="left-content-1"
                 >
@@ -61,10 +60,10 @@
                 </div>
                 </div>
               </td>
-              <td class="gr-diff lineNum right" data-value="1">
+              <td class="lineNum right" data-value="1">
                 <button
                   aria-label="1 unmodified"
-                  class="gr-diff lineNumButton right"
+                  class="lineNumButton right"
                   data-value="1"
                   id="right-button-1"
                   tabindex="-1"
@@ -72,9 +71,9 @@
                   1
                 </button>
               </td>
-              <td class="both content gr-diff no-intraline-info right">
+              <td class="both content no-intraline-info right">
                 <div
-                  class="contentText gr-diff"
+                  class="contentText"
                   data-side="right"
                   id="right-content-1"
                 >
@@ -106,14 +105,13 @@
           <tbody>
             <tr
               aria-labelledby="left-button-1 right-button-1 right-content-1"
-              class="both diff-row gr-diff unified"
+              class="both diff-row unified"
               tabindex="-1"
             >
-              <td class="blame gr-diff" data-line-number="1"></td>
-              <td class="gr-diff left lineNum" data-value="1">
+              <td class="left lineNum" data-value="1">
                 <button
                   aria-label="1 unmodified"
-                  class="gr-diff left lineNumButton"
+                  class="left lineNumButton"
                   data-value="1"
                   id="left-button-1"
                   tabindex="-1"
@@ -121,10 +119,10 @@
                   1
                 </button>
               </td>
-              <td class="gr-diff lineNum right" data-value="1">
+              <td class="lineNum right" data-value="1">
                 <button
                   aria-label="1 unmodified"
-                  class="gr-diff lineNumButton right"
+                  class="lineNumButton right"
                   data-value="1"
                   id="right-button-1"
                   tabindex="-1"
@@ -132,12 +130,8 @@
                   1
                 </button>
               </td>
-              <td class="both content gr-diff no-intraline-info right">
-                <div
-                  class="contentText gr-diff"
-                  data-side="right"
-                  id="right-content-1"
-                >
+              <td class="both content no-intraline-info right">
+                <div class="contentText" data-side="right" id="right-content-1">
                   <gr-diff-text data-side="right"> lorem ipsum </gr-diff-text>
                 </div>
               </td>
@@ -163,20 +157,19 @@
           <tbody>
             <tr
               aria-labelledby="right-button-1 right-content-1"
-              class="diff-row gr-diff side-by-side"
+              class="diff-row side-by-side"
               left-type="blank"
               right-type="add"
               tabindex="-1"
             >
-              <td class="blame gr-diff" data-line-number="0"></td>
-              <td class="blankLineNum gr-diff left"></td>
-              <td class="blank gr-diff left no-intraline-info">
-                <div class="contentText gr-diff" data-side="left"></div>
+              <td class="blankLineNum left"></td>
+              <td class="blank left no-intraline-info">
+                <div class="contentText" data-side="left"></div>
               </td>
-              <td class="gr-diff lineNum right" data-value="1">
+              <td class="lineNum right" data-value="1">
                 <button
                   aria-label="1 added"
-                  class="gr-diff lineNumButton right"
+                  class="lineNumButton right"
                   data-value="1"
                   id="right-button-1"
                   tabindex="-1"
@@ -184,12 +177,8 @@
                   1
                 </button>
               </td>
-              <td class="add content gr-diff no-intraline-info right">
-                <div
-                  class="contentText gr-diff"
-                  data-side="right"
-                  id="right-content-1"
-                >
+              <td class="add content no-intraline-info right">
+                <div class="contentText" data-side="right" id="right-content-1">
                   <gr-diff-text data-side="right"> lorem ipsum </gr-diff-text>
                 </div>
               </td>
@@ -214,16 +203,15 @@
           <tbody>
             <tr
               aria-labelledby="left-button-1 left-content-1"
-              class="diff-row gr-diff side-by-side"
+              class="diff-row side-by-side"
               left-type="remove"
               right-type="blank"
               tabindex="-1"
             >
-              <td class="blame gr-diff" data-line-number="1"></td>
-              <td class="gr-diff left lineNum" data-value="1">
+              <td class="left lineNum" data-value="1">
                 <button
                   aria-label="1 removed"
-                  class="gr-diff left lineNumButton"
+                  class="left lineNumButton"
                   data-value="1"
                   id="left-button-1"
                   tabindex="-1"
@@ -231,18 +219,14 @@
                   1
                 </button>
               </td>
-              <td class="content gr-diff left no-intraline-info remove">
-                <div
-                  class="contentText gr-diff"
-                  data-side="left"
-                  id="left-content-1"
-                >
+              <td class="content left no-intraline-info remove">
+                <div class="contentText" data-side="left" id="left-content-1">
                   <gr-diff-text data-side="left"> lorem ipsum </gr-diff-text>
                 </div>
               </td>
-              <td class="blankLineNum gr-diff right"></td>
-              <td class="blank gr-diff no-intraline-info right">
-                <div class="contentText gr-diff" data-side="right"></div>
+              <td class="blankLineNum right"></td>
+              <td class="blank no-intraline-info right">
+                <div class="contentText" data-side="right"></div>
               </td>
             </tr>
             <slot name="post-left-line-1"></slot>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index d249801..a189e05 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -15,7 +15,7 @@
   DiffPreferencesInfo,
 } from '../../../api/diff';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {diffClasses, getResponsiveMode} from '../gr-diff/gr-diff-utils';
+import {getResponsiveMode} from '../gr-diff/gr-diff-utils';
 import {GrDiffRow} from './gr-diff-row';
 import '../gr-context-controls/gr-context-controls-section';
 import '../gr-context-controls/gr-context-controls';
@@ -23,7 +23,6 @@
 import './gr-diff-row';
 import {when} from 'lit/directives/when.js';
 import {fire} from '../../../utils/event-util';
-import {countLines} from '../../../utils/diff-util';
 import {resolve} from '../../../models/dependency';
 import {
   ColumnsToShow,
@@ -142,7 +141,7 @@
       this.diffPrefs?.show_file_comment_button === false ||
       this.renderPrefs?.show_file_comment_button === false;
     const body = html`
-      <tbody class=${diffClasses(...extras)}>
+      <tbody class=${extras.join(' ')}>
         ${this.renderContextControls()} ${this.renderMoveControls()}
         ${pairs.map(pair => {
           const leftClass = `left-${pair.left.lineNumber(Side.LEFT)}`;
@@ -191,26 +190,8 @@
 
   private renderContextControls() {
     if (this.group?.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
-
-    const leftStart = this.group.lineRange.left.start_line;
-    const leftEnd = this.group.lineRange.left.end_line;
-    const firstGroupIsSkipped = !!this.group.contextGroups[0].skip;
-    const lastGroupIsSkipped =
-      !!this.group.contextGroups[this.group.contextGroups.length - 1].skip;
-    const lineCountLeft = countLines(this.diff, Side.LEFT);
-    const containsWholeFile = lineCountLeft === leftEnd - leftStart + 1;
-    const showAbove =
-      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
-    const showBelow = leftEnd < lineCountLeft && !lastGroupIsSkipped;
-
     return html`
-      <gr-context-controls-section
-        .showAbove=${showAbove}
-        .showBelow=${showBelow}
-        .group=${this.group}
-        .diff=${this.diff}
-        .renderPrefs=${this.renderPrefs}
-      >
+      <gr-context-controls-section .group=${this.group}>
       </gr-context-controls-section>
     `;
   }
@@ -225,41 +206,30 @@
   private renderMoveControls() {
     if (!this.group?.moveDetails) return;
     const movedIn = this.group.adds.length > 0;
-    const plainCell = html`<td class=${diffClasses()}></td>`;
+    const plainCell = html`<td></td>`;
     const moveCell = html`
-      <td class=${diffClasses('moveHeader')}>
-        <gr-range-header class=${diffClasses()} icon="move_item">
+      <td class="moveHeader">
+        <gr-range-header icon="move_item">
           ${this.renderMoveDescription(movedIn)}
         </gr-range-header>
       </td>
     `;
     return html`
-      <tr
-        class=${diffClasses('moveControls', movedIn ? 'movedIn' : 'movedOut')}
-      >
-        ${when(
-          this.columns.blame,
-          () => html`<td class=${diffClasses('blame')}></td>`
-        )}
+      <tr class=${['moveControls', movedIn ? 'movedIn' : 'movedOut'].join(' ')}>
+        ${when(this.columns.blame, () => html`<td class="blame"></td>`)}
         ${when(
           this.columns.leftNumber,
-          () => html`<td class=${diffClasses('moveControlsLineNumCol')}></td>`
+          () => html`<td class="moveControlsLineNumCol"></td>`
         )}
-        ${when(
-          this.columns.leftSign,
-          () => html`<td class=${diffClasses('sign')}></td>`
-        )}
+        ${when(this.columns.leftSign, () => html`<td class="sign"></td>`)}
         ${when(this.columns.leftContent, () =>
           movedIn ? plainCell : moveCell
         )}
         ${when(
           this.columns.rightNumber,
-          () => html`<td class=${diffClasses('moveControlsLineNumCol')}></td>`
+          () => html`<td class="moveControlsLineNumCol"></td>`
         )}
-        ${when(
-          this.columns.rightSign,
-          () => html`<td class=${diffClasses('sign')}></td>`
-        )}
+        ${when(this.columns.rightSign, () => html`<td class="sign"></td>`)}
         ${when(this.columns.rightContent, () =>
           movedIn || this.isUnifiedDiff() ? moveCell : plainCell
         )}
@@ -275,20 +245,18 @@
       const direction = movedIn ? 'from' : 'to';
       const textLabel = `Moved ${andChangedLabel}${direction} lines `;
       return html`
-        <div class=${diffClasses()}>
-          <span class=${diffClasses()}>${textLabel}</span>
+        <div>
+          <span>${textLabel}</span>
           ${this.renderMovedLineAnchor(range.start, otherSide)}
-          <span class=${diffClasses()}> - </span>
+          <span> - </span>
           ${this.renderMovedLineAnchor(range.end, otherSide)}
         </div>
       `;
     }
 
     return html`
-      <div class=${diffClasses()}>
-        <span class=${diffClasses()}
-          >${movedIn ? 'Moved in' : 'Moved out'}</span
-        >
+      <div>
+        <span>${movedIn ? 'Moved in' : 'Moved out'}</span>
       </div>
     `;
   }
@@ -299,11 +267,7 @@
       this.handleMovedLineAnchorClick(e.target, side, line);
     };
     // `href` is not actually used but important for Screen Readers
-    return html`
-      <a class=${diffClasses()} href=${`#${line}`} @click=${listener}
-        >${line}</a
-      >
-    `;
+    return html`<a href=${`#${line}`} @click=${listener}>${line}</a>`;
   }
 
   private handleMovedLineAnchorClick(
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
index e85e945..d5e471b 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
@@ -50,21 +50,20 @@
         /* HTML */ `
           <table>
             <tbody>
-              <tr class="gr-diff moveControls movedOut">
-                <td class="blame gr-diff"></td>
-                <td class="gr-diff moveControlsLineNumCol"></td>
-                <td class="gr-diff moveHeader">
-                  <gr-range-header class="gr-diff" icon="move_item">
-                    <div class="gr-diff">
-                      <span class="gr-diff"> Moved to lines </span>
-                      <a class="gr-diff" href="#1"> 1 </a>
-                      <span class="gr-diff"> - </span>
-                      <a class="gr-diff" href="#2"> 2 </a>
+              <tr class="moveControls movedOut">
+                <td class="moveControlsLineNumCol"></td>
+                <td class="moveHeader">
+                  <gr-range-header icon="move_item">
+                    <div>
+                      <span> Moved to lines </span>
+                      <a href="#1"> 1 </a>
+                      <span> - </span>
+                      <a href="#2"> 2 </a>
                     </div>
                   </gr-range-header>
                 </td>
-                <td class="gr-diff moveControlsLineNumCol"></td>
-                <td class="gr-diff"></td>
+                <td class="moveControlsLineNumCol"></td>
+                <td></td>
               </tr>
             </tbody>
           </table>
@@ -87,17 +86,16 @@
         /* HTML */ `
           <table>
             <tbody>
-              <tr class="gr-diff moveControls movedOut">
-                <td class="blame gr-diff"></td>
-                <td class="gr-diff moveControlsLineNumCol"></td>
-                <td class="gr-diff moveControlsLineNumCol"></td>
-                <td class="gr-diff moveHeader">
-                  <gr-range-header class="gr-diff" icon="move_item">
-                    <div class="gr-diff">
-                      <span class="gr-diff"> Moved to lines </span>
-                      <a class="gr-diff" href="#1"> 1 </a>
-                      <span class="gr-diff"> - </span>
-                      <a class="gr-diff" href="#2"> 2 </a>
+              <tr class="moveControls movedOut">
+                <td class="moveControlsLineNumCol"></td>
+                <td class="moveControlsLineNumCol"></td>
+                <td class="moveHeader">
+                  <gr-range-header icon="move_item">
+                    <div>
+                      <span> Moved to lines </span>
+                      <a href="#1"> 1 </a>
+                      <span> - </span>
+                      <a href="#2"> 2 </a>
                     </div>
                   </gr-range-header>
                 </td>
@@ -135,19 +133,18 @@
         <slot name="post-left-line-1"></slot>
         <slot name="post-right-line-1"></slot>
         <table>
-          <tbody class="both gr-diff section">
+          <tbody class="both section">
             <tr
               aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
-              class="diff-row gr-diff side-by-side"
+              class="diff-row side-by-side"
               left-type="both"
               right-type="both"
               tabindex="-1"
             >
-              <td class="blame gr-diff" data-line-number="1"></td>
-              <td class="gr-diff left lineNum" data-value="1">
+              <td class="left lineNum" data-value="1">
                 <button
                   aria-label="1 unmodified"
-                  class="gr-diff left lineNumButton"
+                  class="left lineNumButton"
                   data-value="1"
                   id="left-button-1"
                   tabindex="-1"
@@ -155,19 +152,15 @@
                   1
                 </button>
               </td>
-              <td class="both content gr-diff left no-intraline-info">
-                <div
-                  class="contentText gr-diff"
-                  data-side="left"
-                  id="left-content-1"
-                >
+              <td class="both content left no-intraline-info">
+                <div class="contentText" data-side="left" id="left-content-1">
                   <gr-diff-text data-side="left">asdf</gr-diff-text>
                 </div>
               </td>
-              <td class="gr-diff lineNum right" data-value="1">
+              <td class="lineNum right" data-value="1">
                 <button
                   aria-label="1 unmodified"
-                  class="gr-diff lineNumButton right"
+                  class="lineNumButton right"
                   data-value="1"
                   id="right-button-1"
                   tabindex="-1"
@@ -175,28 +168,23 @@
                   1
                 </button>
               </td>
-              <td class="both content gr-diff no-intraline-info right">
-                <div
-                  class="contentText gr-diff"
-                  data-side="right"
-                  id="right-content-1"
-                >
+              <td class="both content no-intraline-info right">
+                <div class="contentText" data-side="right" id="right-content-1">
                   <gr-diff-text data-side="right">asdf </gr-diff-text>
                 </div>
               </td>
             </tr>
             <tr
               aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
-              class="diff-row gr-diff side-by-side"
+              class="diff-row side-by-side"
               left-type="both"
               right-type="both"
               tabindex="-1"
             >
-              <td class="blame gr-diff" data-line-number="1"></td>
-              <td class="gr-diff left lineNum" data-value="1">
+              <td class="left lineNum" data-value="1">
                 <button
                   aria-label="1 unmodified"
-                  class="gr-diff left lineNumButton"
+                  class="left lineNumButton"
                   data-value="1"
                   id="left-button-1"
                   tabindex="-1"
@@ -204,19 +192,15 @@
                   1
                 </button>
               </td>
-              <td class="both content gr-diff left no-intraline-info">
-                <div
-                  class="contentText gr-diff"
-                  data-side="left"
-                  id="left-content-1"
-                >
+              <td class="both content left no-intraline-info">
+                <div class="contentText" data-side="left" id="left-content-1">
                   <gr-diff-text data-side="left"> qwer</gr-diff-text>
                 </div>
               </td>
-              <td class="gr-diff lineNum right" data-value="1">
+              <td class="lineNum right" data-value="1">
                 <button
                   aria-label="1 unmodified"
-                  class="gr-diff lineNumButton right"
+                  class="lineNumButton right"
                   data-value="1"
                   id="right-button-1"
                   tabindex="-1"
@@ -224,28 +208,23 @@
                   1
                 </button>
               </td>
-              <td class="both content gr-diff no-intraline-info right">
-                <div
-                  class="contentText gr-diff"
-                  data-side="right"
-                  id="right-content-1"
-                >
+              <td class="both content no-intraline-info right">
+                <div class="contentText" data-side="right" id="right-content-1">
                   <gr-diff-text data-side="right">qwer </gr-diff-text>
                 </div>
               </td>
             </tr>
             <tr
               aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
-              class="diff-row gr-diff side-by-side"
+              class="diff-row side-by-side"
               left-type="both"
               right-type="both"
               tabindex="-1"
             >
-              <td class="blame gr-diff" data-line-number="1"></td>
-              <td class="gr-diff left lineNum" data-value="1">
+              <td class="left lineNum" data-value="1">
                 <button
                   aria-label="1 unmodified"
-                  class="gr-diff left lineNumButton"
+                  class="left lineNumButton"
                   data-value="1"
                   id="left-button-1"
                   tabindex="-1"
@@ -253,19 +232,15 @@
                   1
                 </button>
               </td>
-              <td class="both content gr-diff left no-intraline-info">
-                <div
-                  class="contentText gr-diff"
-                  data-side="left"
-                  id="left-content-1"
-                >
+              <td class="both content left no-intraline-info">
+                <div class="contentText" data-side="left" id="left-content-1">
                   <gr-diff-text data-side="left">zxcv </gr-diff-text>
                 </div>
               </td>
-              <td class="gr-diff lineNum right" data-value="1">
+              <td class="lineNum right" data-value="1">
                 <button
                   aria-label="1 unmodified"
-                  class="gr-diff lineNumButton right"
+                  class="lineNumButton right"
                   data-value="1"
                   id="right-button-1"
                   tabindex="-1"
@@ -273,12 +248,8 @@
                   1
                 </button>
               </td>
-              <td class="both content gr-diff no-intraline-info right">
-                <div
-                  class="contentText gr-diff"
-                  data-side="right"
-                  id="right-content-1"
-                >
+              <td class="both content no-intraline-info right">
+                <div class="contentText" data-side="right" id="right-content-1">
                   <gr-diff-text data-side="right">zxcv </gr-diff-text>
                 </div>
               </td>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
index 5161b18..2156a5b 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
@@ -6,7 +6,6 @@
 import {LitElement, html, TemplateResult} from 'lit';
 import {property} from 'lit/decorators.js';
 import {styleMap} from 'lit/directives/style-map.js';
-import {diffClasses} from '../gr-diff/gr-diff-utils';
 
 const SURROGATE_PAIR = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
@@ -113,7 +112,7 @@
       tabSize = this.tabSize;
     }
     const piece = html`<span
-      class=${diffClasses('tab')}
+      class="tab"
       style=${styleMap({'tab-size': `${tabSize}`})}
       >${TAB}</span
     >`;
@@ -135,9 +134,9 @@
   /** Render a line break, don't advance text offset, reset col position. */
   private renderLineBreak() {
     if (this.isResponsive) {
-      this.pieces.push(html`<wbr class=${diffClasses()}></wbr>`);
+      this.pieces.push(html`<wbr></wbr>`);
     } else {
-      this.pieces.push(html`<span class=${diffClasses('br')}></span>`);
+      this.pieces.push(html`<span class="br"></span>`);
     }
     // this.textOffset += 0;
     this.columnPos = 0;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
index 3858bed..a458a61 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
@@ -8,9 +8,9 @@
 import {GrDiffText} from './gr-diff-text';
 import {fixture, html, assert} from '@open-wc/testing';
 
-const LINE_BREAK = '<span class="gr-diff br"></span>';
+const LINE_BREAK = '<span class="br"></span>';
 
-const LINE_BREAK_WBR = '<wbr class="gr-diff"></wbr>';
+const LINE_BREAK_WBR = '<wbr></wbr>';
 
 const TAB = '<span class="" style=""></span>';
 
@@ -86,21 +86,21 @@
       element.tabSize = 4;
       await check(
         '\t',
-        /* HTML */ '<span class="gr-diff tab" style="tab-size:4;"></span>'
+        /* HTML */ '<span class="tab" style="tab-size:4;"></span>'
       );
       await check(
         'abc\t',
-        /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:1;"></span>'
+        /* HTML */ 'abc<span class="tab" style="tab-size:1;"></span>'
       );
 
       element.tabSize = 8;
       await check(
         '\t',
-        /* HTML */ '<span class="gr-diff tab" style="tab-size:8;"></span>'
+        /* HTML */ '<span class="tab" style="tab-size:8;"></span>'
       );
       await check(
         'abc\t',
-        /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:5;"></span>'
+        /* HTML */ 'abc<span class="tab" style="tab-size:5;"></span>'
       );
     });
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
index 4a0778b..88451f6 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
@@ -3,8 +3,8 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Observable, combineLatest, from} from 'rxjs';
-import {debounceTime, filter, switchMap, withLatestFrom} from 'rxjs/operators';
+import {Observable, combineLatest} from 'rxjs';
+import {debounceTime, filter, map, withLatestFrom} from 'rxjs/operators';
 import {
   CreateCommentEventDetail,
   DiffInfo,
@@ -17,6 +17,7 @@
   LineSelectedEventDetail,
   RenderPreferences,
   Side,
+  SyntaxBlock,
 } from '../../../api/diff';
 import {define} from '../../../models/dependency';
 import {Model} from '../../../models/base/model';
@@ -37,8 +38,8 @@
 } from '../gr-diff-processor/gr-diff-processor';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {assert} from '../../../utils/common-util';
-import {isImageDiff} from '../../../utils/diff-util';
-import {ImageInfo} from '../../../types/common';
+import {countLines, isImageDiff} from '../../../utils/diff-util';
+import {BlameInfo, ImageInfo} from '../../../types/common';
 import {fire} from '../../../utils/event-util';
 import {CommentRange} from '../../../api/rest-api';
 
@@ -56,6 +57,7 @@
   showFullContext: FullContext;
   errorMessage?: string;
   layers: DiffLayer[];
+  blameInfo: BlameInfo[];
 }
 
 export interface ColumnsToShow {
@@ -86,6 +88,15 @@
     diffState => diffState.diff!
   );
 
+  readonly syntaxTreeRight$: Observable<SyntaxBlock[] | undefined> = select(
+    this.diff$,
+    diff => diff.meta_b?.syntax_tree
+  );
+
+  readonly lineCountLeft$: Observable<number> = select(this.diff$, diff =>
+    countLines(diff, Side.LEFT)
+  );
+
   readonly baseImage$: Observable<ImageInfo | undefined> = select(
     this.state$,
     diffState => diffState.baseImage
@@ -101,6 +112,11 @@
     diffState => diffState.path
   );
 
+  readonly blameInfo$: Observable<BlameInfo[]> = select(
+    this.state$,
+    diffState => diffState.blameInfo
+  );
+
   readonly renderPrefs$: Observable<RenderPreferences> = select(
     this.state$,
     diffState => diffState.renderPrefs
@@ -112,15 +128,14 @@
   );
 
   readonly columnsToShow$: Observable<ColumnsToShow> = select(
-    this.renderPrefs$,
-    renderPrefs => {
+    combineLatest([this.blameInfo$, this.renderPrefs$]),
+    ([blameInfo, renderPrefs]) => {
       const hideLeft = !!renderPrefs.hide_left_side;
       const showSign = !!renderPrefs.show_sign_col;
       const unified = renderPrefs.view_mode === DiffViewMode.UNIFIED;
 
       return {
-        // TODO: Do not always render the blame column. Move this into renderPrefs.
-        blame: true,
+        blame: blameInfo.length > 0,
         // Hiding the left side in unified diff mode does not make a lot of sense and is not supported.
         leftNumber: !hideLeft || unified,
         leftSign: !hideLeft && showSign && !unified,
@@ -211,6 +226,7 @@
       groups: [],
       showFullContext: FullContext.UNDECIDED,
       layers: [],
+      blameInfo: [],
     });
     this.subscriptions = [this.processDiff()];
   }
@@ -220,7 +236,7 @@
       .pipe(
         withLatestFrom(this.keyLocations$),
         debounceTime(1),
-        switchMap(([[diff, context, renderPrefs], keyLocations]) => {
+        map(([[diff, context, renderPrefs], keyLocations]) => {
           const options: ProcessingOptions = {
             context,
             keyLocations,
@@ -229,8 +245,9 @@
           if (renderPrefs?.num_lines_rendered_at_once) {
             options.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
           }
+
           const processor = new GrDiffProcessor(options);
-          return from(processor.process(diff.content));
+          return processor.process(diff.content);
         })
       )
       .subscribe(groups => {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 5db6db9..6b7b45f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -11,8 +11,6 @@
 } from '../gr-diff/gr-diff-group';
 import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {assert} from '../../../utils/common-util';
 import {getStringLength} from '../gr-diff-highlight/gr-annotation';
 import {GrDiffLineType, LineNumber} from '../../../api/diff';
 import {FULL_CONTEXT, KeyLocations} from '../gr-diff/gr-diff-utils';
@@ -31,19 +29,6 @@
   keyLocation: boolean;
 }
 
-/**
- * The maximum size for an addition or removal chunk before it is broken down
- * into a series of chunks that are this size at most.
- *
- * Note: The value of 120 is chosen so that it is larger than the default
- * asyncThreshold of 64, but feel free to tune this constant to your
- * performance needs.
- */
-function calcMaxGroupSize(asyncThreshold?: number): number {
-  if (!asyncThreshold) return 120;
-  return asyncThreshold * 2;
-}
-
 /** Interface for listening to the output of the processor. */
 export interface GroupConsumer {
   addGroup(group: GrDiffGroup): void;
@@ -90,114 +75,48 @@
   // visible for testing
   keyLocations: KeyLocations;
 
-  private asyncThreshold: number;
+  private isBinary = false;
 
-  private isBinary: boolean;
-
-  // visible for testing
-  isScrolling?: boolean;
-
-  /** Just for making sure that process() is only called once. */
-  private isStarted = false;
-
-  /** Indicates that processing should be stopped. */
-  private isCancelled = false;
-
-  private resetIsScrollingTask?: DelayedTask;
-
-  private readonly groups: GrDiffGroup[] = [];
+  private groups: GrDiffGroup[] = [];
 
   constructor(options: ProcessingOptions) {
     this.context = options.context;
-    this.asyncThreshold = options.asyncThreshold ?? 64;
     this.keyLocations = options.keyLocations ?? {left: {}, right: {}};
     this.isBinary = options.isBinary ?? false;
   }
 
-  private readonly handleWindowScroll = () => {
-    this.isScrolling = true;
-    this.resetIsScrollingTask = debounce(
-      this.resetIsScrollingTask,
-      () => (this.isScrolling = false),
-      50
-    );
-  };
-
   /**
-   * Asynchronously process the diff chunks into groups. As it processes, it
-   * will splice groups into the `groups` property of the component.
+   * Process the diff chunks into GrDiffGroups.
    *
-   * @return A promise that resolves with an
-   * array of GrDiffGroups when the diff is completely processed.
+   * @return an array of GrDiffGroups
    */
-  async process(chunks: DiffContent[]): Promise<GrDiffGroup[]> {
-    assert(this.isStarted === false, 'diff processor cannot be started twice');
-
-    window.addEventListener('scroll', this.handleWindowScroll);
-
+  process(chunks: DiffContent[]): GrDiffGroup[] {
+    this.groups = [];
     this.groups.push(this.makeGroup('LOST'));
     this.groups.push(this.makeGroup('FILE'));
 
-    if (this.isBinary) return this.groups;
-    try {
-      await this.processChunks(chunks);
-    } finally {
-      this.finish();
-    }
+    this.processChunks(chunks);
     return this.groups;
   }
 
-  finish() {
-    window.removeEventListener('scroll', this.handleWindowScroll);
-  }
-
-  cancel() {
-    this.isCancelled = true;
-    this.finish();
-  }
-
-  async processChunks(chunks: DiffContent[]) {
-    let completed = () => {};
-    const promise = new Promise<void>(resolve => (completed = resolve));
+  processChunks(chunks: DiffContent[]) {
+    if (this.isBinary) return;
 
     const state = {
       lineNums: {left: 0, right: 0},
       chunkIndex: 0,
     };
-
-    chunks = this.splitLargeChunks(chunks);
     chunks = this.splitCommonChunksWithKeyLocations(chunks);
 
-    let currentBatch = 0;
-    const nextStep = () => {
-      if (this.isCancelled || state.chunkIndex >= chunks.length) {
-        completed();
-        return;
-      }
-      if (this.isScrolling) {
-        window.setTimeout(nextStep, 100);
-        return;
-      }
-
+    while (state.chunkIndex < chunks.length) {
       const stateUpdate = this.processNext(state, chunks);
       for (const group of stateUpdate.groups) {
         this.groups.push(group);
-        currentBatch += group.lines.length;
       }
       state.lineNums.left += stateUpdate.lineDelta.left;
       state.lineNums.right += stateUpdate.lineDelta.right;
-
       state.chunkIndex = stateUpdate.newChunkIndex;
-      if (currentBatch >= this.asyncThreshold) {
-        currentBatch = 0;
-        window.setTimeout(nextStep, 1);
-      } else {
-        nextStep.call(this);
-      }
-    };
-
-    nextStep.call(this);
-    await promise;
+    }
   }
 
   /**
@@ -448,53 +367,6 @@
   }
 
   /**
-   * Split chunks into smaller chunks of the same kind.
-   *
-   * This is done to prevent doing too much work on the main thread in one
-   * uninterrupted rendering step, which would make the browser unresponsive.
-   *
-   * Note that in the case of unmodified chunks, we only split chunks if the
-   * context is set to file (because otherwise they are split up further down
-   * the processing into the visible and hidden context), and only split it
-   * into 2 chunks, one max sized one and the rest (for reasons that are
-   * unclear to me).
-   *
-   * @param chunks Chunks as returned from the server
-   * @return Finer grained chunks.
-   */
-  // visible for testing
-  splitLargeChunks(chunks: DiffContent[]): DiffContent[] {
-    const newChunks = [];
-
-    for (const chunk of chunks) {
-      if (!chunk.ab) {
-        for (const subChunk of this.breakdownChunk(chunk)) {
-          newChunks.push(subChunk);
-        }
-        continue;
-      }
-
-      // If the context is set to "whole file", then break down the shared
-      // chunks so they can be rendered incrementally. Note: this is not
-      // enabled for any other context preference because manipulating the
-      // chunks in this way violates assumptions by the context grouper logic.
-      const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
-      if (
-        this.context === FULL_CONTEXT &&
-        chunk.ab.length > MAX_GROUP_SIZE * 2
-      ) {
-        // Split large shared chunks in two, where the first is the maximum
-        // group size.
-        newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
-        newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
-      } else {
-        newChunks.push(chunk);
-      }
-    }
-    return newChunks;
-  }
-
-  /**
    * In order to show key locations, such as comments, out of the bounds of
    * the selected context, treat them as separate chunks within the model so
    * that the content (and context surrounding it) renders correctly.
@@ -675,60 +547,4 @@
     }
     return normalized;
   }
-
-  /**
-   * If a group is an addition or a removal, break it down into smaller groups
-   * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
-   * or a delta it is returned as the single element of the result array.
-   */
-  // visible for testing
-  breakdownChunk(chunk: DiffContent): DiffContent[] {
-    let key: 'a' | 'b' | 'ab' | null = null;
-    const {a, b, ab, move_details} = chunk;
-    if (a?.length && !b?.length) {
-      key = 'a';
-    } else if (b?.length && !a?.length) {
-      key = 'b';
-    } else if (ab?.length) {
-      key = 'ab';
-    }
-
-    // Move chunks should not be divided because of move label
-    // positioned in the top of the chunk
-    if (!key || move_details) {
-      return [chunk];
-    }
-
-    const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
-    return this.breakdown(chunk[key]!, MAX_GROUP_SIZE).map(subChunkLines => {
-      const subChunk: DiffContent = {};
-      subChunk[key!] = subChunkLines;
-      if (chunk.due_to_rebase) {
-        subChunk.due_to_rebase = true;
-      }
-      if (chunk.move_details) {
-        subChunk.move_details = chunk.move_details;
-      }
-      return subChunk;
-    });
-  }
-
-  /**
-   * Given an array and a size, return an array of arrays where no inner array
-   * is larger than that size, preserving the original order.
-   */
-  // visible for testing
-  breakdown<T>(array: T[], size: number): T[][] {
-    if (!array.length) {
-      return [];
-    }
-    if (array.length < size) {
-      return [array];
-    }
-
-    const head = array.slice(0, array.length - size);
-    const tail = array.slice(array.length - size);
-
-    return this.breakdown(head, size).concat([tail]);
-  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index 3485fe4..f6b9737 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -578,71 +578,6 @@
       ]);
     });
 
-    test('breaks down shared chunks w/ whole-file', () => {
-      const maxGroupSize = 128;
-      const size = maxGroupSize * 2 + 5;
-      const ab = Array(size)
-        .fill(0)
-        .map(() => `${Math.random()}`);
-      const content = [{ab}];
-      processor.context = FULL_CONTEXT;
-      const result = processor.splitLargeChunks(content);
-      assert.equal(result.length, 2);
-      assert.deepEqual(result[0].ab, content[0].ab.slice(0, maxGroupSize));
-      assert.deepEqual(result[1].ab, content[0].ab.slice(maxGroupSize));
-    });
-
-    test('breaks down added chunks', () => {
-      const maxGroupSize = 128;
-      const size = maxGroupSize * 2 + 5;
-      const content = Array(size)
-        .fill(0)
-        .map(() => `${Math.random()}`);
-      processor.context = 5;
-      const splitContent = processor
-        .splitLargeChunks([{a: [], b: content}])
-        .map(r => r.b);
-      assert.equal(splitContent.length, 3);
-      assert.deepEqual(splitContent[0], content.slice(0, 5));
-      assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
-      assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
-    });
-
-    test('breaks down removed chunks', () => {
-      const maxGroupSize = 128;
-      const size = maxGroupSize * 2 + 5;
-      const content = Array(size)
-        .fill(0)
-        .map(() => `${Math.random()}`);
-      processor.context = 5;
-      const splitContent = processor
-        .splitLargeChunks([{a: content, b: []}])
-        .map(r => r.a);
-      assert.equal(splitContent.length, 3);
-      assert.deepEqual(splitContent[0], content.slice(0, 5));
-      assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
-      assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
-    });
-
-    test('does not break down moved chunks', () => {
-      const size = 120 * 2 + 5;
-      const content = Array(size)
-        .fill(0)
-        .map(() => `${Math.random()}`);
-      processor.context = 5;
-      const splitContent = processor
-        .splitLargeChunks([
-          {
-            a: content,
-            b: [],
-            move_details: {changed: false, range: {start: 1, end: 1}},
-          },
-        ])
-        .map(r => r.a);
-      assert.equal(splitContent.length, 1);
-      assert.deepEqual(splitContent[0], content);
-    });
-
     test('does not break-down common chunks w/ context', () => {
       const ab = Array(75)
         .fill(0)
@@ -767,15 +702,6 @@
       ]);
     });
 
-    test('isScrolling paused', async () => {
-      const content = Array(200).fill({ab: ['', '']});
-      processor.isScrolling = true;
-      const promise = processor.process(content);
-      processor.isScrolling = false;
-      const groups = await promise;
-      assert.isAtLeast(groups.length, 3);
-    });
-
     test('image diffs', async () => {
       const content = Array(200).fill({ab: ['', '']});
       options.isBinary = true;
@@ -1053,61 +979,5 @@
         assert.notOk(result[result.length - 1].afterNumber);
       });
     });
-
-    suite('breakdown*', () => {
-      test('breakdownChunk breaks down additions', () => {
-        const breakdownSpy = sinon.spy(processor, 'breakdown');
-        const chunk = {b: ['blah', 'blah', 'blah']};
-        const result = processor.breakdownChunk(chunk);
-        assert.deepEqual(result, [chunk]);
-        assert.isTrue(breakdownSpy.called);
-      });
-
-      test('breakdownChunk keeps due_to_rebase for broken down additions', () => {
-        sinon.spy(processor, 'breakdown');
-        const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
-        const result = processor.breakdownChunk(chunk);
-        for (const subResult of result) {
-          assert.isTrue(subResult.due_to_rebase);
-        }
-      });
-
-      test('breakdown common case', () => {
-        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
-          ' '
-        );
-        const size = 3;
-
-        const result = processor.breakdown(array, size);
-
-        for (const subResult of result) {
-          assert.isAtMost(subResult.length, size);
-        }
-        const flattened = result.reduce((a, b) => a.concat(b), []);
-        assert.deepEqual(flattened, array);
-      });
-
-      test('breakdown smaller than size', () => {
-        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
-          ' '
-        );
-        const size = 10;
-        const expected = [array];
-
-        const result = processor.breakdown(array, size);
-
-        assert.deepEqual(result, expected);
-      });
-
-      test('breakdown empty', () => {
-        const array: string[] = [];
-        const size = 10;
-        const expected: string[][] = [];
-
-        const result = processor.breakdown(array, size);
-
-        assert.deepEqual(result, expected);
-      });
-    });
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts
index b5a79cd..e158bd7 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts
@@ -14,12 +14,7 @@
 import '../gr-diff-builder/gr-diff-builder-image';
 import '../gr-diff-builder/gr-diff-section';
 import '../gr-diff-builder/gr-diff-row';
-import {
-  isResponsive,
-  FullContext,
-  diffClasses,
-  FULL_CONTEXT,
-} from './gr-diff-utils';
+import {isResponsive, FullContext, FULL_CONTEXT} from './gr-diff-utils';
 import {ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {
@@ -364,9 +359,9 @@
 
   public renderBinaryDiff() {
     return html`
-      <tbody class="gr-diff binary-diff">
-        <tr class="gr-diff">
-          <td colspan=${this.columnCount} class="gr-diff">
+      <tbody class="binary-diff">
+        <tr>
+          <td colspan=${this.columnCount}>
             <span>Difference in binary files</span>
           </td>
         </tr>
@@ -394,42 +389,19 @@
     );
     return html`
       <colgroup>
-        ${when(
-          this.columns.blame,
-          () => html`<col class=${diffClasses('blame')} />`
-        )}
+        ${when(this.columns.blame, () => html`<col class="blame" />`)}
         ${when(
           this.columns.leftNumber,
-          () =>
-            html`<col
-              class=${diffClasses(Side.LEFT)}
-              width=${lineNumberWidth}
-            />`
+          () => html`<col class="left" width=${lineNumberWidth} />`
         )}
-        ${when(
-          this.columns.leftSign,
-          () => html`<col class=${diffClasses(Side.LEFT, 'sign')} />`
-        )}
-        ${when(
-          this.columns.leftContent,
-          () => html`<col class=${diffClasses(Side.LEFT)} />`
-        )}
+        ${when(this.columns.leftSign, () => html`<col class="left sign" />`)}
+        ${when(this.columns.leftContent, () => html`<col class="left" />`)}
         ${when(
           this.columns.rightNumber,
-          () =>
-            html`<col
-              class=${diffClasses(Side.RIGHT)}
-              width=${lineNumberWidth}
-            />`
+          () => html`<col class="right" width=${lineNumberWidth} />`
         )}
-        ${when(
-          this.columns.rightSign,
-          () => html`<col class=${diffClasses(Side.RIGHT, 'sign')} />`
-        )}
-        ${when(
-          this.columns.rightContent,
-          () => html`<col class=${diffClasses(Side.RIGHT)} />`
-        )}
+        ${when(this.columns.rightSign, () => html`<col class="right sign" />`)}
+        ${when(this.columns.rightContent, () => html`<col class="right" />`)}
       </colgroup>
     `;
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
index be6b72e..1a43719 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
@@ -60,11 +60,10 @@
           <div class="diffContainer sideBySide">
             <table id="diffTable">
               <colgroup>
-                <col class="blame gr-diff" />
-                <col class="gr-diff left" width="48" />
-                <col class="gr-diff left" />
-                <col class="gr-diff right" width="48" />
-                <col class="gr-diff right" />
+                <col class="left" width="48" />
+                <col class="left" />
+                <col class="right" width="48" />
+                <col class="right" />
               </colgroup>
             </table>
           </div>
@@ -86,36 +85,31 @@
           <div class="diffContainer unified">
             <table id="diffTable">
               <colgroup>
-                <col class="blame gr-diff" />
-                <col class="gr-diff left" width="48" />
-                <col class="gr-diff right" width="48" />
-                <col class="gr-diff right" />
+                <col class="left" width="48" />
+                <col class="right" width="48" />
+                <col class="right" />
               </colgroup>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-LOST right-button-LOST right-content-LOST"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="LOST"></td>
-                  <td class="gr-diff left lineNum" data-value="LOST"></td>
-                  <td class="gr-diff lineNum right" data-value="LOST"></td>
-                  <td
-                    class="both content gr-diff lost no-intraline-info right"
-                  ></td>
+                  <td class="left lineNum" data-value="LOST"></td>
+                  <td class="lineNum right" data-value="LOST"></td>
+                  <td class="both content lost no-intraline-info right"></td>
                 </tr>
               </tbody>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-FILE right-button-FILE right-content-FILE"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="FILE"></td>
-                  <td class="gr-diff left lineNum" data-value="FILE">
+                  <td class="left lineNum" data-value="FILE">
                     <button
                       aria-label="Add file comment"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="FILE"
                       id="left-button-FILE"
                       tabindex="-1"
@@ -123,10 +117,10 @@
                       FILE
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="FILE">
+                  <td class="lineNum right" data-value="FILE">
                     <button
                       aria-label="Add file comment"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="FILE"
                       id="right-button-FILE"
                       tabindex="-1"
@@ -134,22 +128,19 @@
                       FILE
                     </button>
                   </td>
-                  <td
-                    class="both content file gr-diff no-intraline-info right"
-                  ></td>
+                  <td class="both content file no-intraline-info right"></td>
                 </tr>
               </tbody>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-1 right-button-1 right-content-1"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="1"></td>
-                  <td class="gr-diff left lineNum" data-value="1">
+                  <td class="left lineNum" data-value="1">
                     <button
                       aria-label="1 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="1"
                       id="left-button-1"
                       tabindex="-1"
@@ -157,10 +148,10 @@
                       1
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="1">
+                  <td class="lineNum right" data-value="1">
                     <button
                       aria-label="1 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="1"
                       id="right-button-1"
                       tabindex="-1"
@@ -168,9 +159,9 @@
                       1
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-1"
                     ></div>
@@ -178,14 +169,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-2 right-button-2 right-content-2"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="2"></td>
-                  <td class="gr-diff left lineNum" data-value="2">
+                  <td class="left lineNum" data-value="2">
                     <button
                       aria-label="2 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="2"
                       id="left-button-2"
                       tabindex="-1"
@@ -193,10 +183,10 @@
                       2
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="2">
+                  <td class="lineNum right" data-value="2">
                     <button
                       aria-label="2 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="2"
                       id="right-button-2"
                       tabindex="-1"
@@ -204,9 +194,9 @@
                       2
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-2"
                     ></div>
@@ -214,14 +204,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-3 right-button-3 right-content-3"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="3"></td>
-                  <td class="gr-diff left lineNum" data-value="3">
+                  <td class="left lineNum" data-value="3">
                     <button
                       aria-label="3 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="3"
                       id="left-button-3"
                       tabindex="-1"
@@ -229,10 +218,10 @@
                       3
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="3">
+                  <td class="lineNum right" data-value="3">
                     <button
                       aria-label="3 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="3"
                       id="right-button-3"
                       tabindex="-1"
@@ -240,9 +229,9 @@
                       3
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-3"
                     ></div>
@@ -250,14 +239,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-4 right-button-4 right-content-4"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="4"></td>
-                  <td class="gr-diff left lineNum" data-value="4">
+                  <td class="left lineNum" data-value="4">
                     <button
                       aria-label="4 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="4"
                       id="left-button-4"
                       tabindex="-1"
@@ -265,10 +253,10 @@
                       4
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="4">
+                  <td class="lineNum right" data-value="4">
                     <button
                       aria-label="4 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="4"
                       id="right-button-4"
                       tabindex="-1"
@@ -276,27 +264,26 @@
                       4
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-4"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="delta gr-diff section total">
+              <tbody class="delta section total">
                 <tr
                   aria-labelledby="right-button-5 right-content-5"
-                  class="add diff-row gr-diff unified"
+                  class="add diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="gr-diff lineNum right" data-value="5">
+                  <td class="blankLineNum left"></td>
+                  <td class="lineNum right" data-value="5">
                     <button
                       aria-label="5 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="5"
                       id="right-button-5"
                       tabindex="-1"
@@ -304,9 +291,9 @@
                       5
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-5"
                     ></div>
@@ -314,15 +301,14 @@
                 </tr>
                 <tr
                   aria-labelledby="right-button-6 right-content-6"
-                  class="add diff-row gr-diff unified"
+                  class="add diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="gr-diff lineNum right" data-value="6">
+                  <td class="blankLineNum left"></td>
+                  <td class="lineNum right" data-value="6">
                     <button
                       aria-label="6 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="6"
                       id="right-button-6"
                       tabindex="-1"
@@ -330,9 +316,9 @@
                       6
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-6"
                     ></div>
@@ -340,15 +326,14 @@
                 </tr>
                 <tr
                   aria-labelledby="right-button-7 right-content-7"
-                  class="add diff-row gr-diff unified"
+                  class="add diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="gr-diff lineNum right" data-value="7">
+                  <td class="blankLineNum left"></td>
+                  <td class="lineNum right" data-value="7">
                     <button
                       aria-label="7 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="7"
                       id="right-button-7"
                       tabindex="-1"
@@ -356,26 +341,25 @@
                       7
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-7"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-5 right-button-8 right-content-8"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="5"></td>
-                  <td class="gr-diff left lineNum" data-value="5">
+                  <td class="left lineNum" data-value="5">
                     <button
                       aria-label="5 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="5"
                       id="left-button-5"
                       tabindex="-1"
@@ -383,10 +367,10 @@
                       5
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="8">
+                  <td class="lineNum right" data-value="8">
                     <button
                       aria-label="8 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="8"
                       id="right-button-8"
                       tabindex="-1"
@@ -394,9 +378,9 @@
                       8
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-8"
                     ></div>
@@ -404,14 +388,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-6 right-button-9 right-content-9"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="6"></td>
-                  <td class="gr-diff left lineNum" data-value="6">
+                  <td class="left lineNum" data-value="6">
                     <button
                       aria-label="6 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="6"
                       id="left-button-6"
                       tabindex="-1"
@@ -419,10 +402,10 @@
                       6
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="9">
+                  <td class="lineNum right" data-value="9">
                     <button
                       aria-label="9 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="9"
                       id="right-button-9"
                       tabindex="-1"
@@ -430,9 +413,9 @@
                       9
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-9"
                     ></div>
@@ -440,14 +423,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-7 right-button-10 right-content-10"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="7"></td>
-                  <td class="gr-diff left lineNum" data-value="7">
+                  <td class="left lineNum" data-value="7">
                     <button
                       aria-label="7 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="7"
                       id="left-button-7"
                       tabindex="-1"
@@ -455,10 +437,10 @@
                       7
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="10">
+                  <td class="lineNum right" data-value="10">
                     <button
                       aria-label="10 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="10"
                       id="right-button-10"
                       tabindex="-1"
@@ -466,9 +448,9 @@
                       10
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-10"
                     ></div>
@@ -476,14 +458,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-8 right-button-11 right-content-11"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="8"></td>
-                  <td class="gr-diff left lineNum" data-value="8">
+                  <td class="left lineNum" data-value="8">
                     <button
                       aria-label="8 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="8"
                       id="left-button-8"
                       tabindex="-1"
@@ -491,10 +472,10 @@
                       8
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="11">
+                  <td class="lineNum right" data-value="11">
                     <button
                       aria-label="11 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="11"
                       id="right-button-11"
                       tabindex="-1"
@@ -502,9 +483,9 @@
                       11
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-11"
                     ></div>
@@ -512,14 +493,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-9 right-button-12 right-content-12"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="9"></td>
-                  <td class="gr-diff left lineNum" data-value="9">
+                  <td class="left lineNum" data-value="9">
                     <button
                       aria-label="9 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="9"
                       id="left-button-9"
                       tabindex="-1"
@@ -527,10 +507,10 @@
                       9
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="12">
+                  <td class="lineNum right" data-value="12">
                     <button
                       aria-label="12 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="12"
                       id="right-button-12"
                       tabindex="-1"
@@ -538,26 +518,25 @@
                       12
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-12"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="delta gr-diff section total">
+              <tbody class="delta section total">
                 <tr
                   aria-labelledby="left-button-10 left-content-10"
-                  class="diff-row gr-diff remove unified"
+                  class="diff-row remove unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="10"></td>
-                  <td class="gr-diff left lineNum" data-value="10">
+                  <td class="left lineNum" data-value="10">
                     <button
                       aria-label="10 removed"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="10"
                       id="left-button-10"
                       tabindex="-1"
@@ -565,10 +544,10 @@
                       10
                     </button>
                   </td>
-                  <td class="blankLineNum gr-diff right"></td>
-                  <td class="content gr-diff left no-intraline-info remove">
+                  <td class="blankLineNum right"></td>
+                  <td class="content left no-intraline-info remove">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-10"
                     ></div>
@@ -576,14 +555,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-11 left-content-11"
-                  class="diff-row gr-diff remove unified"
+                  class="diff-row remove unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="11"></td>
-                  <td class="gr-diff left lineNum" data-value="11">
+                  <td class="left lineNum" data-value="11">
                     <button
                       aria-label="11 removed"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="11"
                       id="left-button-11"
                       tabindex="-1"
@@ -591,10 +569,10 @@
                       11
                     </button>
                   </td>
-                  <td class="blankLineNum gr-diff right"></td>
-                  <td class="content gr-diff left no-intraline-info remove">
+                  <td class="blankLineNum right"></td>
+                  <td class="content left no-intraline-info remove">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-11"
                     ></div>
@@ -602,14 +580,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-12 left-content-12"
-                  class="diff-row gr-diff remove unified"
+                  class="diff-row remove unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="12"></td>
-                  <td class="gr-diff left lineNum" data-value="12">
+                  <td class="left lineNum" data-value="12">
                     <button
                       aria-label="12 removed"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="12"
                       id="left-button-12"
                       tabindex="-1"
@@ -617,10 +594,10 @@
                       12
                     </button>
                   </td>
-                  <td class="blankLineNum gr-diff right"></td>
-                  <td class="content gr-diff left no-intraline-info remove">
+                  <td class="blankLineNum right"></td>
+                  <td class="content left no-intraline-info remove">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-12"
                     ></div>
@@ -628,14 +605,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-13 left-content-13"
-                  class="diff-row gr-diff remove unified"
+                  class="diff-row remove unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="13"></td>
-                  <td class="gr-diff left lineNum" data-value="13">
+                  <td class="left lineNum" data-value="13">
                     <button
                       aria-label="13 removed"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="13"
                       id="left-button-13"
                       tabindex="-1"
@@ -643,28 +619,27 @@
                       13
                     </button>
                   </td>
-                  <td class="blankLineNum gr-diff right"></td>
-                  <td class="content gr-diff left no-intraline-info remove">
+                  <td class="blankLineNum right"></td>
+                  <td class="content left no-intraline-info remove">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-13"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+              <tbody class="delta ignoredWhitespaceOnly section">
                 <tr
                   aria-labelledby="right-button-13 right-content-13"
-                  class="add diff-row gr-diff unified"
+                  class="add diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="gr-diff lineNum right" data-value="13">
+                  <td class="blankLineNum left"></td>
+                  <td class="lineNum right" data-value="13">
                     <button
                       aria-label="13 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="13"
                       id="right-button-13"
                       tabindex="-1"
@@ -672,9 +647,9 @@
                       13
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-13"
                     ></div>
@@ -682,15 +657,14 @@
                 </tr>
                 <tr
                   aria-labelledby="right-button-14 right-content-14"
-                  class="add diff-row gr-diff unified"
+                  class="add diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="gr-diff lineNum right" data-value="14">
+                  <td class="blankLineNum left"></td>
+                  <td class="lineNum right" data-value="14">
                     <button
                       aria-label="14 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="14"
                       id="right-button-14"
                       tabindex="-1"
@@ -698,26 +672,25 @@
                       14
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-14"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="delta gr-diff section">
+              <tbody class="delta section">
                 <tr
                   aria-labelledby="left-button-16 left-content-16"
-                  class="diff-row gr-diff remove unified"
+                  class="diff-row remove unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="16"></td>
-                  <td class="gr-diff left lineNum" data-value="16">
+                  <td class="left lineNum" data-value="16">
                     <button
                       aria-label="16 removed"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="16"
                       id="left-button-16"
                       tabindex="-1"
@@ -725,10 +698,10 @@
                       16
                     </button>
                   </td>
-                  <td class="blankLineNum gr-diff right"></td>
-                  <td class="content gr-diff left remove">
+                  <td class="blankLineNum right"></td>
+                  <td class="content left remove">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-16"
                     ></div>
@@ -736,15 +709,14 @@
                 </tr>
                 <tr
                   aria-labelledby="right-button-15 right-content-15"
-                  class="add diff-row gr-diff unified"
+                  class="add diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="gr-diff lineNum right" data-value="15">
+                  <td class="blankLineNum left"></td>
+                  <td class="lineNum right" data-value="15">
                     <button
                       aria-label="15 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="15"
                       id="right-button-15"
                       tabindex="-1"
@@ -752,26 +724,25 @@
                       15
                     </button>
                   </td>
-                  <td class="add content gr-diff right">
+                  <td class="add content right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-15"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-17 right-button-16 right-content-16"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="17"></td>
-                  <td class="gr-diff left lineNum" data-value="17">
+                  <td class="left lineNum" data-value="17">
                     <button
                       aria-label="17 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="17"
                       id="left-button-17"
                       tabindex="-1"
@@ -779,10 +750,10 @@
                       17
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="16">
+                  <td class="lineNum right" data-value="16">
                     <button
                       aria-label="16 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="16"
                       id="right-button-16"
                       tabindex="-1"
@@ -790,9 +761,9 @@
                       16
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-16"
                     ></div>
@@ -800,14 +771,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-18 right-button-17 right-content-17"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="18"></td>
-                  <td class="gr-diff left lineNum" data-value="18">
+                  <td class="left lineNum" data-value="18">
                     <button
                       aria-label="18 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="18"
                       id="left-button-18"
                       tabindex="-1"
@@ -815,10 +785,10 @@
                       18
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="17">
+                  <td class="lineNum right" data-value="17">
                     <button
                       aria-label="17 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="17"
                       id="right-button-17"
                       tabindex="-1"
@@ -826,9 +796,9 @@
                       17
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-17"
                     ></div>
@@ -836,14 +806,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-19 right-button-18 right-content-18"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="19"></td>
-                  <td class="gr-diff left lineNum" data-value="19">
+                  <td class="left lineNum" data-value="19">
                     <button
                       aria-label="19 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="19"
                       id="left-button-19"
                       tabindex="-1"
@@ -851,10 +820,10 @@
                       19
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="18">
+                  <td class="lineNum right" data-value="18">
                     <button
                       aria-label="18 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="18"
                       id="right-button-18"
                       tabindex="-1"
@@ -862,47 +831,43 @@
                       18
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-18"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="contextControl gr-diff section">
-                <tr class="above contextBackground gr-diff unified">
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="contextLineNum gr-diff"></td>
-                  <td class="contextLineNum gr-diff"></td>
-                  <td class="gr-diff"></td>
+              <tbody class="contextControl section">
+                <tr class="above contextBackground unified">
+                  <td class="contextLineNum"></td>
+                  <td class="contextLineNum"></td>
+                  <td></td>
                 </tr>
-                <tr class="dividerRow gr-diff show-both">
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="dividerCell gr-diff" colspan="3">
-                    <gr-context-controls class="gr-diff" showconfig="both">
+                <tr class="dividerRow show-both">
+                  <td class="dividerCell" colspan="3">
+                    <gr-context-controls showconfig="both">
                     </gr-context-controls>
                   </td>
                 </tr>
-                <tr class="below contextBackground gr-diff unified">
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="contextLineNum gr-diff"></td>
-                  <td class="contextLineNum gr-diff"></td>
-                  <td class="gr-diff"></td>
+                <tr class="below contextBackground unified">
+                  <td class="contextLineNum"></td>
+                  <td class="contextLineNum"></td>
+                  <td></td>
                 </tr>
               </tbody>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-38 right-button-37 right-content-37"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="38"></td>
-                  <td class="gr-diff left lineNum" data-value="38">
+                  <td class="left lineNum" data-value="38">
                     <button
                       aria-label="38 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="38"
                       id="left-button-38"
                       tabindex="-1"
@@ -910,10 +875,10 @@
                       38
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="37">
+                  <td class="lineNum right" data-value="37">
                     <button
                       aria-label="37 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="37"
                       id="right-button-37"
                       tabindex="-1"
@@ -921,9 +886,9 @@
                       37
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-37"
                     ></div>
@@ -931,14 +896,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-39 right-button-38 right-content-38"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="39"></td>
-                  <td class="gr-diff left lineNum" data-value="39">
+                  <td class="left lineNum" data-value="39">
                     <button
                       aria-label="39 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="39"
                       id="left-button-39"
                       tabindex="-1"
@@ -946,10 +910,10 @@
                       39
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="38">
+                  <td class="lineNum right" data-value="38">
                     <button
                       aria-label="38 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="38"
                       id="right-button-38"
                       tabindex="-1"
@@ -957,9 +921,9 @@
                       38
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-38"
                     ></div>
@@ -967,14 +931,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-40 right-button-39 right-content-39"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="40"></td>
-                  <td class="gr-diff left lineNum" data-value="40">
+                  <td class="left lineNum" data-value="40">
                     <button
                       aria-label="40 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="40"
                       id="left-button-40"
                       tabindex="-1"
@@ -982,10 +945,10 @@
                       40
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="39">
+                  <td class="lineNum right" data-value="39">
                     <button
                       aria-label="39 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="39"
                       id="right-button-39"
                       tabindex="-1"
@@ -993,27 +956,26 @@
                       39
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-39"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="delta gr-diff section total">
+              <tbody class="delta section total">
                 <tr
                   aria-labelledby="right-button-40 right-content-40"
-                  class="add diff-row gr-diff unified"
+                  class="add diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="gr-diff lineNum right" data-value="40">
+                  <td class="blankLineNum left"></td>
+                  <td class="lineNum right" data-value="40">
                     <button
                       aria-label="40 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="40"
                       id="right-button-40"
                       tabindex="-1"
@@ -1021,9 +983,9 @@
                       40
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-40"
                     ></div>
@@ -1031,15 +993,14 @@
                 </tr>
                 <tr
                   aria-labelledby="right-button-41 right-content-41"
-                  class="add diff-row gr-diff unified"
+                  class="add diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="gr-diff lineNum right" data-value="41">
+                  <td class="blankLineNum left"></td>
+                  <td class="lineNum right" data-value="41">
                     <button
                       aria-label="41 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="41"
                       id="right-button-41"
                       tabindex="-1"
@@ -1047,9 +1008,9 @@
                       41
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-41"
                     ></div>
@@ -1057,15 +1018,14 @@
                 </tr>
                 <tr
                   aria-labelledby="right-button-42 right-content-42"
-                  class="add diff-row gr-diff unified"
+                  class="add diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="gr-diff lineNum right" data-value="42">
+                  <td class="blankLineNum left"></td>
+                  <td class="lineNum right" data-value="42">
                     <button
                       aria-label="42 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="42"
                       id="right-button-42"
                       tabindex="-1"
@@ -1073,9 +1033,9 @@
                       42
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-42"
                     ></div>
@@ -1083,15 +1043,14 @@
                 </tr>
                 <tr
                   aria-labelledby="right-button-43 right-content-43"
-                  class="add diff-row gr-diff unified"
+                  class="add diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="gr-diff lineNum right" data-value="43">
+                  <td class="blankLineNum left"></td>
+                  <td class="lineNum right" data-value="43">
                     <button
                       aria-label="43 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="43"
                       id="right-button-43"
                       tabindex="-1"
@@ -1099,26 +1058,25 @@
                       43
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-43"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-41 right-button-44 right-content-44"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="41"></td>
-                  <td class="gr-diff left lineNum" data-value="41">
+                  <td class="left lineNum" data-value="41">
                     <button
                       aria-label="41 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="41"
                       id="left-button-41"
                       tabindex="-1"
@@ -1126,10 +1084,10 @@
                       41
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="44">
+                  <td class="lineNum right" data-value="44">
                     <button
                       aria-label="44 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="44"
                       id="right-button-44"
                       tabindex="-1"
@@ -1137,9 +1095,9 @@
                       44
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-44"
                     ></div>
@@ -1147,14 +1105,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-42 right-button-45 right-content-45"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="42"></td>
-                  <td class="gr-diff left lineNum" data-value="42">
+                  <td class="left lineNum" data-value="42">
                     <button
                       aria-label="42 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="42"
                       id="left-button-42"
                       tabindex="-1"
@@ -1162,10 +1119,10 @@
                       42
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="45">
+                  <td class="lineNum right" data-value="45">
                     <button
                       aria-label="45 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="45"
                       id="right-button-45"
                       tabindex="-1"
@@ -1173,9 +1130,9 @@
                       45
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-45"
                     ></div>
@@ -1183,14 +1140,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-43 right-button-46 right-content-46"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="43"></td>
-                  <td class="gr-diff left lineNum" data-value="43">
+                  <td class="left lineNum" data-value="43">
                     <button
                       aria-label="43 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="43"
                       id="left-button-43"
                       tabindex="-1"
@@ -1198,10 +1154,10 @@
                       43
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="46">
+                  <td class="lineNum right" data-value="46">
                     <button
                       aria-label="46 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="46"
                       id="right-button-46"
                       tabindex="-1"
@@ -1209,9 +1165,9 @@
                       46
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-46"
                     ></div>
@@ -1219,14 +1175,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-44 right-button-47 right-content-47"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="44"></td>
-                  <td class="gr-diff left lineNum" data-value="44">
+                  <td class="left lineNum" data-value="44">
                     <button
                       aria-label="44 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="44"
                       id="left-button-44"
                       tabindex="-1"
@@ -1234,10 +1189,10 @@
                       44
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="47">
+                  <td class="lineNum right" data-value="47">
                     <button
                       aria-label="47 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="47"
                       id="right-button-47"
                       tabindex="-1"
@@ -1245,9 +1200,9 @@
                       47
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-47"
                     ></div>
@@ -1255,14 +1210,13 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-45 right-button-48 right-content-48"
-                  class="both diff-row gr-diff unified"
+                  class="both diff-row unified"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="45"></td>
-                  <td class="gr-diff left lineNum" data-value="45">
+                  <td class="left lineNum" data-value="45">
                     <button
                       aria-label="45 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="45"
                       id="left-button-45"
                       tabindex="-1"
@@ -1270,10 +1224,10 @@
                       45
                     </button>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="48">
+                  <td class="lineNum right" data-value="48">
                     <button
                       aria-label="48 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="48"
                       id="right-button-48"
                       tabindex="-1"
@@ -1281,9 +1235,9 @@
                       48
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-48"
                     ></div>
@@ -1299,7 +1253,6 @@
             'gr-diff-section',
             'gr-diff-row',
             'gr-diff-text',
-            'gr-legacy-text',
             'slot',
           ],
         }
@@ -1320,44 +1273,37 @@
           <div class="diffContainer sideBySide">
             <table id="diffTable">
               <colgroup>
-                <col class="blame gr-diff" />
-                <col class="gr-diff left" width="48" />
-                <col class="gr-diff left" />
-                <col class="gr-diff right" width="48" />
-                <col class="gr-diff right" />
+                <col class="left" width="48" />
+                <col class="left" />
+                <col class="right" width="48" />
+                <col class="right" />
               </colgroup>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-LOST left-content-LOST right-button-LOST right-content-LOST"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="LOST"></td>
-                  <td class="gr-diff left lineNum" data-value="LOST"></td>
-                  <td
-                    class="both content gr-diff left lost no-intraline-info"
-                  ></td>
-                  <td class="gr-diff lineNum right" data-value="LOST"></td>
-                  <td
-                    class="both content gr-diff lost no-intraline-info right"
-                  ></td>
+                  <td class="left lineNum" data-value="LOST"></td>
+                  <td class="both content left lost no-intraline-info"></td>
+                  <td class="lineNum right" data-value="LOST"></td>
+                  <td class="both content lost no-intraline-info right"></td>
                 </tr>
               </tbody>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="FILE"></td>
-                  <td class="gr-diff left lineNum" data-value="FILE">
+                  <td class="left lineNum" data-value="FILE">
                     <button
                       aria-label="Add file comment"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="FILE"
                       id="left-button-FILE"
                       tabindex="-1"
@@ -1365,13 +1311,11 @@
                       FILE
                     </button>
                   </td>
-                  <td
-                    class="both content file gr-diff left no-intraline-info"
-                  ></td>
-                  <td class="gr-diff lineNum right" data-value="FILE">
+                  <td class="both content file left no-intraline-info"></td>
+                  <td class="lineNum right" data-value="FILE">
                     <button
                       aria-label="Add file comment"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="FILE"
                       id="right-button-FILE"
                       tabindex="-1"
@@ -1379,24 +1323,21 @@
                       FILE
                     </button>
                   </td>
-                  <td
-                    class="both content file gr-diff no-intraline-info right"
-                  ></td>
+                  <td class="both content file no-intraline-info right"></td>
                 </tr>
               </tbody>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="1"></td>
-                  <td class="gr-diff left lineNum" data-value="1">
+                  <td class="left lineNum" data-value="1">
                     <button
                       aria-label="1 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="1"
                       id="left-button-1"
                       tabindex="-1"
@@ -1404,17 +1345,17 @@
                       1
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-1"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="1">
+                  <td class="lineNum right" data-value="1">
                     <button
                       aria-label="1 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="1"
                       id="right-button-1"
                       tabindex="-1"
@@ -1422,9 +1363,9 @@
                       1
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-1"
                     ></div>
@@ -1432,16 +1373,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-2 left-content-2 right-button-2 right-content-2"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="2"></td>
-                  <td class="gr-diff left lineNum" data-value="2">
+                  <td class="left lineNum" data-value="2">
                     <button
                       aria-label="2 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="2"
                       id="left-button-2"
                       tabindex="-1"
@@ -1449,17 +1389,17 @@
                       2
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-2"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="2">
+                  <td class="lineNum right" data-value="2">
                     <button
                       aria-label="2 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="2"
                       id="right-button-2"
                       tabindex="-1"
@@ -1467,9 +1407,9 @@
                       2
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-2"
                     ></div>
@@ -1477,16 +1417,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-3 left-content-3 right-button-3 right-content-3"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="3"></td>
-                  <td class="gr-diff left lineNum" data-value="3">
+                  <td class="left lineNum" data-value="3">
                     <button
                       aria-label="3 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="3"
                       id="left-button-3"
                       tabindex="-1"
@@ -1494,17 +1433,17 @@
                       3
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-3"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="3">
+                  <td class="lineNum right" data-value="3">
                     <button
                       aria-label="3 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="3"
                       id="right-button-3"
                       tabindex="-1"
@@ -1512,9 +1451,9 @@
                       3
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-3"
                     ></div>
@@ -1522,16 +1461,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-4 left-content-4 right-button-4 right-content-4"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="4"></td>
-                  <td class="gr-diff left lineNum" data-value="4">
+                  <td class="left lineNum" data-value="4">
                     <button
                       aria-label="4 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="4"
                       id="left-button-4"
                       tabindex="-1"
@@ -1539,17 +1477,17 @@
                       4
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-4"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="4">
+                  <td class="lineNum right" data-value="4">
                     <button
                       aria-label="4 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="4"
                       id="right-button-4"
                       tabindex="-1"
@@ -1557,32 +1495,31 @@
                       4
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-4"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="delta gr-diff section total">
+              <tbody class="delta section total">
                 <tr
                   aria-labelledby="right-button-5 right-content-5"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="blank"
                   right-type="add"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="blank gr-diff left no-intraline-info">
-                    <div class="contentText gr-diff" data-side="left"></div>
+                  <td class="blankLineNum left"></td>
+                  <td class="blank left no-intraline-info">
+                    <div class="contentText" data-side="left"></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="5">
+                  <td class="lineNum right" data-value="5">
                     <button
                       aria-label="5 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="5"
                       id="right-button-5"
                       tabindex="-1"
@@ -1590,9 +1527,9 @@
                       5
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-5"
                     ></div>
@@ -1600,20 +1537,19 @@
                 </tr>
                 <tr
                   aria-labelledby="right-button-6 right-content-6"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="blank"
                   right-type="add"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="blank gr-diff left no-intraline-info">
-                    <div class="contentText gr-diff" data-side="left"></div>
+                  <td class="blankLineNum left"></td>
+                  <td class="blank left no-intraline-info">
+                    <div class="contentText" data-side="left"></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="6">
+                  <td class="lineNum right" data-value="6">
                     <button
                       aria-label="6 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="6"
                       id="right-button-6"
                       tabindex="-1"
@@ -1621,9 +1557,9 @@
                       6
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-6"
                     ></div>
@@ -1631,20 +1567,19 @@
                 </tr>
                 <tr
                   aria-labelledby="right-button-7 right-content-7"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="blank"
                   right-type="add"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="blank gr-diff left no-intraline-info">
-                    <div class="contentText gr-diff" data-side="left"></div>
+                  <td class="blankLineNum left"></td>
+                  <td class="blank left no-intraline-info">
+                    <div class="contentText" data-side="left"></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="7">
+                  <td class="lineNum right" data-value="7">
                     <button
                       aria-label="7 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="7"
                       id="right-button-7"
                       tabindex="-1"
@@ -1652,28 +1587,27 @@
                       7
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-7"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-5 left-content-5 right-button-8 right-content-8"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="5"></td>
-                  <td class="gr-diff left lineNum" data-value="5">
+                  <td class="left lineNum" data-value="5">
                     <button
                       aria-label="5 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="5"
                       id="left-button-5"
                       tabindex="-1"
@@ -1681,17 +1615,17 @@
                       5
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-5"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="8">
+                  <td class="lineNum right" data-value="8">
                     <button
                       aria-label="8 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="8"
                       id="right-button-8"
                       tabindex="-1"
@@ -1699,9 +1633,9 @@
                       8
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-8"
                     ></div>
@@ -1709,16 +1643,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-6 left-content-6 right-button-9 right-content-9"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="6"></td>
-                  <td class="gr-diff left lineNum" data-value="6">
+                  <td class="left lineNum" data-value="6">
                     <button
                       aria-label="6 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="6"
                       id="left-button-6"
                       tabindex="-1"
@@ -1726,17 +1659,17 @@
                       6
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-6"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="9">
+                  <td class="lineNum right" data-value="9">
                     <button
                       aria-label="9 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="9"
                       id="right-button-9"
                       tabindex="-1"
@@ -1744,9 +1677,9 @@
                       9
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-9"
                     ></div>
@@ -1754,16 +1687,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-7 left-content-7 right-button-10 right-content-10"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="7"></td>
-                  <td class="gr-diff left lineNum" data-value="7">
+                  <td class="left lineNum" data-value="7">
                     <button
                       aria-label="7 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="7"
                       id="left-button-7"
                       tabindex="-1"
@@ -1771,17 +1703,17 @@
                       7
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-7"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="10">
+                  <td class="lineNum right" data-value="10">
                     <button
                       aria-label="10 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="10"
                       id="right-button-10"
                       tabindex="-1"
@@ -1789,9 +1721,9 @@
                       10
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-10"
                     ></div>
@@ -1799,16 +1731,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-8 left-content-8 right-button-11 right-content-11"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="8"></td>
-                  <td class="gr-diff left lineNum" data-value="8">
+                  <td class="left lineNum" data-value="8">
                     <button
                       aria-label="8 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="8"
                       id="left-button-8"
                       tabindex="-1"
@@ -1816,17 +1747,17 @@
                       8
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-8"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="11">
+                  <td class="lineNum right" data-value="11">
                     <button
                       aria-label="11 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="11"
                       id="right-button-11"
                       tabindex="-1"
@@ -1834,9 +1765,9 @@
                       11
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-11"
                     ></div>
@@ -1844,16 +1775,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-9 left-content-9 right-button-12 right-content-12"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="9"></td>
-                  <td class="gr-diff left lineNum" data-value="9">
+                  <td class="left lineNum" data-value="9">
                     <button
                       aria-label="9 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="9"
                       id="left-button-9"
                       tabindex="-1"
@@ -1861,17 +1791,17 @@
                       9
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-9"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="12">
+                  <td class="lineNum right" data-value="12">
                     <button
                       aria-label="12 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="12"
                       id="right-button-12"
                       tabindex="-1"
@@ -1879,28 +1809,27 @@
                       12
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-12"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="delta gr-diff section total">
+              <tbody class="delta section total">
                 <tr
                   aria-labelledby="left-button-10 left-content-10"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="remove"
                   right-type="blank"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="10"></td>
-                  <td class="gr-diff left lineNum" data-value="10">
+                  <td class="left lineNum" data-value="10">
                     <button
                       aria-label="10 removed"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="10"
                       id="left-button-10"
                       tabindex="-1"
@@ -1908,30 +1837,29 @@
                       10
                     </button>
                   </td>
-                  <td class="content gr-diff left no-intraline-info remove">
+                  <td class="content left no-intraline-info remove">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-10"
                     ></div>
                   </td>
-                  <td class="blankLineNum gr-diff right"></td>
-                  <td class="blank gr-diff no-intraline-info right">
-                    <div class="contentText gr-diff" data-side="right"></div>
+                  <td class="blankLineNum right"></td>
+                  <td class="blank no-intraline-info right">
+                    <div class="contentText" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
                   aria-labelledby="left-button-11 left-content-11"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="remove"
                   right-type="blank"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="11"></td>
-                  <td class="gr-diff left lineNum" data-value="11">
+                  <td class="left lineNum" data-value="11">
                     <button
                       aria-label="11 removed"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="11"
                       id="left-button-11"
                       tabindex="-1"
@@ -1939,30 +1867,29 @@
                       11
                     </button>
                   </td>
-                  <td class="content gr-diff left no-intraline-info remove">
+                  <td class="content left no-intraline-info remove">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-11"
                     ></div>
                   </td>
-                  <td class="blankLineNum gr-diff right"></td>
-                  <td class="blank gr-diff no-intraline-info right">
-                    <div class="contentText gr-diff" data-side="right"></div>
+                  <td class="blankLineNum right"></td>
+                  <td class="blank no-intraline-info right">
+                    <div class="contentText" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
                   aria-labelledby="left-button-12 left-content-12"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="remove"
                   right-type="blank"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="12"></td>
-                  <td class="gr-diff left lineNum" data-value="12">
+                  <td class="left lineNum" data-value="12">
                     <button
                       aria-label="12 removed"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="12"
                       id="left-button-12"
                       tabindex="-1"
@@ -1970,30 +1897,29 @@
                       12
                     </button>
                   </td>
-                  <td class="content gr-diff left no-intraline-info remove">
+                  <td class="content left no-intraline-info remove">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-12"
                     ></div>
                   </td>
-                  <td class="blankLineNum gr-diff right"></td>
-                  <td class="blank gr-diff no-intraline-info right">
-                    <div class="contentText gr-diff" data-side="right"></div>
+                  <td class="blankLineNum right"></td>
+                  <td class="blank no-intraline-info right">
+                    <div class="contentText" data-side="right"></div>
                   </td>
                 </tr>
                 <tr
                   aria-labelledby="left-button-13 left-content-13"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="remove"
                   right-type="blank"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="13"></td>
-                  <td class="gr-diff left lineNum" data-value="13">
+                  <td class="left lineNum" data-value="13">
                     <button
                       aria-label="13 removed"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="13"
                       id="left-button-13"
                       tabindex="-1"
@@ -2001,32 +1927,31 @@
                       13
                     </button>
                   </td>
-                  <td class="content gr-diff left no-intraline-info remove">
+                  <td class="content left no-intraline-info remove">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-13"
                     ></div>
                   </td>
-                  <td class="blankLineNum gr-diff right"></td>
-                  <td class="blank gr-diff no-intraline-info right">
-                    <div class="contentText gr-diff" data-side="right"></div>
+                  <td class="blankLineNum right"></td>
+                  <td class="blank no-intraline-info right">
+                    <div class="contentText" data-side="right"></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+              <tbody class="delta ignoredWhitespaceOnly section">
                 <tr
                   aria-labelledby="left-button-14 left-content-14 right-button-13 right-content-13"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="remove"
                   right-type="add"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="14"></td>
-                  <td class="gr-diff left lineNum" data-value="14">
+                  <td class="left lineNum" data-value="14">
                     <button
                       aria-label="14 removed"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="14"
                       id="left-button-14"
                       tabindex="-1"
@@ -2034,17 +1959,17 @@
                       14
                     </button>
                   </td>
-                  <td class="content gr-diff left no-intraline-info remove">
+                  <td class="content left no-intraline-info remove">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-14"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="13">
+                  <td class="lineNum right" data-value="13">
                     <button
                       aria-label="13 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="13"
                       id="right-button-13"
                       tabindex="-1"
@@ -2052,9 +1977,9 @@
                       13
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-13"
                     ></div>
@@ -2062,16 +1987,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-15 left-content-15 right-button-14 right-content-14"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="remove"
                   right-type="add"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="15"></td>
-                  <td class="gr-diff left lineNum" data-value="15">
+                  <td class="left lineNum" data-value="15">
                     <button
                       aria-label="15 removed"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="15"
                       id="left-button-15"
                       tabindex="-1"
@@ -2079,17 +2003,17 @@
                       15
                     </button>
                   </td>
-                  <td class="content gr-diff left no-intraline-info remove">
+                  <td class="content left no-intraline-info remove">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-15"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="14">
+                  <td class="lineNum right" data-value="14">
                     <button
                       aria-label="14 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="14"
                       id="right-button-14"
                       tabindex="-1"
@@ -2097,28 +2021,27 @@
                       14
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-14"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="delta gr-diff section">
+              <tbody class="delta section">
                 <tr
                   aria-labelledby="left-button-16 left-content-16 right-button-15 right-content-15"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="remove"
                   right-type="add"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="16"></td>
-                  <td class="gr-diff left lineNum" data-value="16">
+                  <td class="left lineNum" data-value="16">
                     <button
                       aria-label="16 removed"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="16"
                       id="left-button-16"
                       tabindex="-1"
@@ -2126,17 +2049,17 @@
                       16
                     </button>
                   </td>
-                  <td class="content gr-diff left remove">
+                  <td class="content left remove">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-16"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="15">
+                  <td class="lineNum right" data-value="15">
                     <button
                       aria-label="15 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="15"
                       id="right-button-15"
                       tabindex="-1"
@@ -2144,28 +2067,27 @@
                       15
                     </button>
                   </td>
-                  <td class="add content gr-diff right">
+                  <td class="add content right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-15"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-17 left-content-17 right-button-16 right-content-16"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="17"></td>
-                  <td class="gr-diff left lineNum" data-value="17">
+                  <td class="left lineNum" data-value="17">
                     <button
                       aria-label="17 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="17"
                       id="left-button-17"
                       tabindex="-1"
@@ -2173,17 +2095,17 @@
                       17
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-17"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="16">
+                  <td class="lineNum right" data-value="16">
                     <button
                       aria-label="16 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="16"
                       id="right-button-16"
                       tabindex="-1"
@@ -2191,9 +2113,9 @@
                       16
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-16"
                     ></div>
@@ -2201,16 +2123,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-18 left-content-18 right-button-17 right-content-17"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="18"></td>
-                  <td class="gr-diff left lineNum" data-value="18">
+                  <td class="left lineNum" data-value="18">
                     <button
                       aria-label="18 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="18"
                       id="left-button-18"
                       tabindex="-1"
@@ -2218,17 +2139,17 @@
                       18
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-18"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="17">
+                  <td class="lineNum right" data-value="17">
                     <button
                       aria-label="17 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="17"
                       id="right-button-17"
                       tabindex="-1"
@@ -2236,9 +2157,9 @@
                       17
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-17"
                     ></div>
@@ -2246,16 +2167,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-19 left-content-19 right-button-18 right-content-18"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="19"></td>
-                  <td class="gr-diff left lineNum" data-value="19">
+                  <td class="left lineNum" data-value="19">
                     <button
                       aria-label="19 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="19"
                       id="left-button-19"
                       tabindex="-1"
@@ -2263,17 +2183,17 @@
                       19
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-19"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="18">
+                  <td class="lineNum right" data-value="18">
                     <button
                       aria-label="18 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="18"
                       id="right-button-18"
                       tabindex="-1"
@@ -2281,61 +2201,56 @@
                       18
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-18"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="contextControl gr-diff section">
+              <tbody class="contextControl section">
                 <tr
-                  class="above contextBackground gr-diff side-by-side"
+                  class="above contextBackground side-by-side"
                   left-type="contextControl"
                   right-type="contextControl"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="contextLineNum gr-diff"></td>
-                  <td class="gr-diff"></td>
-                  <td class="contextLineNum gr-diff"></td>
-                  <td class="gr-diff"></td>
+                  <td class="contextLineNum"></td>
+                  <td></td>
+                  <td class="contextLineNum"></td>
+                  <td></td>
                 </tr>
-                <tr class="dividerRow gr-diff show-both">
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="dividerCell gr-diff" colspan="4">
+                <tr class="dividerRow show-both">
+                  <td class="dividerCell" colspan="4">
                     <gr-context-controls
-                      class="gr-diff"
                       showconfig="both"
                     ></gr-context-controls>
                   </td>
                 </tr>
                 <tr
-                  class="below contextBackground gr-diff side-by-side"
+                  class="below contextBackground side-by-side"
                   left-type="contextControl"
                   right-type="contextControl"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="contextLineNum gr-diff"></td>
-                  <td class="gr-diff"></td>
-                  <td class="contextLineNum gr-diff"></td>
-                  <td class="gr-diff"></td>
+                  <td class="contextLineNum"></td>
+                  <td></td>
+                  <td class="contextLineNum"></td>
+                  <td></td>
                 </tr>
               </tbody>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-38 left-content-38 right-button-37 right-content-37"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="38"></td>
-                  <td class="gr-diff left lineNum" data-value="38">
+                  <td class="left lineNum" data-value="38">
                     <button
                       aria-label="38 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="38"
                       id="left-button-38"
                       tabindex="-1"
@@ -2343,17 +2258,17 @@
                       38
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-38"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="37">
+                  <td class="lineNum right" data-value="37">
                     <button
                       aria-label="37 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="37"
                       id="right-button-37"
                       tabindex="-1"
@@ -2361,9 +2276,9 @@
                       37
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-37"
                     ></div>
@@ -2371,16 +2286,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-39 left-content-39 right-button-38 right-content-38"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="39"></td>
-                  <td class="gr-diff left lineNum" data-value="39">
+                  <td class="left lineNum" data-value="39">
                     <button
                       aria-label="39 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="39"
                       id="left-button-39"
                       tabindex="-1"
@@ -2388,17 +2302,17 @@
                       39
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-39"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="38">
+                  <td class="lineNum right" data-value="38">
                     <button
                       aria-label="38 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="38"
                       id="right-button-38"
                       tabindex="-1"
@@ -2406,9 +2320,9 @@
                       38
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-38"
                     ></div>
@@ -2416,16 +2330,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-40 left-content-40 right-button-39 right-content-39"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="40"></td>
-                  <td class="gr-diff left lineNum" data-value="40">
+                  <td class="left lineNum" data-value="40">
                     <button
                       aria-label="40 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="40"
                       id="left-button-40"
                       tabindex="-1"
@@ -2433,17 +2346,17 @@
                       40
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-40"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="39">
+                  <td class="lineNum right" data-value="39">
                     <button
                       aria-label="39 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="39"
                       id="right-button-39"
                       tabindex="-1"
@@ -2451,32 +2364,31 @@
                       39
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-39"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="delta gr-diff section total">
+              <tbody class="delta section total">
                 <tr
                   aria-labelledby="right-button-40 right-content-40"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="blank"
                   right-type="add"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="blank gr-diff left no-intraline-info">
-                    <div class="contentText gr-diff" data-side="left"></div>
+                  <td class="blankLineNum left"></td>
+                  <td class="blank left no-intraline-info">
+                    <div class="contentText" data-side="left"></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="40">
+                  <td class="lineNum right" data-value="40">
                     <button
                       aria-label="40 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="40"
                       id="right-button-40"
                       tabindex="-1"
@@ -2484,9 +2396,9 @@
                       40
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-40"
                     ></div>
@@ -2494,20 +2406,19 @@
                 </tr>
                 <tr
                   aria-labelledby="right-button-41 right-content-41"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="blank"
                   right-type="add"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="blank gr-diff left no-intraline-info">
-                    <div class="contentText gr-diff" data-side="left"></div>
+                  <td class="blankLineNum left"></td>
+                  <td class="blank left no-intraline-info">
+                    <div class="contentText" data-side="left"></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="41">
+                  <td class="lineNum right" data-value="41">
                     <button
                       aria-label="41 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="41"
                       id="right-button-41"
                       tabindex="-1"
@@ -2515,9 +2426,9 @@
                       41
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-41"
                     ></div>
@@ -2525,20 +2436,19 @@
                 </tr>
                 <tr
                   aria-labelledby="right-button-42 right-content-42"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="blank"
                   right-type="add"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="blank gr-diff left no-intraline-info">
-                    <div class="contentText gr-diff" data-side="left"></div>
+                  <td class="blankLineNum left"></td>
+                  <td class="blank left no-intraline-info">
+                    <div class="contentText" data-side="left"></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="42">
+                  <td class="lineNum right" data-value="42">
                     <button
                       aria-label="42 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="42"
                       id="right-button-42"
                       tabindex="-1"
@@ -2546,9 +2456,9 @@
                       42
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-42"
                     ></div>
@@ -2556,20 +2466,19 @@
                 </tr>
                 <tr
                   aria-labelledby="right-button-43 right-content-43"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="blank"
                   right-type="add"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="0"></td>
-                  <td class="blankLineNum gr-diff left"></td>
-                  <td class="blank gr-diff left no-intraline-info">
-                    <div class="contentText gr-diff" data-side="left"></div>
+                  <td class="blankLineNum left"></td>
+                  <td class="blank left no-intraline-info">
+                    <div class="contentText" data-side="left"></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="43">
+                  <td class="lineNum right" data-value="43">
                     <button
                       aria-label="43 added"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="43"
                       id="right-button-43"
                       tabindex="-1"
@@ -2577,28 +2486,27 @@
                       43
                     </button>
                   </td>
-                  <td class="add content gr-diff no-intraline-info right">
+                  <td class="add content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-43"
                     ></div>
                   </td>
                 </tr>
               </tbody>
-              <tbody class="both gr-diff section">
+              <tbody class="both section">
                 <tr
                   aria-labelledby="left-button-41 left-content-41 right-button-44 right-content-44"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="41"></td>
-                  <td class="gr-diff left lineNum" data-value="41">
+                  <td class="left lineNum" data-value="41">
                     <button
                       aria-label="41 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="41"
                       id="left-button-41"
                       tabindex="-1"
@@ -2606,17 +2514,17 @@
                       41
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-41"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="44">
+                  <td class="lineNum right" data-value="44">
                     <button
                       aria-label="44 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="44"
                       id="right-button-44"
                       tabindex="-1"
@@ -2624,9 +2532,9 @@
                       44
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-44"
                     ></div>
@@ -2634,16 +2542,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-42 left-content-42 right-button-45 right-content-45"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="42"></td>
-                  <td class="gr-diff left lineNum" data-value="42">
+                  <td class="left lineNum" data-value="42">
                     <button
                       aria-label="42 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="42"
                       id="left-button-42"
                       tabindex="-1"
@@ -2651,17 +2558,17 @@
                       42
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-42"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="45">
+                  <td class="lineNum right" data-value="45">
                     <button
                       aria-label="45 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="45"
                       id="right-button-45"
                       tabindex="-1"
@@ -2669,9 +2576,9 @@
                       45
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-45"
                     ></div>
@@ -2679,16 +2586,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-43 left-content-43 right-button-46 right-content-46"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="43"></td>
-                  <td class="gr-diff left lineNum" data-value="43">
+                  <td class="left lineNum" data-value="43">
                     <button
                       aria-label="43 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="43"
                       id="left-button-43"
                       tabindex="-1"
@@ -2696,17 +2602,17 @@
                       43
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-43"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="46">
+                  <td class="lineNum right" data-value="46">
                     <button
                       aria-label="46 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="46"
                       id="right-button-46"
                       tabindex="-1"
@@ -2714,9 +2620,9 @@
                       46
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-46"
                     ></div>
@@ -2724,16 +2630,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-44 left-content-44 right-button-47 right-content-47"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="44"></td>
-                  <td class="gr-diff left lineNum" data-value="44">
+                  <td class="left lineNum" data-value="44">
                     <button
                       aria-label="44 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="44"
                       id="left-button-44"
                       tabindex="-1"
@@ -2741,17 +2646,17 @@
                       44
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-44"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="47">
+                  <td class="lineNum right" data-value="47">
                     <button
                       aria-label="47 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="47"
                       id="right-button-47"
                       tabindex="-1"
@@ -2759,9 +2664,9 @@
                       47
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-47"
                     ></div>
@@ -2769,16 +2674,15 @@
                 </tr>
                 <tr
                   aria-labelledby="left-button-45 left-content-45 right-button-48 right-content-48"
-                  class="diff-row gr-diff side-by-side"
+                  class="diff-row side-by-side"
                   left-type="both"
                   right-type="both"
                   tabindex="-1"
                 >
-                  <td class="blame gr-diff" data-line-number="45"></td>
-                  <td class="gr-diff left lineNum" data-value="45">
+                  <td class="left lineNum" data-value="45">
                     <button
                       aria-label="45 unmodified"
-                      class="gr-diff left lineNumButton"
+                      class="left lineNumButton"
                       data-value="45"
                       id="left-button-45"
                       tabindex="-1"
@@ -2786,17 +2690,17 @@
                       45
                     </button>
                   </td>
-                  <td class="both content gr-diff left no-intraline-info">
+                  <td class="both content left no-intraline-info">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="left"
                       id="left-content-45"
                     ></div>
                   </td>
-                  <td class="gr-diff lineNum right" data-value="48">
+                  <td class="lineNum right" data-value="48">
                     <button
                       aria-label="48 unmodified"
-                      class="gr-diff lineNumButton right"
+                      class="lineNumButton right"
                       data-value="48"
                       id="right-button-48"
                       tabindex="-1"
@@ -2804,9 +2708,9 @@
                       48
                     </button>
                   </td>
-                  <td class="both content gr-diff no-intraline-info right">
+                  <td class="both content no-intraline-info right">
                     <div
-                      class="contentText gr-diff"
+                      class="contentText"
                       data-side="right"
                       id="right-content-48"
                     ></div>
@@ -2822,7 +2726,6 @@
             'gr-diff-section',
             'gr-diff-row',
             'gr-diff-text',
-            'gr-legacy-text',
             'slot',
           ],
         }
@@ -2859,25 +2762,23 @@
               <gr-diff-row class="left-FILE right-FILE"> </gr-diff-row>
               <table id="diffTable">
                 <colgroup>
-                  <col class="blame gr-diff" />
-                  <col class="gr-diff left" width="48" />
-                  <col class="gr-diff left" />
-                  <col class="gr-diff right" width="48" />
-                  <col class="gr-diff right" />
+                  <col class="left" width="48" />
+                  <col class="left" />
+                  <col class="right" width="48" />
+                  <col class="right" />
                 </colgroup>
-                <tbody class="both gr-diff section">
+                <tbody class="both section">
                   <tr
                     aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
-                    class="diff-row gr-diff side-by-side"
+                    class="diff-row side-by-side"
                     left-type="both"
                     right-type="both"
                     tabindex="-1"
                   >
-                    <td class="blame gr-diff" data-line-number="FILE"></td>
-                    <td class="gr-diff left lineNum" data-value="FILE">
+                    <td class="left lineNum" data-value="FILE">
                       <button
                         aria-label="Add file comment"
-                        class="gr-diff left lineNumButton"
+                        class="left lineNumButton"
                         data-value="FILE"
                         id="left-button-FILE"
                         tabindex="-1"
@@ -2885,13 +2786,11 @@
                         FILE
                       </button>
                     </td>
-                    <td
-                      class="both content file gr-diff left no-intraline-info"
-                    ></td>
-                    <td class="gr-diff lineNum right" data-value="FILE">
+                    <td class="both content file left no-intraline-info"></td>
+                    <td class="lineNum right" data-value="FILE">
                       <button
                         aria-label="Add file comment"
-                        class="gr-diff lineNumButton right"
+                        class="lineNumButton right"
                         data-value="FILE"
                         id="right-button-FILE"
                         tabindex="-1"
@@ -2899,14 +2798,12 @@
                         FILE
                       </button>
                     </td>
-                    <td
-                      class="both content file gr-diff no-intraline-info right"
-                    ></td>
+                    <td class="both content file no-intraline-info right"></td>
                   </tr>
                 </tbody>
-                <tbody class="binary-diff gr-diff">
-                  <tr class="gr-diff">
-                    <td class="gr-diff" colspan="5">
+                <tbody class="binary-diff">
+                  <tr>
+                    <td colspan="4">
                       <span> Difference in binary files </span>
                     </td>
                   </tr>
@@ -2982,32 +2879,32 @@
         assert.lightDom.equal(
           imageDiffSection,
           /* HTML */ `
-            <tbody class="gr-diff image-diff">
-              <tr class="gr-diff">
-                <td class="blank gr-diff left lineNum"></td>
-                <td class="gr-diff left">
+            <tbody class="image-diff">
+              <tr>
+                <td class="blank left lineNum"></td>
+                <td class="left">
                   <img
                     class="gr-diff left"
                     src="data:image/bmp;base64,${mockFile1.body}"
                   />
                 </td>
-                <td class="blank gr-diff lineNum right"></td>
-                <td class="gr-diff right">
+                <td class="blank lineNum right"></td>
+                <td class="right">
                   <img
                     class="gr-diff right"
                     src="data:image/bmp;base64,${mockFile2.body}"
                   />
                 </td>
               </tr>
-              <tr class="gr-diff">
-                <td class="blank gr-diff left lineNum"></td>
-                <td class="gr-diff left">
+              <tr>
+                <td class="blank left lineNum"></td>
+                <td class="left">
                   <label class="gr-diff">
                     <span class="gr-diff label"> 1×1 image/bmp </span>
                   </label>
                 </td>
-                <td class="blank gr-diff lineNum right"></td>
-                <td class="gr-diff right">
+                <td class="blank lineNum right"></td>
+                <td class="right">
                   <label class="gr-diff">
                     <span class="gr-diff label"> 1×1 image/bmp </span>
                   </label>
@@ -3020,8 +2917,8 @@
         assert.dom.equal(
           endpoint,
           /* HTML */ `
-            <tbody class="gr-diff endpoint">
-              <tr class="gr-diff">
+            <tbody class="endpoint">
+              <tr>
                 <gr-endpoint-decorator class="gr-diff" name="image-diff">
                   <gr-endpoint-param class="gr-diff" name="baseImage">
                   </gr-endpoint-param>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index f406215..c5366e7 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -9,10 +9,12 @@
 import {isDefined} from '../../../types/types';
 
 export enum GrDiffGroupType {
-  /** Unchanged context. */
+  /** A group of unchanged diff lines. */
   BOTH = 'both',
 
-  /** A widget used to show more context. */
+  /**
+   * A context control group "hides" other diff groups in its `contextGroups` field.
+   */
   CONTEXT_CONTROL = 'contextControl',
 
   /** Added, removed or modified chunk. */
@@ -216,7 +218,13 @@
   };
 }
 
-/** A chunk of the diff that should be rendered together. */
+/**
+ * A chunk of the diff that should be rendered together. Typically corresponds
+ * to a gr-diff-section. It mostly just contains an array of diff `lines`.
+ *
+ * A group of type CONTEXT_CONTROL does not contain any lines directly, but
+ * "hides" other groups in `contextGroups`, which the user can expand.
+ */
 export class GrDiffGroup {
   constructor(
     options:
@@ -322,6 +330,12 @@
 
   readonly removes: GrDiffLine[] = [];
 
+  /**
+   * Only set, iff type is CONTEXT_CONTROL.
+   *
+   * A CONTEXT_CONTROL group "hides" other groups that the user may expand.
+   * This field contains those hidden groups.
+   */
   readonly contextGroups: GrDiffGroup[] = [];
 
   readonly skip?: number;
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 95b2f4e..8b333e6 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
@@ -5,14 +5,8 @@
  */
 import {css} from 'lit';
 
+// Styles related to the top-level <gr-diff> component.
 export const grDiffStyles = css`
-  :host(.disable-context-control-buttons) .section {
-    border-right: none;
-  }
-  :host(.hide-line-length-indicator) .full-width td.content .contentText {
-    background-image: none;
-  }
-
   :host {
     font-family: var(--monospace-font-family, ''), 'Roboto Mono';
     font-size: var(--font-size, var(--font-size-code, 12px));
@@ -22,77 +16,714 @@
     );
   }
 
-  .thread-group {
-    display: block;
-    max-width: var(--content-width, 80ch);
-    white-space: normal;
-    background-color: var(--diff-blank-background-color);
+  gr-diff-image-new,
+  gr-diff-image-old,
+  gr-diff-section,
+  gr-context-controls-section,
+  gr-diff-row {
+    display: contents;
   }
-  .diffContainer {
+`;
+
+// Styles related to the <gr-diff-element> component.
+export const grDiffElementStyles = css`
+  gr-diff-element div.diffContainer {
     max-width: var(--diff-max-width, none);
     font-family: var(--monospace-font-family);
   }
-  table {
+  gr-diff-element table {
     border-collapse: collapse;
     table-layout: fixed;
   }
-  td.lineNum,
-  td.blankLineNum {
-    /* Enforces background whenever lines wrap */
-    background-color: var(--diff-blank-background-color);
+  gr-diff-element table.responsive {
+    width: 100%;
   }
-
-  /* Provides the option to add side borders (left and right) to the line
-     number column. */
-  td.lineNum,
-  td.blankLineNum,
-  td.moveControlsLineNumCol,
-  td.contextLineNum {
-    box-shadow: var(--line-number-box-shadow, unset);
+  gr-diff-element div#diffHeader {
+    background-color: var(--table-header-background-color);
+    border-bottom: 1px solid var(--border-color);
+    color: var(--link-color);
+    padding: var(--spacing-m) 0 var(--spacing-m) 48px;
   }
+  gr-diff-element table#diffTable:focus {
+    outline: none;
+  }
+  gr-diff-element div#loadingError,
+  gr-diff-element div#sizeWarning {
+    display: block;
+    margin: var(--spacing-l) auto;
+    max-width: 60em;
+    text-align: center;
+  }
+  gr-diff-element div#loadingError {
+    color: var(--error-text-color);
+  }
+  gr-diff-element div#sizeWarning gr-button {
+    margin: var(--spacing-l);
+  }
+  gr-diff-element div.newlineWarning {
+    color: var(--deemphasized-text-color);
+    text-align: center;
+  }
+  gr-diff-element div.newlineWarning.hidden {
+    display: none;
+  }
+  gr-diff-element div.whitespace-change-only-message {
+    background-color: var(--diff-context-control-background-color);
+    border: 1px solid var(--diff-context-control-border-color);
+    text-align: center;
+  }
+  gr-diff-element {
+    /* for gr-selection-action-box positioning */
+    position: relative;
+  }
+  gr-diff-element gr-selection-action-box {
+    /* Needs z-index to appear above wrapped content, since it's inserted
+       into DOM before it. */
+    z-index: 120;
+  }
+`;
 
+// Styles related to the <gr-diff-section> component.
+export const grDiffSectionStyles = css`
+  :host(.disable-context-control-buttons) gr-diff-section tbody.section {
+    border-right: none;
+  }
   /* Context controls break up the table visually, so we set the right
      border on individual sections to leave a gap for the divider.
 
      Also taken into account for max-width calculations in SHRINK_ONLY mode
      (check GrDiff.updatePreferenceStyles). */
-  .section {
+  gr-diff-section tbody.section {
     border-right: 1px solid var(--border-color);
   }
-  .section.contextControl {
+  gr-diff-section tbody.section.contextControl {
     /* Divider inside this section must not have border; we set borders on
        the padding rows below. */
     border-right-width: 0;
   }
+  gr-diff-section tr.moveControls td.moveHeader a {
+    color: inherit;
+  }
+  gr-diff-section tbody.contextControl {
+    display: table-row-group;
+    background-color: transparent;
+    border: none;
+    --divider-height: var(--spacing-s);
+    --divider-border: 1px;
+  }
+  /* TODO: Is this still used? */
+  gr-diff-section tbody.contextControl gr-button gr-icon {
+    /* should match line-height of gr-button */
+    font-size: var(--line-height-mono, 18px);
+  }
+  gr-diff-section tbody.contextControl td:not(.lineNumButton) {
+    text-align: center;
+  }
+  /* Option to add side borders (left and right) to the line number column. */
+  gr-diff-section tr.moveControls td.moveControlsLineNumCol {
+    box-shadow: var(--line-number-box-shadow, unset);
+  }
+`;
+
+// Styles related to the <gr-diff-row> component.
+export const grDiffContextControlsSectionStyles = css`
+  /* Hide the actual context control buttons */
+  :host(.disable-context-control-buttons)
+    gr-diff-section
+    tbody.contextControl
+    gr-context-controls-section
+    gr-context-controls {
+    display: none;
+  }
+  /* Maintain a small amount of padding at the edges of diff chunks */
+  :host(.disable-context-control-buttons)
+    gr-diff-section
+    tbody.contextControl
+    gr-context-controls-section
+    tr.contextBackground {
+    height: var(--spacing-s);
+    border-right: none;
+  }
+  gr-context-controls-section tr.contextBackground {
+    /* One line of background behind the context expanders which they can
+       render on top of, plus some padding. */
+    height: calc(var(--line-height-normal) + var(--spacing-s));
+  }
   /* Padding rows behind context controls. The diff is styled to be cut
      into two halves by the negative space of the divider on which the
      context control buttons are anchored. */
-  .contextBackground {
+  gr-context-controls-section tr.contextBackground {
     border-right: 1px solid var(--border-color);
   }
-  .contextBackground.above {
+  gr-context-controls-section tr.contextBackground.above {
     border-bottom: 1px solid var(--border-color);
   }
-  .contextBackground.below {
+  gr-context-controls-section tr.contextBackground.below {
     border-top: 1px solid var(--border-color);
   }
+  gr-context-controls-section tr.contextBackground td.contextLineNum {
+    color: var(--deemphasized-text-color);
+    padding: 0 var(--spacing-m);
+    text-align: right;
+  }
+  /* Option to add side borders (left and right) to the line number column. */
+  gr-context-controls-section tr.contextBackground td.contextLineNum {
+    box-shadow: var(--line-number-box-shadow, unset);
+  }
+  /* Padding rows behind context controls. Styled as a continuation of the
+     line gutters and code area. */
+  gr-context-controls-section tr.contextBackground td.contextLineNum {
+    background-color: var(--diff-blank-background-color);
+  }
+  gr-context-controls-section tr.contextBackground > td:not(.contextLineNum) {
+    background-color: var(--view-background-color);
+  }
+  gr-context-controls-section td.dividerCell {
+    vertical-align: top;
+  }
+  gr-context-controls-section tr.dividerRow.show-both td.dividerCell {
+    height: var(--divider-height);
+  }
+  gr-context-controls-section tr.dividerRow.show-above td.dividerCell {
+    height: 0;
+  }
+`;
 
-  .lineNumButton {
+// Styles related to the <gr-diff-row> component.
+export const grDiffRowStyles = css`
+  gr-diff-row td.content div.thread-group {
+    display: block;
+    max-width: var(--content-width, 80ch);
+    white-space: normal;
+    background-color: var(--diff-blank-background-color);
+  }
+  gr-diff-row tr.target-row td.blame {
+    background: var(--diff-selection-background-color);
+  }
+  gr-diff-row td.content.lost div {
+    background-color: var(--info-background);
+  }
+  gr-diff-row td.content.lost div.lost-message {
+    font-family: var(--font-family, 'Roboto');
+    font-size: var(--font-size-normal, 14px);
+    line-height: var(--line-height-normal);
+    padding: var(--spacing-s) 0;
+  }
+  gr-diff-row td.content.lost div.lost-message gr-icon {
+    padding: 0 var(--spacing-s) 0 var(--spacing-m);
+    color: var(--blue-700);
+  }
+  gr-diff-row td.blame {
+    padding: 0 var(--spacing-m);
+    white-space: pre;
+  }
+  gr-diff-row td.blame > span {
+    opacity: 0.6;
+  }
+  gr-diff-row td.blame > span.startOfRange {
+    opacity: 1;
+  }
+  gr-diff-row td.blame .blameDate {
+    font-family: var(--monospace-font-family);
+    color: var(--link-color);
+    text-decoration: none;
+  }
+  gr-diff-row td.content div.contentText gr-diff-text:empty:after,
+  gr-diff-row td.content div.contentText:empty:after {
+    /* Newline, to ensure empty lines are one line-height tall. */
+    content: '\\A';
+  }
+  /* Option to add side borders (left and right) to the line number column. */
+  gr-diff-row td.lineNum,
+  gr-diff-row td.blankLineNum {
+    box-shadow: var(--line-number-box-shadow, unset);
+  }
+  gr-diff-row td.lineNum,
+  gr-diff-row td.blankLineNum {
+    /* Enforces background whenever lines wrap */
+    background-color: var(--diff-blank-background-color);
+  }
+  gr-diff-row td.lineNum button.lineNumButton {
     display: block;
     width: 100%;
     height: 100%;
     background-color: var(--diff-blank-background-color);
     box-shadow: var(--line-number-box-shadow, unset);
   }
-  td.lineNum {
+  gr-diff-row td.lineNum {
     vertical-align: top;
   }
-
   /* The only way to focus this (clicking) will apply our own focus
      styling, so this default styling is not needed and distracting. */
-  .lineNumButton:focus {
+  gr-diff-row td.lineNum button.lineNumButton:focus {
     outline: none;
   }
+  gr-diff-row tr.diff-row {
+    outline: none;
+  }
+  gr-diff-row
+    tr.diff-row.target-row.target-side-left
+    td.lineNum
+    button.lineNumButton.left,
+  gr-diff-row
+    tr.diff-row.target-row.target-side-right
+    td.lineNum
+    button.lineNumButton.right,
+  gr-diff-row tr.diff-row.target-row.unified td.lineNum button.lineNumButton {
+    color: var(--primary-text-color);
+  }
+  /* Preparing selected line cells with position relative so it allows a
+     positioned overlay with 'position: absolute'. */
+  gr-diff-row tr.target-row td {
+    position: relative;
+  }
+  /* Defines an overlay to the selected line for drawing an outline without
+     blocking user interaction (e.g. text selection). */
+  gr-diff-row tr.target-row td::before {
+    border-width: 0;
+    border-style: solid;
+    border-color: var(--focused-line-outline-color);
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    pointer-events: none;
+    user-select: none;
+    content: ' ';
+  }
+  /* The outline for the selected content cell should be the same in all
+     cases. */
+  gr-diff-row tr.target-row.target-side-left td.left.content::before,
+  gr-diff-row tr.target-row.target-side-right td.right.content::before,
+  gr-diff-row tr.unified.target-row td.content::before {
+    border-width: 1px 1px 1px 0;
+  }
+  /* The outline for the sign cell should be always be contiguous
+     top/bottom. */
+  gr-diff-row tr.target-row.target-side-left td.left.sign::before,
+  gr-diff-row tr.target-row.target-side-right td.right.sign::before {
+    border-width: 1px 0;
+  }
+  /* For side-by-side we need to select the correct line number to
+     "visually close" the outline. */
+  gr-diff-row
+    tr.side-by-side.target-row.target-side-left
+    td.left.lineNum::before,
+  gr-diff-row
+    tr.side-by-side.target-row.target-side-right
+    td.right.lineNum::before {
+    border-width: 1px 0 1px 1px;
+  }
+  /* For unified diff we always start the overlay from the left cell. */
+  gr-diff-row tr.unified.target-row td.left:not(.content)::before {
+    border-width: 1px 0 1px 1px;
+  }
+  /* For unified diff we should continue the top/bottom border in right
+     line number column. */
+  gr-diff-row tr.unified.target-row td.right:not(.content)::before {
+    border-width: 1px 0;
+  }
+  gr-diff-row td.content {
+    background-color: var(--diff-blank-background-color);
+  }
+  /* The file line, which has no contentText, add some margin before the
+     first comment. We cannot add padding the container because we only
+     want it if there is at least one comment thread, and the slotting
+     makes :empty not work as expected. */
+  gr-diff-row td.content.file slot:first-child::slotted(.comment-thread) {
+    display: block;
+    margin-top: var(--spacing-xs);
+  }
+  gr-diff-row td.content div.contentText {
+    background-color: var(--view-background-color);
+  }
+  gr-diff-row td.blank {
+    background-color: var(--diff-blank-background-color);
+  }
+  gr-diff-element div.canComment gr-diff-row td.lineNum button.lineNumButton {
+    cursor: pointer;
+  }
+  gr-diff-row td.sign {
+    min-width: 1ch;
+    width: 1ch;
+    background-color: var(--view-background-color);
+  }
+  gr-diff-row td.sign.blank {
+    background-color: var(--diff-blank-background-color);
+  }
+  gr-diff-row td.content {
+    /* Set min width since setting width on table cells still allows them
+       to shrink. Do not set max width because CJK
+       (Chinese-Japanese-Korean) glyphs have variable width. */
+    min-width: var(--content-width, 80ch);
+    width: var(--content-width, 80ch);
+  }
+  /* If there are no intraline info, consider everything changed */
+  gr-diff-row td.content.add div.contentText .intraline,
+  gr-diff-row td.content.add.no-intraline-info div.contentText,
+  gr-diff-row td.sign.add.no-intraline-info,
+  gr-diff-section tbody.delta.total gr-diff-row td.content.add div.contentText {
+    background-color: var(--dark-add-highlight-color);
+  }
+  gr-diff-row td.content.add div.contentText,
+  gr-diff-row td.sign.add {
+    background-color: var(--light-add-highlight-color);
+  }
+  /* If there are no intraline info, consider everything changed */
+  gr-diff-row td.content.remove div.contentText .intraline,
+  gr-diff-row td.content.remove.no-intraline-info div.contentText,
+  gr-diff-section
+    tbody.delta.total
+    gr-diff-row
+    td.content.remove
+    div.contentText,
+  gr-diff-row td.sign.remove.no-intraline-info {
+    background-color: var(--dark-remove-highlight-color);
+  }
+  gr-diff-row td.content.remove div.contentText,
+  gr-diff-row td.sign.remove {
+    background-color: var(--light-remove-highlight-color);
+  }
+  gr-diff-element table.responsive gr-diff-row td.content div.contentText {
+    white-space: break-spaces;
+    word-break: break-all;
+  }
+  gr-diff-row td.lineNum button.lineNumButton,
+  td.content {
+    vertical-align: top;
+    white-space: pre;
+  }
+  gr-diff-row td.lineNum button.lineNumButton {
+    color: var(--deemphasized-text-color);
+    padding: 0 var(--spacing-m);
+    text-align: right;
+  }
+  gr-diff-element table.responsive gr-diff-row td.blame {
+    overflow: hidden;
+    width: 200px;
+  }
+  /** Support the line length indicator **/
+  gr-diff-element table.responsive gr-diff-row td.content div.contentText {
+    /* Same strategy as in
+       https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
+       */
+    background-image: linear-gradient(
+      var(--line-length-indicator-color),
+      var(--line-length-indicator-color)
+    );
+    background-size: 1px 100%;
+    background-position: var(--line-limit-marker) 0;
+    background-repeat: no-repeat;
+  }
+  gr-diff-row td.lineNum.COVERED button.lineNumButton {
+    color: var(
+      --coverage-covered-line-num-color,
+      var(--deemphasized-text-color)
+    );
+    background-color: var(--coverage-covered, #e0f2f1);
+  }
+  gr-diff-row td.lineNum.NOT_COVERED button.lineNumButton {
+    color: var(
+      --coverage-covered-line-num-color,
+      var(--deemphasized-text-color)
+    );
+    background-color: var(--coverage-not-covered, #ffd1a4);
+  }
+  gr-diff-row td.lineNum.PARTIALLY_COVERED button.lineNumButton {
+    color: var(
+      --coverage-covered-line-num-color,
+      var(--deemphasized-text-color)
+    );
+    background: linear-gradient(
+      to right bottom,
+      var(--coverage-not-covered, #ffd1a4) 0%,
+      var(--coverage-not-covered, #ffd1a4) 50%,
+      var(--coverage-covered, #e0f2f1) 50%,
+      var(--coverage-covered, #e0f2f1) 100%
+    );
+  }
+`;
+
+// Styles related to (not) highlighting ignored whitespace.
+export const grDiffIgnoredWhitespaceStyles = css`
+  gr-diff-section
+    tbody.ignoredWhitespaceOnly
+    gr-diff-row
+    td.sign.no-intraline-info,
+  gr-diff-section
+    tbody.ignoredWhitespaceOnly
+    gr-diff-row
+    td.content.add
+    div.contentText
+    .intraline,
+  gr-diff-section
+    tbody.delta.total.ignoredWhitespaceOnly
+    gr-diff-row
+    td.content.add
+    div.contentText,
+  gr-diff-section
+    tbody.ignoredWhitespaceOnly
+    gr-diff-row
+    td.content.add
+    div.contentText,
+  gr-diff-section
+    tbody.ignoredWhitespaceOnly
+    gr-diff-row
+    td.content.remove
+    div.contentText
+    .intraline,
+  gr-diff-section
+    tbody.delta.total.ignoredWhitespaceOnly
+    gr-diff-row
+    td.content.remove
+    div.contentText,
+  gr-diff-section
+    tbody.ignoredWhitespaceOnly
+    gr-diff-row
+    td.content.remove
+    div.contentText {
+    background-color: var(--view-background-color);
+  }
+`;
+
+// Styles related to highlighting moved code sections.
+export const grDiffMoveStyles = css`
+  gr-diff-section tbody.dueToMove gr-diff-row .sign.add,
+  gr-diff-section tbody.dueToMove gr-diff-row td.content.add div.contentText,
+  gr-diff-section tbody.dueToMove tr.moveControls.movedIn .sign.right,
+  gr-diff-section tbody.dueToMove tr.moveControls.movedIn td.moveHeader,
+  gr-diff-section tbody.delta.total.dueToMove td.content.add div.contentText {
+    background-color: var(--diff-moved-in-background);
+  }
+
+  gr-diff-section tbody.dueToMove.changed gr-diff-row .sign.add,
+  gr-diff-section
+    tbody.dueToMove.changed
+    gr-diff-row
+    td.content.add
+    div.contentText,
+  gr-diff-section tbody.dueToMove.changed tr.moveControls.movedIn .sign.right,
+  gr-diff-section tbody.dueToMove.changed tr.moveControls.movedIn td.moveHeader,
+  gr-diff-section
+    tbody.delta.total.dueToMove.changed
+    td.content.add
+    div.contentText {
+    background-color: var(--diff-moved-in-changed-background);
+  }
+
+  gr-diff-section tbody.dueToMove .sign.remove,
+  gr-diff-section tbody.dueToMove td.content.remove div.contentText,
+  gr-diff-section tbody.dueToMove tr.moveControls.movedOut td.moveHeader,
+  gr-diff-section tbody.dueToMove tr.moveControls.movedOut .sign.left,
+  gr-diff-section
+    tbody.delta.total.dueToMove
+    td.content.remove
+    div.contentText {
+    background-color: var(--diff-moved-out-background);
+  }
+
+  gr-diff-section tbody.delta.dueToMove tr.movedIn td.moveHeader {
+    --gr-range-header-color: var(--diff-moved-in-label-color);
+  }
+  gr-diff-section tbody.delta.dueToMove.changed tr.movedIn td.moveHeader {
+    --gr-range-header-color: var(--diff-moved-in-changed-label-color);
+  }
+  gr-diff-section tbody.delta.dueToMove tr.movedOut td.moveHeader {
+    --gr-range-header-color: var(--diff-moved-out-label-color);
+  }
+`;
+
+// Styles related to highlighting rebased code sections.
+export const grDiffRebaseStyles = css`
+  gr-diff-section
+    tbody.dueToRebase
+    gr-diff-row
+    td.content.add
+    div.contentText
+    .intraline,
+  gr-diff-section
+    tbody.delta.total.dueToRebase
+    gr-diff-row
+    td.content.add
+    div.contentText {
+    background-color: var(--dark-rebased-add-highlight-color);
+  }
+  gr-diff-section tbody.dueToRebase gr-diff-row td.content.add div.contentText {
+    background-color: var(--light-rebased-add-highlight-color);
+  }
+  gr-diff-section
+    tbody.dueToRebase
+    gr-diff-row
+    td.content.remove
+    div.contentText
+    .intraline,
+  gr-diff-section
+    tbody.delta.total.dueToRebase
+    gr-diff-row
+    td.content.remove
+    div.contentText {
+    background-color: var(--dark-rebased-remove-highlight-color);
+  }
+  gr-diff-section
+    tbody.dueToRebase
+    gr-diff-row
+    td.content.remove
+    div.contentText {
+    background-color: var(--light-rebased-remove-highlight-color);
+  }
+`;
+
+// Styles related to selecting code in gr-diff.
+export const grDiffSelectionStyles = css`
+  /* by default do not allow selecting anything */
+  gr-context-controls-section .contextLineNum,
+  gr-diff-row td.lineNum button.lineNumButton,
+  gr-diff-row tr.diff-row,
+  gr-diff-row td.content,
+  gr-diff-section tbody.contextControl,
+  gr-diff-row td.blame,
+  #diffHeader {
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+  }
+  /* selected-left */
+  gr-diff-element.selected-left:not(.selected-comment)
+    gr-diff-row
+    tr.side-by-side
+    td.left
+    + td.content
+    div.contentText,
+  gr-diff-element.selected-left:not(.selected-comment)
+    gr-diff-row
+    tr.unified
+    td.left.lineNum
+    ~ td.content:not(.both)
+    div.contentText {
+    -webkit-user-select: text;
+    -moz-user-select: text;
+    -ms-user-select: text;
+    user-select: text;
+  }
+  /* selected-right */
+  gr-diff-element.selected-right:not(.selected-comment)
+    gr-diff-row
+    tr.side-by-side
+    td.right
+    + td.content
+    div.contentText,
+  gr-diff-element.selected-right:not(.selected-comment)
+    gr-diff-row
+    tr.unified
+    td.right.lineNum
+    ~ td.content
+    div.contentText {
+    -webkit-user-select: text;
+    -moz-user-select: text;
+    -ms-user-select: text;
+    user-select: text;
+  }
+  /* selected-comment */
+  gr-diff-element.selected-left.selected-comment
+    gr-diff-row
+    tr.side-by-side
+    td.left
+    + td.content
+    div.thread-group
+    .message,
+  gr-diff-element.selected-left.selected-comment
+    ::slotted(.comment-thread[diff-side='left']),
+  gr-diff-element.selected-right.selected-comment
+    gr-diff-row
+    tr.side-by-side
+    td.right
+    + td.content
+    div.thread-group
+    .message
+    :not(.collapsedContent),
+  gr-diff-element.selected-right.selected-comment
+    ::slotted(.comment-thread[diff-side='right']),
+  gr-diff-element.selected-comment
+    gr-diff-row
+    tr.unified
+    td.content
+    div.thread-group
+    .message
+    :not(.collapsedContent) {
+    -webkit-user-select: text;
+    -moz-user-select: text;
+    -ms-user-select: text;
+    user-select: text;
+  }
+  /* selected-blame */
+  gr-diff-element.selected-blame gr-diff-row td.blame {
+    -webkit-user-select: text;
+    -moz-user-select: text;
+    -ms-user-select: text;
+    user-select: text;
+  }
+`;
+
+// Styles related to the <gr-diff-text> component.
+export const grDiffTextStyles = css`
+  gr-diff-text .token-highlight {
+    background-color: var(--token-highlighting-color, #fffd54);
+  }
+  /* Describes two states of semantic tokens: whenever a token has a
+     definition that can be navigated to (navigable) and whenever
+     the token is actually clickable to perform this navigation. */
+  gr-diff-text .semantic-token.navigable {
+    text-decoration-style: dotted;
+    text-decoration-line: underline;
+  }
+  gr-diff-text .semantic-token.navigable.clickable {
+    text-decoration-style: solid;
+    cursor: pointer;
+  }
+  gr-diff-text .br:after {
+    /* Line feed */
+    content: '\\A';
+  }
+  gr-diff-text .tab {
+    display: inline-block;
+  }
+  gr-diff-text .tab-indicator:before {
+    color: var(--diff-tab-indicator-color);
+    /* >> character */
+    content: '\\00BB';
+    position: absolute;
+  }
+  gr-diff-text .special-char-indicator {
+    /* spacing so elements don't collide */
+    padding-right: var(--spacing-m);
+  }
+  gr-diff-text .special-char-indicator:before {
+    color: var(--diff-tab-indicator-color);
+    content: '•';
+    position: absolute;
+  }
+  gr-diff-text .special-char-warning {
+    /* spacing so elements don't collide */
+    padding-right: var(--spacing-m);
+  }
+  gr-diff-text .special-char-warning:before {
+    color: var(--warning-foreground);
+    content: '!';
+    position: absolute;
+  }
+  /* Is defined after other background-colors, such that this
+     rule wins in case of same specificity. */
+  td.content div.contentText gr-diff-text .trailing-whitespace,
+  td.content div.contentText gr-diff-text .trailing-whitespace .intraline {
+    border-radius: var(--border-radius, 4px);
+    background-color: var(--diff-trailing-whitespace-indicator);
+  }
+`;
+
+// Styles related to the image diffs.
+export const grDiffImageStyles = css`
   gr-image-viewer {
     width: 100%;
     height: 100%;
@@ -106,555 +737,31 @@
        are almost invisible. */
     --primary-background-color: var(--background-color-secondary);
   }
-  .image-diff .gr-diff {
+  tbody.image-diff .gr-diff {
     text-align: center;
   }
-  .image-diff img {
+  tbody.image-diff img {
     box-shadow: var(--elevation-level-1);
     max-width: 50em;
   }
-  .image-diff .right.lineNumButton {
+  tbody.image-diff button.right.lineNumButton {
     border-left: 1px solid var(--border-color);
   }
-  .image-diff label {
+  tbody.image-diff label {
     font-family: var(--font-family);
     font-style: italic;
   }
-  tbody.binary-diff td {
+  .image-diff td.content {
+    background-color: var(--diff-blank-background-color);
+  }
+`;
+
+// Styles related to the binary diffs.
+export const grDiffBinaryStyles = css`
+  gr-diff-element tbody.binary-diff td {
     font-family: var(--font-family);
     font-style: italic;
     text-align: center;
     padding: var(--spacing-s) 0;
   }
-  .diff-row {
-    outline: none;
-    user-select: none;
-  }
-  .diff-row.target-row.target-side-left .lineNumButton.left,
-  .diff-row.target-row.target-side-right .lineNumButton.right,
-  .diff-row.target-row.unified .lineNumButton {
-    color: var(--primary-text-color);
-  }
-
-  /* Preparing selected line cells with position relative so it allows a
-     positioned overlay with 'position: absolute'. */
-  .target-row td {
-    position: relative;
-  }
-
-  /* Defines an overlay to the selected line for drawing an outline without
-     blocking user interaction (e.g. text selection). */
-  .target-row td::before {
-    border-width: 0;
-    border-style: solid;
-    border-color: var(--focused-line-outline-color);
-    position: absolute;
-    top: 0;
-    left: 0;
-    width: 100%;
-    height: 100%;
-    pointer-events: none;
-    user-select: none;
-    content: ' ';
-  }
-
-  /* The outline for the selected content cell should be the same in all
-     cases. */
-  .target-row.target-side-left td.left.content::before,
-  .target-row.target-side-right td.right.content::before,
-  .unified.target-row td.content::before {
-    border-width: 1px 1px 1px 0;
-  }
-
-  /* The outline for the sign cell should be always be contiguous
-     top/bottom. */
-  .target-row.target-side-left td.left.sign::before,
-  .target-row.target-side-right td.right.sign::before {
-    border-width: 1px 0;
-  }
-
-  /* For side-by-side we need to select the correct line number to
-     "visually close" the outline. */
-  .side-by-side.target-row.target-side-left td.left.lineNum::before,
-  .side-by-side.target-row.target-side-right td.right.lineNum::before {
-    border-width: 1px 0 1px 1px;
-  }
-
-  /* For unified diff we always start the overlay from the left cell. */
-  .unified.target-row td.left:not(.content)::before {
-    border-width: 1px 0 1px 1px;
-  }
-
-  /* For unified diff we should continue the top/bottom border in right
-     line number column. */
-  .unified.target-row td.right:not(.content)::before {
-    border-width: 1px 0;
-  }
-
-  .content {
-    background-color: var(--diff-blank-background-color);
-  }
-
-  /* Describes two states of semantic tokens: whenever a token has a
-     definition that can be navigated to (navigable) and whenever
-     the token is actually clickable to perform this navigation. */
-  .semantic-token.navigable {
-    text-decoration-style: dotted;
-    text-decoration-line: underline;
-  }
-  .semantic-token.navigable.clickable {
-    text-decoration-style: solid;
-    cursor: pointer;
-  }
-
-  /* The file line, which has no contentText, add some margin before the
-     first comment. We cannot add padding the container because we only
-     want it if there is at least one comment thread, and the slotting
-     makes :empty not work as expected. */
-  .content.file slot:first-child::slotted(.comment-thread) {
-    display: block;
-    margin-top: var(--spacing-xs);
-  }
-  .contentText {
-    background-color: var(--view-background-color);
-  }
-  .blank {
-    background-color: var(--diff-blank-background-color);
-  }
-  .image-diff .content {
-    background-color: var(--diff-blank-background-color);
-  }
-  .responsive {
-    width: 100%;
-  }
-  .responsive .contentText {
-    white-space: break-spaces;
-    word-break: break-all;
-  }
-  .lineNumButton,
-  .content {
-    vertical-align: top;
-    white-space: pre;
-  }
-  .contextLineNum,
-  .lineNumButton {
-    -webkit-user-select: none;
-    -moz-user-select: none;
-    -ms-user-select: none;
-    user-select: none;
-
-    color: var(--deemphasized-text-color);
-    padding: 0 var(--spacing-m);
-    text-align: right;
-  }
-  .canComment .lineNumButton {
-    cursor: pointer;
-  }
-  .sign {
-    min-width: 1ch;
-    width: 1ch;
-    background-color: var(--view-background-color);
-  }
-  .sign.blank {
-    background-color: var(--diff-blank-background-color);
-  }
-  .content {
-    /* Set min width since setting width on table cells still allows them
-       to shrink. Do not set max width because CJK
-       (Chinese-Japanese-Korean) glyphs have variable width. */
-    min-width: var(--content-width, 80ch);
-    width: var(--content-width, 80ch);
-  }
-  /* If there are no intraline info, consider everything changed */
-  .content.add .contentText .intraline,
-  .content.add.no-intraline-info .contentText,
-  .sign.add.no-intraline-info,
-  .delta.total .content.add .contentText {
-    background-color: var(--dark-add-highlight-color);
-  }
-  .content.add .contentText,
-  .sign.add {
-    background-color: var(--light-add-highlight-color);
-  }
-  /* If there are no intraline info, consider everything changed */
-  .content.remove .contentText .intraline,
-  .content.remove.no-intraline-info .contentText,
-  .delta.total .content.remove .contentText,
-  .sign.remove.no-intraline-info {
-    background-color: var(--dark-remove-highlight-color);
-  }
-  .content.remove .contentText,
-  .sign.remove {
-    background-color: var(--light-remove-highlight-color);
-  }
-
-  .ignoredWhitespaceOnly .sign.no-intraline-info {
-    background-color: var(--view-background-color);
-  }
-
-  /* dueToRebase */
-  .dueToRebase .content.add .contentText .intraline,
-  .delta.total.dueToRebase .content.add .contentText {
-    background-color: var(--dark-rebased-add-highlight-color);
-  }
-  .dueToRebase .content.add .contentText {
-    background-color: var(--light-rebased-add-highlight-color);
-  }
-  .dueToRebase .content.remove .contentText .intraline,
-  .delta.total.dueToRebase .content.remove .contentText {
-    background-color: var(--dark-rebased-remove-highlight-color);
-  }
-  .dueToRebase .content.remove .contentText {
-    background-color: var(--light-rebased-remove-highlight-color);
-  }
-
-  /* dueToMove */
-  .dueToMove .sign.add,
-  .dueToMove .content.add .contentText,
-  .dueToMove .moveControls.movedIn .sign.right,
-  .dueToMove .moveControls.movedIn .moveHeader,
-  .delta.total.dueToMove .content.add .contentText {
-    background-color: var(--diff-moved-in-background);
-  }
-
-  .dueToMove.changed .sign.add,
-  .dueToMove.changed .content.add .contentText,
-  .dueToMove.changed .moveControls.movedIn .sign.right,
-  .dueToMove.changed .moveControls.movedIn .moveHeader,
-  .delta.total.dueToMove.changed .content.add .contentText {
-    background-color: var(--diff-moved-in-changed-background);
-  }
-
-  .dueToMove .sign.remove,
-  .dueToMove .content.remove .contentText,
-  .dueToMove .moveControls.movedOut .moveHeader,
-  .dueToMove .moveControls.movedOut .sign.left,
-  .delta.total.dueToMove .content.remove .contentText {
-    background-color: var(--diff-moved-out-background);
-  }
-
-  .delta.dueToMove .movedIn .moveHeader {
-    --gr-range-header-color: var(--diff-moved-in-label-color);
-  }
-  .delta.dueToMove.changed .movedIn .moveHeader {
-    --gr-range-header-color: var(--diff-moved-in-changed-label-color);
-  }
-  .delta.dueToMove .movedOut .moveHeader {
-    --gr-range-header-color: var(--diff-moved-out-label-color);
-  }
-
-  .moveHeader a {
-    color: inherit;
-  }
-
-  /* ignoredWhitespaceOnly */
-  .ignoredWhitespaceOnly .content.add .contentText .intraline,
-  .delta.total.ignoredWhitespaceOnly .content.add .contentText,
-  .ignoredWhitespaceOnly .content.add .contentText,
-  .ignoredWhitespaceOnly .content.remove .contentText .intraline,
-  .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
-  .ignoredWhitespaceOnly .content.remove .contentText {
-    background-color: var(--view-background-color);
-  }
-
-  .content .contentText gr-diff-text:empty:after,
-  .content .contentText gr-legacy-text:empty:after,
-  .content .contentText:empty:after {
-    /* Newline, to ensure empty lines are one line-height tall. */
-    content: '\\A';
-  }
-
-  /* Context controls */
-  .contextControl {
-    display: table-row-group;
-    background-color: transparent;
-    border: none;
-    --divider-height: var(--spacing-s);
-    --divider-border: 1px;
-  }
-  /* TODO: Is this still used? */
-  .contextControl gr-button gr-icon {
-    /* should match line-height of gr-button */
-    font-size: var(--line-height-mono, 18px);
-  }
-  .contextControl td:not(.lineNumButton) {
-    text-align: center;
-  }
-
-  /* Padding rows behind context controls. Styled as a continuation of the
-     line gutters and code area. */
-  .contextBackground > .contextLineNum {
-    background-color: var(--diff-blank-background-color);
-  }
-  .contextBackground > td:not(.contextLineNum) {
-    background-color: var(--view-background-color);
-  }
-  .contextBackground {
-    /* One line of background behind the context expanders which they can
-       render on top of, plus some padding. */
-    height: calc(var(--line-height-normal) + var(--spacing-s));
-  }
-
-  /* Hide the actual context control buttons */
-  :host(.disable-context-control-buttons) .contextControl gr-context-controls {
-    display: none;
-  }
-  /* Maintain a small amount of padding at the edges of diff chunks */
-  :host(.disable-context-control-buttons) .contextControl .contextBackground {
-    height: var(--spacing-s);
-    border-right: none;
-  }
-
-  .dividerCell {
-    vertical-align: top;
-  }
-  .dividerRow.show-both .dividerCell {
-    height: var(--divider-height);
-  }
-  .dividerRow.show-above .dividerCell,
-  .dividerRow.show-above .dividerCell {
-    height: 0;
-  }
-
-  .br:after {
-    /* Line feed */
-    content: '\\A';
-  }
-  .tab {
-    display: inline-block;
-  }
-  .tab-indicator:before {
-    color: var(--diff-tab-indicator-color);
-    /* >> character */
-    content: '\\00BB';
-    position: absolute;
-  }
-  .special-char-indicator {
-    /* spacing so elements don't collide */
-    padding-right: var(--spacing-m);
-  }
-  .special-char-indicator:before {
-    color: var(--diff-tab-indicator-color);
-    content: '•';
-    position: absolute;
-  }
-  .special-char-warning {
-    /* spacing so elements don't collide */
-    padding-right: var(--spacing-m);
-  }
-  .special-char-warning:before {
-    color: var(--warning-foreground);
-    content: '!';
-    position: absolute;
-  }
-  /* Is defined after other background-colors, such that this
-     rule wins in case of same specificity. */
-  .trailing-whitespace,
-  .content .contentText .trailing-whitespace,
-  .trailing-whitespace .intraline,
-  .content .contentText .trailing-whitespace .intraline {
-    border-radius: var(--border-radius, 4px);
-    background-color: var(--diff-trailing-whitespace-indicator);
-  }
-  #diffHeader {
-    background-color: var(--table-header-background-color);
-    border-bottom: 1px solid var(--border-color);
-    color: var(--link-color);
-    padding: var(--spacing-m) 0 var(--spacing-m) 48px;
-  }
-  gr-diff-element {
-    /* for gr-selection-action-box positioning */
-    position: relative;
-  }
-  #diffTable:focus {
-    outline: none;
-  }
-  #loadingError,
-  #sizeWarning {
-    display: block;
-    margin: var(--spacing-l) auto;
-    max-width: 60em;
-    text-align: center;
-  }
-  #loadingError {
-    color: var(--error-text-color);
-  }
-  #sizeWarning gr-button {
-    margin: var(--spacing-l);
-  }
-  .target-row td.blame {
-    background: var(--diff-selection-background-color);
-  }
-  td.lost div {
-    background-color: var(--info-background);
-  }
-  td.lost div.lost-message {
-    font-family: var(--font-family, 'Roboto');
-    font-size: var(--font-size-normal, 14px);
-    line-height: var(--line-height-normal);
-    padding: var(--spacing-s) 0;
-  }
-  td.lost div.lost-message gr-icon {
-    padding: 0 var(--spacing-s) 0 var(--spacing-m);
-    color: var(--blue-700);
-  }
-
-  col.blame {
-    display: none;
-  }
-  td.blame {
-    display: none;
-    padding: 0 var(--spacing-m);
-    white-space: pre;
-  }
-  :host(.showBlame) col.blame {
-    display: table-column;
-  }
-  :host(.showBlame) td.blame {
-    display: table-cell;
-  }
-  td.blame > span {
-    opacity: 0.6;
-  }
-  td.blame > span.startOfRange {
-    opacity: 1;
-  }
-  td.blame .blameDate {
-    font-family: var(--monospace-font-family);
-    color: var(--link-color);
-    text-decoration: none;
-  }
-  .responsive td.blame {
-    overflow: hidden;
-    width: 200px;
-  }
-  /** Support the line length indicator **/
-  .responsive td.content .contentText {
-    /* Same strategy as in
-       https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
-       */
-    background-image: linear-gradient(
-      var(--line-length-indicator-color),
-      var(--line-length-indicator-color)
-    );
-    background-size: 1px 100%;
-    background-position: var(--line-limit-marker) 0;
-    background-repeat: no-repeat;
-  }
-  .newlineWarning {
-    color: var(--deemphasized-text-color);
-    text-align: center;
-  }
-  .newlineWarning.hidden {
-    display: none;
-  }
-  .lineNum.COVERED .lineNumButton {
-    color: var(
-      --coverage-covered-line-num-color,
-      var(--deemphasized-text-color)
-    );
-    background-color: var(--coverage-covered, #e0f2f1);
-  }
-  .lineNum.NOT_COVERED .lineNumButton {
-    color: var(
-      --coverage-covered-line-num-color,
-      var(--deemphasized-text-color)
-    );
-    background-color: var(--coverage-not-covered, #ffd1a4);
-  }
-  .lineNum.PARTIALLY_COVERED .lineNumButton {
-    color: var(
-      --coverage-covered-line-num-color,
-      var(--deemphasized-text-color)
-    );
-    background: linear-gradient(
-      to right bottom,
-      var(--coverage-not-covered, #ffd1a4) 0%,
-      var(--coverage-not-covered, #ffd1a4) 50%,
-      var(--coverage-covered, #e0f2f1) 50%,
-      var(--coverage-covered, #e0f2f1) 100%
-    );
-  }
-
-  // TODO: Investigate whether this CSS is still necessary.
-  /* BEGIN: Select and copy for Polymer 2 */
-  /* Below was copied and modified from the original css in gr-diff-selection.html. */
-  .content,
-  .contextControl,
-  .blame {
-    -webkit-user-select: none;
-    -moz-user-select: none;
-    -ms-user-select: none;
-    user-select: none;
-  }
-
-  .selected-left:not(.selected-comment)
-    .side-by-side
-    .left
-    + .content
-    .contentText,
-  .selected-right:not(.selected-comment)
-    .side-by-side
-    .right
-    + .content
-    .contentText,
-  .selected-left:not(.selected-comment)
-    .unified
-    .left.lineNum
-    ~ .content:not(.both)
-    .contentText,
-  .selected-right:not(.selected-comment)
-    .unified
-    .right.lineNum
-    ~ .content
-    .contentText,
-  .selected-left.selected-comment .side-by-side .left + .content .message,
-  .selected-right.selected-comment
-    .side-by-side
-    .right
-    + .content
-    .message
-    :not(.collapsedContent),
-  .selected-comment .unified .message :not(.collapsedContent),
-  .selected-blame .blame {
-    -webkit-user-select: text;
-    -moz-user-select: text;
-    -ms-user-select: text;
-    user-select: text;
-  }
-
-  /* Make comments and check results selectable when selected */
-  .selected-left.selected-comment ::slotted(.comment-thread[diff-side='left']),
-  .selected-right.selected-comment
-    ::slotted(.comment-thread[diff-side='right']) {
-    -webkit-user-select: text;
-    -moz-user-select: text;
-    -ms-user-select: text;
-    user-select: text;
-  }
-  /* END: Select and copy for Polymer 2 */
-
-  .whitespace-change-only-message {
-    background-color: var(--diff-context-control-background-color);
-    border: 1px solid var(--diff-context-control-border-color);
-    text-align: center;
-  }
-
-  .token-highlight {
-    background-color: var(--token-highlighting-color, #fffd54);
-  }
-
-  gr-selection-action-box {
-    /* Needs z-index to appear above wrapped content, since it's inserted
-       into DOM before it. */
-    z-index: 120;
-  }
-
-  gr-diff-image-new,
-  gr-diff-image-old,
-  gr-diff-section,
-  gr-context-controls-section,
-  gr-diff-row {
-    display: contents;
-  }
 `;
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 219f16e..c4aaa2d 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
@@ -3,7 +3,7 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {CommentRange} from '../../../types/common';
+import {BlameInfo, CommentRange} from '../../../types/common';
 import {Side, SpecialFilePath} from '../../../constants/constants';
 import {
   DiffContextExpandedExternalDetail,
@@ -315,146 +315,6 @@
   );
 }
 
-/**
- * Simple helper method for creating element classes in the context of
- * gr-diff. This is just a super simple convenience function.
- */
-export function diffClasses(...additionalClasses: string[]) {
-  return ['gr-diff', ...additionalClasses].join(' ');
-}
-
-/**
- * Simple helper method for creating elements in the context of gr-diff.
- * This is just a super simple convenience function.
- */
-export function createElementDiff(
-  tagName: string,
-  classStr?: string
-): HTMLElement {
-  const el = document.createElement(tagName);
-
-  el.classList.add('gr-diff');
-  if (classStr) {
-    for (const className of classStr.split(' ')) {
-      el.classList.add(className);
-    }
-  }
-  return el;
-}
-
-export function createElementDiffWithText(
-  tagName: string,
-  textContent: string
-) {
-  const element = createElementDiff(tagName);
-  element.textContent = textContent;
-  return element;
-}
-
-export function createLineBreak(mode: DiffResponsiveMode) {
-  return isResponsive(mode)
-    ? createElementDiff('wbr')
-    : createElementDiff('span', 'br');
-}
-
-/**
- * Returns a <span> element holding a '\t' character, that will visually
- * occupy |tabSize| many columns.
- *
- * @param tabSize The effective size of this tab stop.
- */
-export function createTabWrapper(tabSize: number): HTMLElement {
-  // Force this to be a number to prevent arbitrary injection.
-  const result = createElementDiff('span', 'tab');
-  result.setAttribute(
-    'style',
-    `tab-size: ${tabSize}; -moz-tab-size: ${tabSize};`
-  );
-  result.innerText = '\t';
-  return result;
-}
-
-/**
- * Returns a 'div' element containing the supplied |text| as its innerText,
- * with '\t' characters expanded to a width determined by |tabSize|, and the
- * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
- * desired.
- *
- * @param text The text to be formatted.
- * @param responsiveMode The responsive mode of the diff.
- * @param tabSize The width of each tab stop.
- * @param lineLimit The column after which to wrap lines.
- */
-export function formatText(
-  text: string,
-  responsiveMode: DiffResponsiveMode,
-  tabSize: number,
-  lineLimit: number,
-  elementId: string
-): HTMLElement {
-  const contentText = createElementDiff('div', 'contentText');
-  // <gr-legacy-text> is not defined anywhere, so this behave just as a <div>
-  // would. We use this during the migration to lit based diff elements to
-  // match <gr-diff-text>. We define a css rule with `display:contents` making
-  // sure that this extra element is basically a no-op.
-  const legacyText = document.createElement('gr-legacy-text');
-  contentText.appendChild(legacyText);
-  contentText.id = elementId;
-  let columnPos = 0;
-  let textOffset = 0;
-  for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
-    if (segment) {
-      // |segment| contains only normal characters. If |segment| doesn't fit
-      // entirely on the current line, append chunks of |segment| followed by
-      // line breaks.
-      let rowStart = 0;
-      let rowEnd = lineLimit - columnPos;
-      while (rowEnd < segment.length) {
-        legacyText.appendChild(
-          document.createTextNode(segment.substring(rowStart, rowEnd))
-        );
-        legacyText.appendChild(createLineBreak(responsiveMode));
-        columnPos = 0;
-        rowStart = rowEnd;
-        rowEnd += lineLimit;
-      }
-      // Append the last part of |segment|, which fits on the current line.
-      legacyText.appendChild(
-        document.createTextNode(segment.substring(rowStart))
-      );
-      columnPos += segment.length - rowStart;
-      textOffset += segment.length;
-    }
-    if (textOffset < text.length) {
-      // Handle the special character at |textOffset|.
-      if (text.startsWith('\t', textOffset)) {
-        // Append a single '\t' character.
-        let effectiveTabSize = tabSize - (columnPos % tabSize);
-        if (columnPos + effectiveTabSize > lineLimit) {
-          legacyText.appendChild(createLineBreak(responsiveMode));
-          columnPos = 0;
-          effectiveTabSize = tabSize;
-        }
-        legacyText.appendChild(createTabWrapper(effectiveTabSize));
-        columnPos += effectiveTabSize;
-        textOffset++;
-      } else {
-        // Append a single surrogate pair.
-        if (columnPos >= lineLimit) {
-          legacyText.appendChild(createLineBreak(responsiveMode));
-          columnPos = 0;
-        }
-        legacyText.appendChild(
-          document.createTextNode(text.substring(textOffset, textOffset + 2))
-        );
-        textOffset += 2;
-        columnPos += 1;
-      }
-    }
-  }
-  return contentText;
-}
-
 export interface DiffContextExpandedEventDetail
   extends DiffContextExpandedExternalDetail {
   /** The context control group that should be replaced by `groups`. */
@@ -462,3 +322,10 @@
   groups: GrDiffGroup[];
   numLines: number;
 }
+
+export function findBlame(blameInfos: BlameInfo[], line?: LineNumber) {
+  if (typeof line !== 'number') return undefined;
+  return blameInfos.find(info =>
+    info.ranges.find(range => range.start <= line && line <= range.end)
+  );
+}
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 f425e2b..07aeaf0 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
@@ -6,9 +6,6 @@
 import {assert} from '@open-wc/testing';
 import '../../../test/common-test-setup';
 import {
-  createElementDiff,
-  formatText,
-  createTabWrapper,
   getRange,
   computeKeyLocations,
   GrDiffCommentThread,
@@ -23,156 +20,7 @@
 import {FILE, LOST, Side} from '../../../api/diff';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 
-const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
-
 suite('gr-diff-utils tests', () => {
-  test('createElementDiff classStr applies all classes', () => {
-    const node = createElementDiff('div', 'test classes');
-    assert.isTrue(node.classList.contains('gr-diff'));
-    assert.isTrue(node.classList.contains('test'));
-    assert.isTrue(node.classList.contains('classes'));
-  });
-
-  test('formatText newlines 1', () => {
-    let text = 'abcdef';
-
-    assert.equal(
-      formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
-      text
-    );
-    text = 'a'.repeat(20);
-    assert.equal(
-      formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
-      'a'.repeat(10) + LINE_BREAK_HTML + 'a'.repeat(10)
-    );
-  });
-
-  test('formatText newlines 2', () => {
-    const text = '<span class="thumbsup">👍</span>';
-    assert.equal(
-      formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
-      '&lt;span clas' +
-        LINE_BREAK_HTML +
-        's="thumbsu' +
-        LINE_BREAK_HTML +
-        'p"&gt;👍&lt;/span' +
-        LINE_BREAK_HTML +
-        '&gt;'
-    );
-  });
-
-  test('formatText newlines 3', () => {
-    const text = '01234\t56789';
-    assert.equal(
-      formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
-      '01234' + createTabWrapper(3).outerHTML + '56' + LINE_BREAK_HTML + '789'
-    );
-  });
-
-  test('formatText newlines 4', () => {
-    const text = '👍'.repeat(58);
-    assert.equal(
-      formatText(text, 'NONE', 4, 20, '').firstElementChild?.innerHTML,
-      '👍'.repeat(20) +
-        LINE_BREAK_HTML +
-        '👍'.repeat(20) +
-        LINE_BREAK_HTML +
-        '👍'.repeat(18)
-    );
-  });
-
-  test('tab wrapper style', () => {
-    const pattern = new RegExp(
-      '^<span class="gr-diff tab" ' +
-        'style="((?:-moz-)?tab-size: (\\d+);.?)+">\\t<\\/span>$'
-    );
-
-    for (const size of [1, 3, 8, 55]) {
-      const html = createTabWrapper(size).outerHTML;
-      assert.match(html, pattern);
-      assert.equal(html.match(pattern)?.[2], size.toString());
-    }
-  });
-
-  test('tab wrapper insertion', () => {
-    const html = 'abc\tdef';
-    const tabSize = 8;
-    const wrapper = createTabWrapper(tabSize - 3);
-    assert.ok(wrapper);
-    assert.equal(wrapper.innerText, '\t');
-    assert.equal(
-      formatText(html, 'NONE', tabSize, Infinity, '').firstElementChild
-        ?.innerHTML,
-      'abc' + wrapper.outerHTML + 'def'
-    );
-  });
-
-  test('escaping HTML', () => {
-    let input = '<script>alert("XSS");<' + '/script>';
-    let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
-
-    let result = formatText(input, 'NONE', 1, Number.POSITIVE_INFINITY, '')
-      .firstElementChild?.innerHTML;
-    assert.equal(result, expected);
-
-    input = '& < > " \' / `';
-    expected = '&amp; &lt; &gt; " \' / `';
-    result = formatText(input, 'NONE', 1, Number.POSITIVE_INFINITY, '')
-      .firstElementChild?.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  test('text length with tabs and unicode', () => {
-    function expectTextLength(text: string, tabSize: number, expected: number) {
-      // Formatting to |expected| columns should not introduce line breaks.
-      const result = formatText(text, 'NONE', tabSize, expected, '')
-        .firstElementChild!;
-      assert.isNotOk(
-        result.querySelector('.contentText > .br'),
-        '  Expected the result of: \n' +
-          `      _formatText(${text}', 'NONE',  ${tabSize}, ${expected})\n` +
-          '  to not contain a br. But the actual result HTML was:\n' +
-          `      '${result.innerHTML}'\nwhereupon`
-      );
-
-      // Increasing the line limit should produce the same markup.
-      assert.equal(
-        formatText(text, 'NONE', tabSize, Infinity, '').firstElementChild
-          ?.innerHTML,
-        result.innerHTML
-      );
-      assert.equal(
-        formatText(text, 'NONE', tabSize, expected + 1, '').firstElementChild
-          ?.innerHTML,
-        result.innerHTML
-      );
-
-      // Decreasing the line limit should introduce line breaks.
-      if (expected > 0) {
-        const tooSmall = formatText(text, 'NONE', tabSize, expected - 1, '')
-          .firstElementChild!;
-        assert.isOk(
-          tooSmall.querySelector('.contentText .br'),
-          '  Expected the result of: \n' +
-            `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
-            '  to contain a br. But the actual result HTML was:\n' +
-            `      '${tooSmall.innerHTML}'\nwhereupon`
-        );
-      }
-    }
-    expectTextLength('12345', 4, 5);
-    expectTextLength('\t\t12', 4, 10);
-    expectTextLength('abc💢123', 4, 7);
-    expectTextLength('abc\t', 8, 8);
-    expectTextLength('abc\t\t', 10, 20);
-    expectTextLength('', 10, 0);
-    // 17 Thai combining chars.
-    expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
-    expectTextLength('abc\tde', 10, 12);
-    expectTextLength('abc\tde\t', 10, 20);
-    expectTextLength('\t\t\t\t\t', 20, 100);
-  });
-
   test('getRange returns undefined with start_line = 0', () => {
     const range = {
       start_line: 0,
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 a0b579a..90b9cfe 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -59,7 +59,20 @@
 import {iconStyles} from '../../../styles/gr-icon-styles';
 import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
 import {provide} from '../../../models/dependency';
-import {grDiffStyles} from './gr-diff-styles';
+import {
+  grDiffBinaryStyles,
+  grDiffContextControlsSectionStyles,
+  grDiffElementStyles,
+  grDiffIgnoredWhitespaceStyles,
+  grDiffImageStyles,
+  grDiffMoveStyles,
+  grDiffRebaseStyles,
+  grDiffRowStyles,
+  grDiffSectionStyles,
+  grDiffSelectionStyles,
+  grDiffStyles,
+  grDiffTextStyles,
+} from './gr-diff-styles';
 import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
 import {
   GrAnnotationImpl,
@@ -277,6 +290,17 @@
       grSyntaxTheme,
       grRangedCommentTheme,
       grDiffStyles,
+      grDiffElementStyles,
+      grDiffSectionStyles,
+      grDiffContextControlsSectionStyles,
+      grDiffRowStyles,
+      grDiffIgnoredWhitespaceStyles,
+      grDiffMoveStyles,
+      grDiffRebaseStyles,
+      grDiffSelectionStyles,
+      grDiffTextStyles,
+      grDiffImageStyles,
+      grDiffBinaryStyles,
     ];
   }
 
@@ -392,7 +416,7 @@
       this.layersChanged();
     }
     if (changedProperties.has('blame')) {
-      this.blameChanged();
+      this.diffModel.updateState({blameInfo: this.blame ?? []});
     }
     if (changedProperties.has('renderPrefs')) {
       this.renderPrefsChanged();
@@ -508,15 +532,6 @@
     return !!this.highlights.selectedRange;
   }
 
-  private blameChanged() {
-    this.setBlame(this.blame ?? []);
-    if (this.blame) {
-      this.classList.add('showBlame');
-    } else {
-      this.classList.remove('showBlame');
-    }
-  }
-
   // Private but used in tests.
   selectLine(el: Element) {
     const lineNumber = Number(el.getAttribute('data-value'));
@@ -545,8 +560,6 @@
 
   private prefsChanged() {
     if (!this.prefs) return;
-
-    this.blame = null;
     this.updatePreferenceStyles();
 
     if (!Number.isInteger(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
@@ -796,7 +809,7 @@
       // differences to highlight and apply them to the element as
       // annotations.
       annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-        const HL_CLASS = 'gr-diff intraline';
+        const HL_CLASS = 'intraline';
         for (const highlight of line.highlights) {
           // The start and end indices could be the same if a highlight is
           // meant to start at the end of a line and continue onto the
@@ -865,7 +878,7 @@
             contentEl,
             index,
             length,
-            'gr-diff trailing-whitespace'
+            'trailing-whitespace'
           );
         }
       },
@@ -963,21 +976,6 @@
       .slice(startIndex, endIndex + 1)
       .filter(group => group.lines.length > 0);
   }
-
-  /**
-   * Set the blame information for the diff. For any already-rendered line,
-   * re-render its blame cell content.
-   */
-  setBlame(blame: BlameInfo[]) {
-    for (const blameInfo of blame) {
-      for (const range of blameInfo.ranges) {
-        for (let line = range.start; line <= range.end; line++) {
-          const row = this.findRow(Side.LEFT, line);
-          if (row) row.blameInfo = blameInfo;
-        }
-      }
-    }
-  }
 }
 
 function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
@@ -998,7 +996,7 @@
     // Skip forward by the length of the content
     pos += split[i].length;
 
-    GrAnnotationImpl.annotateElement(contentEl, pos, 1, `gr-diff ${className}`);
+    GrAnnotationImpl.annotateElement(contentEl, pos, 1, className);
 
     pos++;
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index bceafa3..5fa4788 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -190,13 +190,13 @@
       element.renderPrefs = {hide_left_side: true};
       await element.updateComplete;
       let cols = queryAll(element, 'col');
-      assert.equal(cols.length, 3);
+      assert.equal(cols.length, 2);
 
       diffModel.updateState({renderPrefs: {hide_left_side: false}});
       element.renderPrefs = {hide_left_side: false};
       await element.updateComplete;
       cols = queryAll(element, 'col');
-      assert.equal(cols.length, 5);
+      assert.equal(cols.length, 4);
     });
 
     suite('getCursorStops', () => {
@@ -333,32 +333,6 @@
     });
   });
 
-  suite('blame', () => {
-    test('unsetting', async () => {
-      element.blame = [];
-      const setBlameSpy = sinon.spy(element, 'setBlame');
-      element.classList.add('showBlame');
-      element.blame = null;
-      await element.updateComplete;
-      assert.isTrue(setBlameSpy.calledWithExactly([]));
-      assert.isFalse(element.classList.contains('showBlame'));
-    });
-
-    test('setting', async () => {
-      element.blame = [
-        {
-          author: 'test-author',
-          time: 12345,
-          commit_msg: '',
-          id: 'commit id',
-          ranges: [{start: 1, end: 2}],
-        },
-      ];
-      await element.updateComplete;
-      assert.isTrue(element.classList.contains('showBlame'));
-    });
-  });
-
   const setupSampleDiff = async function (params: {
     content: DiffContent[];
     ignore_whitespace?: IgnoreWhitespaceType;
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 4d9c71a..fa9a782 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -49,8 +49,8 @@
   [side in Side]: LinesMap;
 };
 
-const RANGE_BASE_ONLY = 'gr-diff range';
-const RANGE_HIGHLIGHT = 'gr-diff range rangeHighlight';
+const RANGE_BASE_ONLY = 'range';
+const RANGE_HIGHLIGHT = 'range rangeHighlight';
 // Note that there is also `rangeHoverHighlight` being set by GrDiffHighlight.
 
 /**
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index 7c25eeb..338ac07 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -159,10 +159,7 @@
       assert.equal(lastCall.args[0], el);
       assert.equal(lastCall.args[1], expectedStart);
       assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(
-        lastCall.args[3],
-        'gr-diff range rangeHighlight generated_a'
-      );
+      assert.equal(lastCall.args[3], 'range rangeHighlight generated_a');
     });
 
     test('type=Both has-comment', () => {
@@ -179,10 +176,7 @@
       assert.equal(lastCall.args[0], el);
       assert.equal(lastCall.args[1], expectedStart);
       assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(
-        lastCall.args[3],
-        'gr-diff range rangeHighlight generated_a'
-      );
+      assert.equal(lastCall.args[3], 'range rangeHighlight generated_a');
     });
 
     test('type=Both has-comment off side', () => {
@@ -210,10 +204,7 @@
       assert.equal(lastCall.args[0], el);
       assert.equal(lastCall.args[1], expectedStart);
       assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(
-        lastCall.args[3],
-        'gr-diff range rangeHighlight generated_b'
-      );
+      assert.equal(lastCall.args[3], 'range rangeHighlight generated_b');
     });
 
     test('long range comment', () => {
@@ -226,7 +217,7 @@
       assert.isTrue(annotateElementStub.called);
       assert.equal(
         annotateElementStub.lastCall.args[3],
-        'gr-diff range generated_right-60-1-71-1'
+        'range generated_right-60-1-71-1'
       );
     });
 
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
index cd10f4d..120aa2b 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -68,6 +68,8 @@
     `;
   }
 
+  // TODO(b/315277651): This is very similar in purpose to gr-tooltip-content.
+  //   We should figure out a way to reuse as much of the logic as possible.
   async placeAbove(el: Text | Element | Range) {
     if (!this.tooltip) return;
     await this.tooltip.updateComplete;
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index 4e166ba..5074290 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -77,6 +77,7 @@
   ['text/x-puppet', 'puppet'],
   ['text/x-python', 'python'],
   ['text/x-q', 'q'],
+  ['text/x-qml', 'qml'],
   ['text/x-ruby', 'ruby'],
   ['text/x-rustsrc', 'rust'],
   ['text/x-scala', 'scala'],
@@ -99,7 +100,7 @@
   ['text/vbscript', 'vbscript'],
 ]);
 
-const CLASS_PREFIX = 'gr-diff gr-syntax gr-syntax-';
+const CLASS_PREFIX = 'gr-syntax gr-syntax-';
 
 const CLASS_SAFELIST = new Set<string>([
   'attr',
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
index c6c46f9..221eada 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
@@ -139,7 +139,7 @@
       const el = annotate(Side.LEFT, 1, 'import it;');
       assert.equal(
         el.innerHTML,
-        '<hl class="gr-diff gr-syntax gr-syntax-literal">import</hl> it;'
+        '<hl class="gr-syntax gr-syntax-literal">import</hl> it;'
       );
       assert.equal(listener.callCount, 2);
       assert.equal(listener.getCall(0).args[0], 1);
@@ -155,9 +155,9 @@
       const el = annotate(Side.RIGHT, 3, '  public static final {');
       assert.equal(
         el.innerHTML,
-        '  <hl class="gr-diff gr-syntax gr-syntax-literal">public</hl> ' +
-          '<hl class="gr-diff gr-syntax gr-syntax-keyword">static</hl> ' +
-          '<hl class="gr-diff gr-syntax gr-syntax-name">final</hl> {'
+        '  <hl class="gr-syntax gr-syntax-literal">public</hl> ' +
+          '<hl class="gr-syntax gr-syntax-keyword">static</hl> ' +
+          '<hl class="gr-syntax gr-syntax-name">final</hl> {'
       );
       assert.equal(listener.callCount, 2);
     });
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 26003e1..a070944 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -27,6 +27,8 @@
 import {getUserId} from '../../utils/account-util';
 import {getChangeNumber} from '../../utils/change-util';
 import {deepEqual} from '../../utils/deep-util';
+import {throwingErrorCallback} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {assert} from '../../utils/common-util';
 
 export const bulkActionsModelToken =
   define<BulkActionsModel>('bulk-actions-model');
@@ -126,7 +128,7 @@
     reason?: string,
     // errorFn is needed to avoid showing an error dialog
     errFn?: (changeNum: NumericChangeId) => void
-  ): Promise<Response | undefined>[] {
+  ): Promise<Response>[] {
     const current = this.getState();
     return current.selectedChangeNums.map(changeNum => {
       if (!current.allChanges.get(changeNum))
@@ -207,10 +209,16 @@
     const current = this.getState();
     return current.selectedChangeNums.map(changeNum =>
       this.restApiService
-        .setChangeHashtag(changeNum, {
-          add: hashtags,
-        })
+        .setChangeHashtag(
+          changeNum,
+          {
+            add: hashtags,
+          },
+          throwingErrorCallback
+        )
         .then(responseHashtags => {
+          // With throwingErrorCallback guaranteed to be non-null.
+          assert(!!responseHashtags, 'setChangeHastag returned undefined');
           // Once we get server confirmation that the hashtags were added to the
           // change, we are updating the model's ChangeInfo. This way we can
           // keep the page state (dialog status) but use the updated change info
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 ece126c..1ac9593 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
@@ -223,7 +223,7 @@
     });
 
     test('already abandoned change does not call executeChangeAction', () => {
-      const actionStub = stubRestApi('executeChangeAction');
+      const actionStub = stubRestApi('executeChangeAction').resolves();
       bulkActionsModel.abandonChanges();
       assert.equal(actionStub.callCount, 1);
       assert.deepEqual(actionStub.lastCall.args.slice(0, 5), [
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
index 192520d..9e8718d 100644
--- a/polygerrit-ui/app/models/change/files-model.ts
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -12,7 +12,7 @@
   PatchSetNumber,
   RevisionPatchSetNum,
 } from '../../types/common';
-import {combineLatest, of, from} from 'rxjs';
+import {combineLatest, of, from, Observable} from 'rxjs';
 import {switchMap, map} from 'rxjs/operators';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {select} from '../../utils/observable-util';
@@ -120,6 +120,11 @@
 export class FilesModel extends Model<FilesState> {
   public readonly files$ = select(this.state$, state => state.files);
 
+  public file$ = (path$: Observable<string | undefined>) =>
+    combineLatest([path$, this.files$]).pipe(
+      map(([path, files]) => files.find(f => f.__path === path))
+    );
+
   /**
    * `files$` only includes the files that were modified. Here we also include
    * all unmodified files that have comments with
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index a567fb5..fc18968 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -14,13 +14,13 @@
   Replacement,
   RunStatus,
 } from '../../api/checks';
-import {PatchSetNumber, RevisionPatchSetNum} from '../../api/rest-api';
-import {CommentSide} from '../../constants/constants';
 import {
-  FixSuggestionInfo,
+  PatchSetNumber,
+  RevisionPatchSetNum,
   FixReplacementInfo,
-  DraftInfo,
-} from '../../types/common';
+} from '../../api/rest-api';
+import {CommentSide} from '../../constants/constants';
+import {FixSuggestionInfo, DraftInfo} from '../../types/common';
 import {OpenFixPreviewEventDetail} from '../../types/events';
 import {isDefined} from '../../types/types';
 import {PROVIDED_FIX_ID, createNew} from '../../utils/comment-util';
@@ -145,7 +145,7 @@
   if (replacements.length === 0) return undefined;
 
   return {
-    description: fix.description ?? `Fix provided by ${checkName}`,
+    description: fix.description || `Fix provided by ${checkName}`,
     fix_id: PROVIDED_FIX_ID,
     replacements,
   };
@@ -520,7 +520,6 @@
 }
 
 export function computeIsExpandable(result?: CheckResultApi) {
-  if (!result?.summary) return false;
   const hasMessage = !!result?.message;
   const hasMultipleLinks = (result?.links ?? []).length > 1;
   const hasPointers = (result?.codePointers ?? []).length > 0;
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
index 83b4cd6..3b5c108 100644
--- a/polygerrit-ui/app/models/checks/checks-util_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -93,6 +93,29 @@
     assert.equal(rectified?.fix_id, PROVIDED_FIX_ID);
   });
 
+  test('rectifyFix changes description when description is empty', () => {
+    const rectified = rectifyFix(
+      {
+        replacements: [
+          {
+            path: 'test-path',
+            range: {
+              start_line: 1,
+              end_line: 1,
+              start_character: 0,
+              end_character: 1,
+            } as CommentRange,
+            replacement: 'test-replacement-string',
+          },
+        ],
+        description: '',
+      },
+      'test-check-name'
+    );
+    assert.isDefined(rectified);
+    assert.equal(rectified?.description, 'Fix provided by test-check-name');
+  });
+
   test('sortAttemptChoices', () => {
     const unsorted: (AttemptChoice | undefined)[] = [
       3,
@@ -121,7 +144,7 @@
     });
 
     test('no summary', () => {
-      assert.isFalse(
+      assert.isTrue(
         computeIsExpandable({
           ...createCheckResult(),
           message: 'asdf',
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index e61b88b..f9a8401 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -7,7 +7,6 @@
 import {
   CommentInfo,
   NumericChangeId,
-  PatchSetNum,
   RevisionId,
   UrlEncodedCommentId,
   RobotCommentInfo,
@@ -26,15 +25,25 @@
   convertToCommentInput,
   createNew,
   createNewPatchsetLevel,
+  getFirstComment,
+  hasSuggestion,
   id,
   isDraftThread,
   isNewThread,
+  isUnresolved,
   reportingDetails,
 } from '../../utils/comment-util';
 import {deepEqual} from '../../utils/deep-util';
 import {select} from '../../utils/observable-util';
 import {define} from '../dependency';
-import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
+import {
+  BehaviorSubject,
+  combineLatest,
+  forkJoin,
+  from,
+  Observable,
+  of,
+} from 'rxjs';
 import {fire, fireAlert} from '../../utils/event-util';
 import {CURRENT} from '../../utils/patch-set-util';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
@@ -57,6 +66,7 @@
 import {isDefined} from '../../types/types';
 import {ChangeViewModel} from '../views/change';
 import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
+import {readJSONResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 export interface CommentState {
   /** undefined means 'still loading' */
@@ -68,9 +78,20 @@
   drafts?: {[path: string]: DraftInfo[]};
   // Ported comments only affect `CommentThread` properties, not individual
   // comments.
-  /** undefined means 'still loading' */
+  /**
+   * Comments ported from earlier patchsets.
+   *
+   * This only considers current patchset (right side), not the base patchset
+   * (left-side).
+   *
+   * undefined means 'still loading'
+   */
   portedComments?: {[path: string]: CommentInfo[]};
-  /** undefined means 'still loading' */
+  /**
+   * Drafts ported from earlier patchsets.
+   *
+   * undefined means 'still loading'
+   */
   portedDrafts?: {[path: string]: DraftInfo[]};
   /**
    * If a draft is discarded by the user, then we temporarily keep it in this
@@ -215,7 +236,7 @@
   return nextState;
 }
 
-/** Adds or updates a draft. */
+/** Adds or updates a draft in the state. */
 export function setDraft(state: CommentState, draft: DraftInfo): CommentState {
   const nextState = {...state};
   assert(!!draft.path, 'draft without path');
@@ -234,6 +255,12 @@
   return nextState;
 }
 
+/** Removes a draft from the state.
+ *
+ * Removed draft is stored in discardedDrafts for potential undo operation.
+ * discardedDrafts however is only a client-side cache and such drafts are not
+ * retained in the server.
+ */
 export function deleteDraft(
   state: CommentState,
   draft: DraftInfo
@@ -253,6 +280,10 @@
 }
 
 export const commentsModelToken = define<CommentsModel>('comments-model');
+/**
+ * Model that maintains the state of all comments and drafts for the current
+ * change in the context of change-view.
+ */
 export class CommentsModel extends Model<CommentState> {
   public readonly commentsLoading$ = select(
     this.state$,
@@ -401,6 +432,17 @@
     threads.filter(t => !isNewThread(t) && isDraftThread(t))
   );
 
+  public readonly threadsWithSuggestions$ = select(
+    combineLatest([this.threads$, this.changeModel.latestPatchNum$]),
+    ([threads, latestPs]) =>
+      threads.filter(
+        t =>
+          isUnresolved(t) &&
+          hasSuggestion(t) &&
+          getFirstComment(t)?.patch_set === latestPs
+      )
+  );
+
   public readonly commentedPaths$ = select(
     combineLatest([
       this.changeComments$,
@@ -414,6 +456,8 @@
     }
   );
 
+  public readonly reloadAllComments$ = new BehaviorSubject(undefined);
+
   public thread$(id: UrlEncodedCommentId) {
     return select(this.threads$, threads => threads.find(t => t.rootId === id));
   }
@@ -422,8 +466,6 @@
 
   private changeNum?: NumericChangeId;
 
-  private patchNum?: PatchSetNum;
-
   private drafts: {[path: string]: DraftInfo[]} = {};
 
   private draftToastTask?: DelayedTask;
@@ -463,9 +505,8 @@
     this.subscriptions.push(
       this.drafts$.subscribe(x => (this.drafts = x ?? {}))
     );
-    this.subscriptions.push(
-      this.changeModel.patchNum$.subscribe(x => (this.patchNum = x))
-    );
+    // Patchset-level draft should always exist when opening reply dialog.
+    // If there are none, create an empty one.
     this.subscriptions.push(
       combineLatest([
         this.draftsLoading$,
@@ -477,41 +518,55 @@
       })
     );
     this.subscriptions.push(
-      this.changeViewModel.changeNum$.subscribe(changeNum => {
-        this.changeNum = changeNum;
-        this.setState({...initialState});
-        this.reloadAllComments();
-      })
+      combineLatest([this.changeViewModel.changeNum$, this.reloadAllComments$])
+        .pipe(
+          switchMap(([changeNum, _]) => {
+            this.changeNum = changeNum;
+            this.setState({...initialState});
+            if (!changeNum) return of([undefined, undefined, undefined]);
+            return forkJoin([
+              this.restApiService.getDiffComments(changeNum),
+              this.restApiService.getDiffRobotComments(changeNum),
+              this.restApiService.getDiffDrafts(changeNum),
+            ]);
+          })
+        )
+        .subscribe(([comments, robotComments, drafts]) => {
+          this.reportRobotCommentStats(robotComments);
+          this.modifyState(s => {
+            s = setComments(s, comments);
+            s = setRobotComments(s, robotComments);
+            return setDrafts(s, drafts);
+          });
+        })
     );
+    // When the patchset selection changes update information about comments
+    // ported from earlier patchsets.
     this.subscriptions.push(
-      combineLatest([
-        this.changeModel.changeNum$,
-        this.changeModel.patchNum$,
-      ]).subscribe(([changeNum, patchNum]) => {
-        this.changeNum = changeNum;
-        this.patchNum = patchNum;
-        this.reloadAllPortedComments();
-      })
+      combineLatest([this.changeModel.changeNum$, this.changeModel.patchNum$])
+        .pipe(
+          switchMap(([changeNum, patchNum]) => {
+            this.changeNum = changeNum;
+            if (!changeNum) return of([undefined, undefined]);
+            const revision = patchNum ?? (CURRENT as RevisionId);
+            return forkJoin([
+              this.restApiService.getPortedComments(changeNum, revision),
+              this.restApiService.getPortedDrafts(changeNum, revision),
+            ]);
+          })
+        )
+        .subscribe(([portedComments, portedDrafts]) =>
+          this.modifyState(s => {
+            s = setPortedComments(s, portedComments);
+            return setPortedDrafts(s, portedDrafts);
+          })
+        )
     );
   }
 
   // Note that this does *not* reload ported comments.
-  async reloadAllComments() {
-    if (!this.changeNum) return;
-    await Promise.all([
-      this.reloadComments(this.changeNum),
-      this.reloadRobotComments(this.changeNum),
-      this.reloadDrafts(this.changeNum),
-    ]);
-  }
-
-  async reloadAllPortedComments() {
-    if (!this.changeNum) return;
-    if (!this.patchNum) return;
-    await Promise.all([
-      this.reloadPortedComments(this.changeNum, this.patchNum),
-      this.reloadPortedDrafts(this.changeNum, this.patchNum),
-    ]);
+  reloadAllComments() {
+    this.reloadAllComments$.next(undefined);
   }
 
   // visible for testing
@@ -519,19 +574,6 @@
     this.setState(reducer({...this.getState()}));
   }
 
-  async reloadComments(changeNum: NumericChangeId): Promise<void> {
-    const comments = await this.restApiService.getDiffComments(changeNum);
-    this.modifyState(s => setComments(s, comments));
-  }
-
-  async reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
-    const robotComments = await this.restApiService.getDiffRobotComments(
-      changeNum
-    );
-    this.reportRobotCommentStats(robotComments);
-    this.modifyState(s => setRobotComments(s, robotComments));
-  }
-
   private reportRobotCommentStats(obj?: PathToRobotCommentsInfoMap) {
     if (!obj) return;
     const comments = Object.values(obj).flat();
@@ -560,33 +602,6 @@
     );
   }
 
-  async reloadDrafts(changeNum: NumericChangeId): Promise<void> {
-    const drafts = await this.restApiService.getDiffDrafts(changeNum);
-    this.modifyState(s => setDrafts(s, drafts));
-  }
-
-  async reloadPortedComments(
-    changeNum: NumericChangeId,
-    patchNum = CURRENT as RevisionId
-  ): Promise<void> {
-    const portedComments = await this.restApiService.getPortedComments(
-      changeNum,
-      patchNum
-    );
-    this.modifyState(s => setPortedComments(s, portedComments));
-  }
-
-  async reloadPortedDrafts(
-    changeNum: NumericChangeId,
-    patchNum = CURRENT as RevisionId
-  ): Promise<void> {
-    const portedDrafts = await this.restApiService.getPortedDrafts(
-      changeNum,
-      patchNum
-    );
-    this.modifyState(s => setPortedDrafts(s, portedDrafts));
-  }
-
   async restoreDraft(draftId: UrlEncodedCommentId) {
     const found = this.discardedDrafts?.find(d => id(d) === draftId);
     if (!found) throw new Error('discarded draft not found');
@@ -643,9 +658,8 @@
       );
       if (changeNum !== this.changeNum) return draft;
       if (!result.ok) throw new Error('request failed');
-      savedComment = (await this.restApiService.getResponseObject(
-        result
-      )) as unknown as CommentInfo;
+      savedComment = (await readJSONResponsePayload(result))
+        .parsed as unknown as CommentInfo;
     } catch (error) {
       if (showToast) this.handleFailedDraftRequest();
       const draftError: DraftInfo = {...draft, savingState: SavingState.ERROR};
@@ -727,7 +741,10 @@
       comment.id,
       reason
     );
-    this.modifyState(s => updateComment(s, newComment));
+    // Don't update state on server error.
+    if (newComment) {
+      this.modifyState(s => updateComment(s, newComment));
+    }
   }
 
   private report(interaction: Interaction, comment: Comment) {
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 33ec35a..8e9bead 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -45,6 +45,11 @@
 /** Application wide state of plugins. */
 interface PluginsState {
   /**
+   * Initially false. Becomes true, if either all plugins were loaded, or if
+   * loading plugins has timed out. Once true, it will not change again.
+   */
+  pluginsLoaded: boolean;
+  /**
    * List of plugins that have called annotationApi().setCoverageProvider().
    */
   coveragePlugins: CoveragePlugin[];
@@ -84,8 +89,11 @@
 
   public coveragePlugins$ = select(this.state$, state => state.coveragePlugins);
 
+  public pluginsLoaded$ = select(this.state$, state => state.pluginsLoaded);
+
   constructor() {
     super({
+      pluginsLoaded: false,
       coveragePlugins: [],
       checksPlugins: [],
       suggestionsPlugins: [],
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index 55d387b..cd6a66a 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -29,6 +29,7 @@
 import {define} from '../dependency';
 import {Model} from '../base/model';
 import {isDefined} from '../../types/types';
+import {readJSONResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 export function changeTablePrefs(prefs: Partial<PreferencesInfo>) {
   const cols = prefs.change_table ?? [];
@@ -214,8 +215,8 @@
     return this.restApiService
       .saveDiffPreferences(diffPrefs)
       .then((response: Response) =>
-        this.restApiService.getResponseObject(response).then(obj => {
-          const newPrefs = obj as unknown as DiffPreferencesInfo;
+        readJSONResponsePayload(response).then(obj => {
+          const newPrefs = obj.parsed as unknown as DiffPreferencesInfo;
           if (!newPrefs) return;
           this.setDiffPreferences(newPrefs);
         })
@@ -226,8 +227,8 @@
     return this.restApiService
       .saveEditPreferences(editPrefs)
       .then((response: Response) => {
-        this.restApiService.getResponseObject(response).then(obj => {
-          const newPrefs = obj as unknown as EditPreferencesInfo;
+        readJSONResponsePayload(response).then(obj => {
+          const newPrefs = obj.parsed as unknown as EditPreferencesInfo;
           if (!newPrefs) return;
           this.setEditPreferences(newPrefs);
         });
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index a5dc421..9004eec 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -30,21 +30,21 @@
     "@polymer/paper-toggle-button": "^3.0.1",
     "@polymer/paper-tooltip": "^3.0.1",
     "@polymer/polymer": "^3.5.1",
-    "@types/resemblejs": "^4.1.0",
-    "@types/resize-observer-browser": "^0.1.7",
+    "@types/resemblejs": "^4.1.3",
+    "@types/resize-observer-browser": "^0.1.11",
     "@webcomponents/shadycss": "^1.11.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
-    "highlight.js": "^11.8.0",
+    "highlight.js": "^11.9.0",
     "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates",
     "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text",
     "immer": "^9.0.21",
-    "lit": "^3.0.0",
+    "lit": "^3.1.0",
     "polymer-bridges": "file:../../polymer-bridges",
     "polymer-resin": "^2.0.1",
     "resemblejs": "^5.0.0",
     "rxjs": "^6.6.7",
     "safevalues": "0.3.1",
-    "web-vitals": "^3.4.0"
+    "web-vitals": "^3.5.1"
   },
   "dependencies // comments": {
     "safevalues": [
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 9ab0f64..bdfb870 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -1,5 +1,5 @@
-load("//tools/bzl:genrule2.bzl", "genrule2")
 load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+load("//tools/bzl:genrule2.bzl", "genrule2")
 
 def polygerrit_bundle(name, srcs, outs, entry_point, app_name):
     """Build .zip bundle from source code
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index b7d36f4..b59d7a8b3 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -20,5 +20,6 @@
   PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
   PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
   ML_SUGGESTED_EDIT = 'UiFeature__ml_suggested_edit',
+  ML_SUGGESTED_EDIT_V2 = 'UiFeature__ml_suggested_edit_v2',
   REVISION_PARENTS_DATA = 'UiFeature__revision_parents_data',
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth.ts b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
index a6b4fdd..bc63099 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
@@ -5,33 +5,10 @@
  */
 import {define} from '../../models/dependency';
 import {AuthRequestInit, Finalizable} from '../../types/types';
-export enum AuthType {
-  XSRF_TOKEN = 'xsrf_token',
-  ACCESS_TOKEN = 'access_token',
-}
-
-export enum AuthStatus {
-  UNDETERMINED = 0,
-  AUTHED = 1,
-  NOT_AUTHED = 2,
-  ERROR = 3,
-}
-
-export interface Token {
-  access_token?: string;
-  expires_at?: string;
-}
-
-export type GetTokenCallback = () => Promise<Token | null>;
-
-export interface DefaultAuthOptions {
-  credentials: RequestCredentials;
-}
 
 export const authServiceToken = define<AuthService>('auth-service');
 
 export interface AuthService extends Finalizable {
-  baseUrl: string;
   isAuthed: boolean;
 
   /**
@@ -42,11 +19,6 @@
   clearCache(): void;
 
   /**
-   * Enable cross-domain authentication using OAuth access token.
-   */
-  setup(getToken: GetTokenCallback, defaultOptions: DefaultAuthOptions): void;
-
-  /**
    * Perform network fetch with authentication.
    */
   fetch(url: string, options?: AuthRequestInit): Promise<Response>;
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
index fb53d5e..7570ba3 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -6,21 +6,18 @@
 import {AuthRequestInit, Finalizable} from '../../types/types';
 import {fire} from '../../utils/event-util';
 import {getBaseUrl} from '../../utils/url-util';
-import {
-  AuthService,
-  AuthStatus,
-  AuthType,
-  DefaultAuthOptions,
-  GetTokenCallback,
-  Token,
-} from './gr-auth';
+import {AuthService} from './gr-auth';
 
-export const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
-const MAX_GET_TOKEN_RETRIES = 2;
+const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
 
-interface ValidToken extends Token {
-  access_token: string;
-  expires_at: string;
+const CREDS_EXPIRED_MSG = 'Credentials expired.';
+
+// visible for testing
+export enum AuthStatus {
+  UNDETERMINED = 0,
+  AUTHED = 1,
+  NOT_AUTHED = 2,
+  ERROR = 3,
 }
 
 interface AuthRequestInitWithHeaders extends AuthRequestInit {
@@ -34,46 +31,12 @@
  * Auth class.
  */
 export class Auth implements AuthService, Finalizable {
-  // TODO(dmfilippov): Remove Type and Status properties, expose AuthType and
-  // AuthStatus to API
-  static TYPE = {
-    XSRF_TOKEN: AuthType.XSRF_TOKEN,
-    ACCESS_TOKEN: AuthType.ACCESS_TOKEN,
-  };
-
-  static STATUS = {
-    UNDETERMINED: AuthStatus.UNDETERMINED,
-    AUTHED: AuthStatus.AUTHED,
-    NOT_AUTHED: AuthStatus.NOT_AUTHED,
-    ERROR: AuthStatus.ERROR,
-  };
-
-  static CREDS_EXPIRED_MSG = 'Credentials expired.';
-
   private authCheckPromise?: Promise<boolean>;
 
   private _last_auth_check_time: number = Date.now();
 
   private _status = AuthStatus.UNDETERMINED;
 
-  private retriesLeft = MAX_GET_TOKEN_RETRIES;
-
-  private cachedTokenPromise: Promise<Token | null> | null = null;
-
-  private type?: AuthType;
-
-  private defaultOptions: AuthRequestInit = {};
-
-  private getToken: GetTokenCallback;
-
-  constructor() {
-    this.getToken = () => Promise.resolve(this.cachedTokenPromise);
-  }
-
-  get baseUrl() {
-    return getBaseUrl();
-  }
-
   finalize() {}
 
   /**
@@ -85,7 +48,7 @@
       Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS
     ) {
       // Refetch after last check expired
-      this.authCheckPromise = fetch(`${this.baseUrl}/auth-check`)
+      this.authCheckPromise = fetch(`${getBaseUrl()}/auth-check`)
         .then(res => {
           // Make a call that requires loading the body of the request. This makes it so that the browser
           // can close the request even though callers of this method might only ever read headers.
@@ -99,10 +62,10 @@
           // auth-check will return 204 if authed
           // treat the rest as unauthed
           if (res.status === 204) {
-            this._setStatus(Auth.STATUS.AUTHED);
+            this._setStatus(AuthStatus.AUTHED);
             return true;
           } else {
-            this._setStatus(Auth.STATUS.NOT_AUTHED);
+            this._setStatus(AuthStatus.NOT_AUTHED);
             return false;
           }
         })
@@ -127,35 +90,20 @@
 
     if (this._status === AuthStatus.AUTHED) {
       fire(document, 'auth-error', {
-        message: Auth.CREDS_EXPIRED_MSG,
+        message: CREDS_EXPIRED_MSG,
         action: 'Refresh credentials',
       });
     }
     this._status = status;
   }
 
+  // visible for testing
   get status() {
     return this._status;
   }
 
   get isAuthed() {
-    return this._status === Auth.STATUS.AUTHED;
-  }
-
-  /**
-   * Enable cross-domain authentication using OAuth access token.
-   */
-  setup(getToken: GetTokenCallback, defaultOptions: DefaultAuthOptions) {
-    this.retriesLeft = MAX_GET_TOKEN_RETRIES;
-    if (getToken) {
-      this.type = AuthType.ACCESS_TOKEN;
-      this.cachedTokenPromise = null;
-      this.getToken = getToken;
-    }
-    this.defaultOptions = {};
-    if (defaultOptions) {
-      this.defaultOptions.credentials = defaultOptions.credentials;
-    }
+    return this._status === AuthStatus.AUTHED;
   }
 
   /**
@@ -164,16 +112,9 @@
   fetch(url: string, options?: AuthRequestInit): Promise<Response> {
     const optionsWithHeaders: AuthRequestInitWithHeaders = {
       headers: new Headers(),
-      ...this.defaultOptions,
       ...options,
     };
-    if (this.type === AuthType.ACCESS_TOKEN) {
-      return this._getAccessToken().then(accessToken =>
-        this._fetchWithAccessToken(url, optionsWithHeaders, accessToken)
-      );
-    } else {
-      return this._fetchWithXsrfToken(url, optionsWithHeaders);
-    }
+    return this._fetchWithXsrfToken(url, optionsWithHeaders);
   }
 
   // private but used in test
@@ -191,23 +132,6 @@
     return result;
   }
 
-  // private but used in test
-  _isTokenValid(token: Token | null): token is ValidToken {
-    if (!token) {
-      return false;
-    }
-    if (!token.access_token || !token.expires_at) {
-      return false;
-    }
-
-    const expiration = new Date(Number(token.expires_at) * 1000);
-    if (Date.now() >= expiration.getTime()) {
-      return false;
-    }
-
-    return true;
-  }
-
   private _fetchWithXsrfToken(
     url: string,
     options: AuthRequestInitWithHeaders
@@ -222,72 +146,6 @@
     return this._ensureBodyLoaded(fetch(url, options));
   }
 
-  private _getAccessToken(): Promise<string | null> {
-    if (!this.cachedTokenPromise) {
-      this.cachedTokenPromise = this.getToken();
-    }
-    return this.cachedTokenPromise.then(token => {
-      if (this._isTokenValid(token)) {
-        this.retriesLeft = MAX_GET_TOKEN_RETRIES;
-        return token.access_token;
-      }
-      if (this.retriesLeft > 0) {
-        this.retriesLeft--;
-        this.cachedTokenPromise = null;
-        return this._getAccessToken();
-      }
-      // Fall back to anonymous access.
-      return null;
-    });
-  }
-
-  private _fetchWithAccessToken(
-    url: string,
-    options: AuthRequestInitWithHeaders,
-    accessToken: string | null
-  ): Promise<Response> {
-    const params = [];
-
-    if (accessToken) {
-      params.push(`access_token=${accessToken}`);
-      const baseUrl = this.baseUrl;
-      const pathname = baseUrl
-        ? url.substring(url.indexOf(baseUrl) + baseUrl.length)
-        : url;
-      if (!pathname.startsWith('/a/')) {
-        url = url.replace(pathname, '/a' + pathname);
-      }
-    }
-
-    const method = options.method || 'GET';
-    let contentType = options.headers.get('Content-Type');
-
-    // For all requests with body, ensure json content type.
-    if (!contentType && options.body) {
-      contentType = 'application/json';
-    }
-
-    if (method !== 'GET') {
-      options.method = 'POST';
-      params.push(`$m=${method}`);
-      // If a request is not GET, and does not have a body, ensure text/plain
-      // content type.
-      if (!contentType) {
-        contentType = 'text/plain';
-      }
-    }
-
-    if (contentType) {
-      options.headers.set('Content-Type', 'text/plain');
-      params.push(`$ct=${encodeURIComponent(contentType)}`);
-    }
-
-    if (params.length) {
-      url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&');
-    }
-    return this._ensureBodyLoaded(fetch(url, options));
-  }
-
   private _ensureBodyLoaded(response: Promise<Response>): Promise<Response> {
     return response.then(response => {
       if (!response.ok) {
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
index 9cdd37e..1be9c9e 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
@@ -5,23 +5,14 @@
  */
 import {AuthRequestInit} from '../../types/types';
 import {fire} from '../../utils/event-util';
-import {
-  AuthService,
-  AuthStatus,
-  DefaultAuthOptions,
-  GetTokenCallback,
-} from './gr-auth';
-import {Auth} from './gr-auth_impl';
+import {AuthService} from './gr-auth';
+import {AuthStatus} from './gr-auth_impl';
 
 export class GrAuthMock implements AuthService {
-  baseUrl = '';
-
   private _status = AuthStatus.UNDETERMINED;
 
-  constructor() {}
-
   get isAuthed() {
-    return this._status === Auth.STATUS.AUTHED;
+    return this._status === AuthStatus.AUTHED;
   }
 
   finalize() {}
@@ -30,7 +21,7 @@
     if (this._status === status) return;
     if (this._status === AuthStatus.AUTHED) {
       fire(document, 'auth-error', {
-        message: Auth.CREDS_EXPIRED_MSG,
+        message: 'Credentials expired.',
         action: 'Refresh credentials',
       });
     }
@@ -42,12 +33,12 @@
   }
 
   authCheck() {
-    return this.fetch(`${this.baseUrl}/auth-check`).then(res => {
+    return this.fetch('/auth-check').then(res => {
       if (res.status === 204) {
-        this._setStatus(Auth.STATUS.AUTHED);
+        this._setStatus(AuthStatus.AUTHED);
         return true;
       } else {
-        this._setStatus(Auth.STATUS.NOT_AUTHED);
+        this._setStatus(AuthStatus.NOT_AUTHED);
         return false;
       }
     });
@@ -55,8 +46,6 @@
 
   clearCache() {}
 
-  setup(_getToken: GetTokenCallback, _defaultOptions: DefaultAuthOptions) {}
-
   fetch(_url: string, _options?: AuthRequestInit): Promise<Response> {
     return Promise.resolve(new Response());
   }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
index b9cef86..f50921e 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
@@ -4,10 +4,8 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../test/common-test-setup';
-import {Auth} from './gr-auth_impl';
-import {stubBaseUrl} from '../../test/test-utils';
+import {Auth, AuthStatus} from './gr-auth_impl';
 import {SinonFakeTimers} from 'sinon';
-import {DefaultAuthOptions} from './gr-auth';
 import {assert} from '@open-wc/testing';
 import {AuthRequestInit} from '../../types/types';
 
@@ -28,28 +26,28 @@
       fakeFetch.returns(Promise.resolve({status: 403}));
       const authed = await auth.authCheck();
       assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      assert.equal(auth.status, AuthStatus.NOT_AUTHED);
     });
 
     test('auth-check returns 204', async () => {
       fakeFetch.returns(Promise.resolve({status: 204}));
       const authed = await auth.authCheck();
       assert.isTrue(authed);
-      assert.equal(auth.status, Auth.STATUS.AUTHED);
+      assert.equal(auth.status, AuthStatus.AUTHED);
     });
 
     test('auth-check returns 502', async () => {
       fakeFetch.returns(Promise.resolve({status: 502}));
       const authed = await auth.authCheck();
       assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      assert.equal(auth.status, AuthStatus.NOT_AUTHED);
     });
 
     test('auth-check failed', async () => {
       fakeFetch.returns(Promise.reject(new Error('random error')));
       const authed = await auth.authCheck();
       assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.ERROR);
+      assert.equal(auth.status, AuthStatus.ERROR);
     });
   });
 
@@ -65,42 +63,42 @@
       fakeFetch.returns(Promise.resolve({status: 403}));
       const authed = await auth.authCheck();
       assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      assert.equal(auth.status, AuthStatus.NOT_AUTHED);
       fakeFetch.returns(Promise.resolve({status: 204}));
       const authed2 = await auth.authCheck();
       assert.isFalse(authed2);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      assert.equal(auth.status, AuthStatus.NOT_AUTHED);
     });
 
     test('clearCache should refetch auth-check result', async () => {
       fakeFetch.returns(Promise.resolve({status: 403}));
       const authed = await auth.authCheck();
       assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      assert.equal(auth.status, AuthStatus.NOT_AUTHED);
       fakeFetch.returns(Promise.resolve({status: 204}));
       auth.clearCache();
       const authed2 = await auth.authCheck();
       assert.isTrue(authed2);
-      assert.equal(auth.status, Auth.STATUS.AUTHED);
+      assert.equal(auth.status, AuthStatus.AUTHED);
     });
 
     test('cache expired on auth-check after certain time', async () => {
       fakeFetch.returns(Promise.resolve({status: 403}));
       const authed = await auth.authCheck();
       assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      assert.equal(auth.status, AuthStatus.NOT_AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.resolve({status: 204}));
       const authed2 = await auth.authCheck();
       assert.isTrue(authed2);
-      assert.equal(auth.status, Auth.STATUS.AUTHED);
+      assert.equal(auth.status, AuthStatus.AUTHED);
     });
 
     test('no cache if auth-check failed', async () => {
       fakeFetch.returns(Promise.reject(new Error('random error')));
       const authed = await auth.authCheck();
       assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.ERROR);
+      assert.equal(auth.status, AuthStatus.ERROR);
       assert.equal(fakeFetch.callCount, 1);
       await auth.authCheck();
       assert.equal(fakeFetch.callCount, 2);
@@ -110,14 +108,14 @@
       fakeFetch.returns(Promise.resolve({status: 204}));
       const authed = await auth.authCheck();
       assert.isTrue(authed);
-      assert.equal(auth.status, Auth.STATUS.AUTHED);
+      assert.equal(auth.status, AuthStatus.AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.resolve({status: 403}));
       const emitStub = sinon.stub();
       document.addEventListener('auth-error', emitStub);
       const authed2 = await auth.authCheck();
       assert.isFalse(authed2);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      assert.equal(auth.status, AuthStatus.NOT_AUTHED);
       assert.isTrue(emitStub.called);
       document.removeEventListener('auth-error', emitStub);
     });
@@ -126,7 +124,7 @@
       fakeFetch.returns(Promise.resolve({status: 204}));
       const authed = await auth.authCheck();
       assert.isTrue(authed);
-      assert.equal(auth.status, Auth.STATUS.AUTHED);
+      assert.equal(auth.status, AuthStatus.AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.reject(new Error('random error')));
       const emitStub = sinon.stub();
@@ -134,7 +132,7 @@
       const authed2 = await auth.authCheck();
       assert.isFalse(authed2);
       assert.isTrue(emitStub.called);
-      assert.equal(auth.status, Auth.STATUS.ERROR);
+      assert.equal(auth.status, AuthStatus.ERROR);
       document.removeEventListener('auth-error', emitStub);
     });
 
@@ -142,7 +140,7 @@
       fakeFetch.returns(Promise.resolve({status: 403}));
       const authed = await auth.authCheck();
       assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      assert.equal(auth.status, AuthStatus.NOT_AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.resolve({status: 204}));
       const emitStub = sinon.stub();
@@ -150,7 +148,7 @@
       const authed2 = await auth.authCheck();
       assert.isTrue(authed2);
       assert.isFalse(emitStub.called);
-      assert.equal(auth.status, Auth.STATUS.AUTHED);
+      assert.equal(auth.status, AuthStatus.AUTHED);
       document.removeEventListener('auth-error', emitStub);
     });
 
@@ -158,7 +156,7 @@
       fakeFetch.returns(Promise.resolve({status: 403}));
       const authed = await auth.authCheck();
       assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      assert.equal(auth.status, AuthStatus.NOT_AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.reject(new Error('random error')));
       const emitStub = sinon.stub();
@@ -166,7 +164,7 @@
       const authed2 = await auth.authCheck();
       assert.isFalse(authed2);
       assert.isFalse(emitStub.called);
-      assert.equal(auth.status, Auth.STATUS.ERROR);
+      assert.equal(auth.status, AuthStatus.ERROR);
       document.removeEventListener('auth-error', emitStub);
     });
   });
@@ -196,134 +194,4 @@
       assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
     });
   });
-
-  suite('cors (access token)', () => {
-    let fakeFetch: sinon.SinonStub;
-
-    setup(() => {
-      fakeFetch = sinon
-        .stub(window, 'fetch')
-        .returns(Promise.resolve({...new Response(), ok: true}));
-    });
-
-    let getToken: sinon.SinonStub;
-
-    const makeToken = (accessToken?: string) => {
-      return {
-        access_token: accessToken || 'zbaz',
-        expires_at: new Date(Date.now() + 10e8).getTime(),
-      };
-    };
-
-    setup(() => {
-      getToken = sinon.stub();
-      getToken.returns(Promise.resolve(makeToken()));
-      const defaultOptions: DefaultAuthOptions = {
-        credentials: 'include',
-      };
-      auth.setup(getToken, defaultOptions);
-    });
-
-    test('base url support', async () => {
-      const baseUrl = 'http://foo';
-      stubBaseUrl(baseUrl);
-      await auth.fetch(baseUrl + '/url', {bar: 'bar'} as AuthRequestInit);
-      const [url] = fakeFetch.lastCall.args;
-      assert.equal(url, 'http://foo/a/url?access_token=zbaz');
-    });
-
-    test('fetch not signed in', async () => {
-      getToken.returns(Promise.resolve());
-      await auth.fetch('/url', {bar: 'bar'} as AuthRequestInit);
-      const [url, options] = fakeFetch.lastCall.args;
-      assert.equal(url, '/url');
-      assert.equal(options.bar, 'bar');
-      assert.equal(Object.keys(options.headers).length, 0);
-    });
-
-    test('fetch signed in', async () => {
-      await auth.fetch('/url', {bar: 'bar'} as AuthRequestInit);
-      const [url, options] = fakeFetch.lastCall.args;
-      assert.equal(url, '/a/url?access_token=zbaz');
-      assert.equal(options.bar, 'bar');
-    });
-
-    test('getToken calls are cached', async () => {
-      await Promise.all([auth.fetch('/url-one'), auth.fetch('/url-two')]);
-      assert.equal(getToken.callCount, 1);
-    });
-
-    test('getToken refreshes token', async () => {
-      const isTokenValidStub = sinon.stub(auth, '_isTokenValid');
-      isTokenValidStub
-        .onFirstCall()
-        .returns(true)
-        .onSecondCall()
-        .returns(false)
-        .onThirdCall()
-        .returns(true);
-      await auth.fetch('/url-one');
-      getToken.returns(Promise.resolve(makeToken('bzzbb')));
-      await auth.fetch('/url-two');
-
-      const [[firstUrl], [secondUrl]] = fakeFetch.args;
-      assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
-      assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
-    });
-
-    test('signed in token error falls back to anonymous', async () => {
-      getToken.returns(Promise.resolve('rubbish'));
-      await auth.fetch('/url', {bar: 'bar'} as AuthRequestInit);
-      const [url, options] = fakeFetch.lastCall.args;
-      assert.equal(url, '/url');
-      assert.equal(options.bar, 'bar');
-    });
-
-    test('_isTokenValid', () => {
-      assert.isFalse(auth._isTokenValid(null));
-      assert.isFalse(auth._isTokenValid({}));
-      assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
-      assert.isFalse(
-        auth._isTokenValid({
-          access_token: 'foo',
-          expires_at: `${Date.now() / 1000 - 1}`,
-        })
-      );
-      assert.isTrue(
-        auth._isTokenValid({
-          access_token: 'foo',
-          expires_at: `${Date.now() / 1000 + 1}`,
-        })
-      );
-    });
-
-    test('HTTP PUT with content type', async () => {
-      const originalOptions = {
-        method: 'PUT',
-        headers: new Headers({'Content-Type': 'mail/pigeon'}),
-      };
-      await auth.fetch('/url', originalOptions);
-      assert.isTrue(getToken.called);
-      const [url, options] = fakeFetch.lastCall.args;
-      assert.include(url, '$ct=mail%2Fpigeon');
-      assert.include(url, '$m=PUT');
-      assert.include(url, 'access_token=zbaz');
-      assert.equal(options.method, 'POST');
-      assert.equal(options.headers.get('Content-Type'), 'text/plain');
-    });
-
-    test('HTTP PUT without content type', async () => {
-      const originalOptions = {
-        method: 'PUT',
-      };
-      await auth.fetch('/url', originalOptions);
-      assert.isTrue(getToken.called);
-      const [url, options] = fakeFetch.lastCall.args;
-      assert.include(url, '$ct=text%2Fplain');
-      assert.include(url, '$m=PUT');
-      assert.include(url, 'access_token=zbaz');
-      assert.equal(options.method, 'POST');
-      assert.equal(options.headers.get('Content-Type'), 'text/plain');
-    });
-  });
 });
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index b6bb560..1eb3bc2 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -612,8 +612,9 @@
     this.time(Timing.DIFF_VIEW_CONTENT_DISPLAYED);
     this.time(Timing.DIFF_VIEW_DISPLAYED);
     this.time(Timing.FILE_LIST_DISPLAYED);
-    this.reportRepoName = undefined;
-    this.reportChangeId = undefined;
+
+    this.setRepoName(undefined);
+    this.setChangeId(undefined);
     // reset slow rpc list since here start page loads which report these rpcs
     this.slowRpcList = [];
     this.hiddenDurationTimer.reset();
@@ -1004,12 +1005,17 @@
     );
   }
 
-  setRepoName(repoName: string) {
+  setRepoName(repoName?: string) {
     this.reportRepoName = repoName;
   }
 
-  setChangeId(changeId: NumericChangeId) {
+  setChangeId(changeId?: NumericChangeId) {
+    const originalChangeId = this.reportChangeId;
     this.reportChangeId = changeId;
+
+    if (!!changeId && changeId !== originalChangeId) {
+      this.reportInteraction(Interaction.CHANGE_ID_CHANGED, {changeId});
+    }
   }
 }
 
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 bbb47c2..2b5842a 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
@@ -6,17 +6,6 @@
 /* NB: Order is important, because of namespaced classes. */
 
 import {GrEtagDecorator} from '../../elements/shared/gr-rest-api-interface/gr-etag-decorator';
-import {
-  FetchJSONRequest,
-  FetchParams,
-  FetchPromisesCache,
-  GrRestApiHelper,
-  parsePrefixedJSON,
-  readResponsePayload,
-  SendJSONRequest,
-  SendRequest,
-  SiteBasedCache,
-} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {parseDate} from '../../utils/date-util';
 import {getBaseUrl} from '../../utils/url-util';
@@ -113,7 +102,6 @@
   TagInput,
   TopMenuEntryInfo,
   UrlEncodedCommentId,
-  FixReplacementInfo,
   DraftInfo,
   ListChangesOption,
   ReviewResult,
@@ -124,7 +112,6 @@
   IgnoreWhitespaceType,
 } from '../../types/diff';
 import {
-  CancelConditionCallback,
   GetDiffCommentsOutput,
   GetDiffRobotCommentsOutput,
   RestApiService,
@@ -138,17 +125,26 @@
   ReviewerState,
 } from '../../constants/constants';
 import {firePageError, fireServerError} from '../../utils/event-util';
-import {
-  AuthRequestInit,
-  Finalizable,
-  ParsedChangeInfo,
-} from '../../types/types';
+import {Finalizable, ParsedChangeInfo} from '../../types/types';
 import {ErrorCallback} from '../../api/rest';
 import {addDraftProp} from '../../utils/comment-util';
 import {BaseScheduler, Scheduler} from '../scheduler/scheduler';
 import {MaxInFlightScheduler} from '../scheduler/max-in-flight-scheduler';
 import {escapeAndWrapSearchOperatorValue} from '../../utils/string-util';
 import {FlagsService, KnownExperimentId} from '../flags/flags';
+import {RetryScheduler} from '../scheduler/retry-scheduler';
+import {FixReplacementInfo} from '../../api/rest-api';
+import {
+  FetchParams,
+  FetchPromisesCache,
+  FetchRequest,
+  getFetchOptions,
+  GrRestApiHelper,
+  parsePrefixedJSON,
+  readJSONResponsePayload,
+  SiteBasedCache,
+  throwingErrorCallback,
+} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const MAX_PROJECT_RESULTS = 25;
 
@@ -169,7 +165,7 @@
 let pendingRequest: {[promiseName: string]: Array<Promise<unknown>>} = {}; // Shared across instances.
 let grEtagDecorator = new GrEtagDecorator(); // Shared across instances.
 // TODO: consider changing this to Map()
-let projectLookup: {[changeNum: string]: Promise<RepoName | undefined>} = {}; // Shared across instances.
+let projectLookup: {[changeNum: string]: Promise<RepoName> | undefined} = {}; // Shared across instances.
 
 function suppress404s(res?: Response | null) {
   if (!res || res.status === 404) return;
@@ -177,48 +173,6 @@
   fireServerError(res);
 }
 
-interface FetchChangeJSON {
-  reportEndpointAsIs?: boolean;
-  endpoint: string;
-  anonymizedEndpoint?: string;
-  revision?: RevisionId;
-  changeNum: NumericChangeId;
-  errFn?: ErrorCallback;
-  params?: FetchParams;
-  fetchOptions?: AuthRequestInit;
-  // TODO(TS): The following properties are not used, however some methods
-  // set them to true. They should be either changed to reportEndpointAsIs: true
-  // or deleted. This should be done carefully case by case.
-  reportEndpointAsId?: true;
-}
-
-interface SendChangeRequestBase {
-  patchNum?: PatchSetNum;
-  reportEndpointAsIs?: boolean;
-  endpoint: string;
-  anonymizedEndpoint?: string;
-  changeNum: NumericChangeId;
-  method: HttpMethod | undefined;
-  errFn?: ErrorCallback;
-  headers?: Record<string, string>;
-  contentType?: string;
-  body?: string | object;
-
-  // TODO(TS): The following properties are not used, however some methods
-  // set them to true. They should be either changed to reportEndpointAsIs: true
-  // or deleted. This should be done carefully case by case.
-  reportUrlAsIs?: true;
-  reportEndpointAsId?: true;
-}
-
-interface SendRawChangeRequest extends SendChangeRequestBase {
-  parseResponse?: false | null;
-}
-
-interface SendJSONChangeRequest extends SendChangeRequestBase {
-  parseResponse: true;
-}
-
 interface QueryChangesParams {
   [paramName: string]: string | undefined | number | string[];
   O?: string; // options
@@ -256,8 +210,6 @@
   base?: PatchSetNum;
 }
 
-type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;
-
 export function testOnlyResetGrRestApiSharedObjects(authService: AuthService) {
   siteBasedCache = new SiteBasedCache();
   fetchPromisesCache = new FetchPromisesCache();
@@ -268,11 +220,19 @@
 }
 
 function createReadScheduler() {
-  return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 10);
+  return new RetryScheduler<Response>(
+    new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 10),
+    3 /* maxRetry */,
+    50 /* backoffIntervalMs */
+  );
 }
 
 function createWriteScheduler() {
-  return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5);
+  return new RetryScheduler<Response>(
+    new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5),
+    3 /* maxRetry */,
+    50 /* backoffIntervalMs */
+  );
 }
 
 function createSerializingScheduler() {
@@ -302,32 +262,27 @@
     private readonly authService: AuthService,
     private readonly flagService: FlagsService
   ) {
+    const readScheduler = createReadScheduler();
+    const writeScheduler = createWriteScheduler();
     this._restApiHelper = new GrRestApiHelper(
       this._cache,
       this.authService,
       this._sharedFetchPromises,
-      createReadScheduler(),
-      createWriteScheduler()
+      readScheduler,
+      writeScheduler
     );
     this._serialScheduler = createSerializingScheduler();
   }
 
   finalize() {}
 
-  _fetchSharedCacheURL(
-    req: FetchJSONRequest
-  ): Promise<AccountDetailInfo | ParsedJSON | undefined> {
-    // Cache is shared across instances
-    return this._restApiHelper.fetchCacheURL(req);
-  }
-
-  getResponseObject(response: Response): Promise<ParsedJSON> {
-    return this._restApiHelper.getResponseObject(response);
+  async getResponseObject(response: Response): Promise<ParsedJSON> {
+    return (await readJSONResponsePayload(response)).parsed;
   }
 
   getConfig(noCache?: boolean): Promise<ServerInfo | undefined> {
     if (!noCache) {
-      return this._fetchSharedCacheURL({
+      return this._restApiHelper.fetchCacheJSON({
         url: '/config/server/info',
         reportUrlAsIs: true,
       }) as Promise<ServerInfo | undefined>;
@@ -343,9 +298,7 @@
     repo: RepoName,
     errFn?: ErrorCallback
   ): Promise<ProjectInfo | undefined> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: '/projects/' + encodeURIComponent(repo),
       errFn,
       anonymizedUrl: '/projects/*',
@@ -356,9 +309,7 @@
     repo: RepoName,
     errFn?: ErrorCallback
   ): Promise<ConfigInfo | undefined> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: '/projects/' + encodeURIComponent(repo) + '/config',
       errFn,
       anonymizedUrl: '/projects/*/config',
@@ -366,9 +317,7 @@
   }
 
   getRepoAccess(repo: RepoName): Promise<RepoAccessInfoMap | undefined> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: '/access/?project=' + encodeURIComponent(repo),
       anonymizedUrl: '/access/?project=*',
     }) as Promise<RepoAccessInfoMap | undefined>;
@@ -378,9 +327,7 @@
     repo: RepoName,
     errFn?: ErrorCallback
   ): Promise<DashboardInfo[] | undefined> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
       errFn,
       anonymizedUrl: '/projects/*/dashboards?inherited',
@@ -388,49 +335,55 @@
   }
 
   saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
     const url = `/projects/${encodeURIComponent(repo)}/config`;
     this._cache.delete(url);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: config,
+      }),
       url,
-      body: config,
       anonymizedUrl: '/projects/*/config',
+      reportServerError: true,
     });
   }
 
   runRepoGC(repo: RepoName): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
     const encodeName = encodeURIComponent(repo);
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: '',
+      }),
       url: `/projects/${encodeName}/gc`,
-      body: '',
       anonymizedUrl: '/projects/*/gc',
+      reportServerError: true,
     });
   }
 
   createRepo(config: ProjectInput & {name: RepoName}): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
     const encodeName = encodeURIComponent(config.name);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: config,
+      }),
       url: `/projects/${encodeName}`,
-      body: config,
       anonymizedUrl: '/projects/*',
+      reportServerError: true,
     });
   }
 
   createGroup(config: GroupInput & {name: string}): Promise<Response> {
     const encodeName = encodeURIComponent(config.name);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: config,
+      }),
       url: `/groups/${encodeName}`,
-      body: config,
       anonymizedUrl: '/groups/*',
+      reportServerError: true,
     });
   }
 
@@ -446,28 +399,30 @@
   }
 
   deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
     const encodeName = encodeURIComponent(repo);
     const encodeRef = encodeURIComponent(ref);
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.DELETE,
+        body: '',
+      }),
       url: `/projects/${encodeName}/branches/${encodeRef}`,
-      body: '',
       anonymizedUrl: '/projects/*/branches/*',
+      reportServerError: true,
     });
   }
 
   deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
     const encodeName = encodeURIComponent(repo);
     const encodeRef = encodeURIComponent(ref);
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.DELETE,
+        body: '',
+      }),
       url: `/projects/${encodeName}/tags/${encodeRef}`,
-      body: '',
       anonymizedUrl: '/projects/*/tags/*',
+      reportServerError: true,
     });
   }
 
@@ -476,15 +431,16 @@
     branch: BranchName,
     revision: BranchInput
   ): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
     const encodeName = encodeURIComponent(name);
     const encodeBranch = encodeURIComponent(branch);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: revision,
+      }),
       url: `/projects/${encodeName}/branches/${encodeBranch}`,
-      body: revision,
       anonymizedUrl: '/projects/*/branches/*',
+      reportServerError: true,
     });
   }
 
@@ -493,15 +449,16 @@
     tag: string,
     revision: TagInput
   ): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
     const encodeName = encodeURIComponent(name);
     const encodeTag = encodeURIComponent(tag);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: revision,
+      }),
       url: `/projects/${encodeName}/tags/${encodeTag}`,
-      body: revision,
       anonymizedUrl: '/projects/*/tags/*',
+      reportServerError: true,
     });
   }
 
@@ -512,9 +469,9 @@
       url: `/groups/?owned&g=${encodeName}`,
       anonymizedUrl: '/groups/owned&g=*',
     };
-    return this._fetchSharedCacheURL(req).then(configs =>
-      hasOwnProperty(configs, groupName)
-    );
+    return this._restApiHelper
+      .fetchCacheJSON(req)
+      .then(configs => hasOwnProperty(configs, groupName));
   }
 
   getGroupMembers(groupName: GroupId | GroupName): Promise<AccountInfo[]> {
@@ -536,11 +493,14 @@
 
   saveGroupName(groupId: GroupId | GroupName, name: string): Promise<Response> {
     const encodeId = encodeURIComponent(groupId);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {name},
+      }),
       url: `/groups/${encodeId}/name`,
-      body: {name},
       anonymizedUrl: '/groups/*/name',
+      reportServerError: true,
     });
   }
 
@@ -549,11 +509,14 @@
     ownerId: string
   ): Promise<Response> {
     const encodeId = encodeURIComponent(groupId);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {owner: ownerId},
+      }),
       url: `/groups/${encodeId}/owner`,
-      body: {owner: ownerId},
       anonymizedUrl: '/groups/*/owner',
+      reportServerError: true,
     });
   }
 
@@ -562,11 +525,14 @@
     description: string
   ): Promise<Response> {
     const encodeId = encodeURIComponent(groupId);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {description},
+      }),
       url: `/groups/${encodeId}/description`,
-      body: {description},
       anonymizedUrl: '/groups/*/description',
+      reportServerError: true,
     });
   }
 
@@ -575,11 +541,14 @@
     options: GroupOptionsInput
   ): Promise<Response> {
     const encodeId = encodeURIComponent(groupId);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: options,
+      }),
       url: `/groups/${encodeId}/options`,
-      body: options,
       anonymizedUrl: '/groups/*/options',
+      reportServerError: true,
     });
   }
 
@@ -587,7 +556,7 @@
     group: EncodedGroupId,
     errFn?: ErrorCallback
   ): Promise<GroupAuditEventInfo[] | undefined> {
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: `/groups/${group}/log.audit`,
       errFn,
       anonymizedUrl: '/groups/*/log.audit',
@@ -597,15 +566,16 @@
   saveGroupMember(
     groupName: GroupId | GroupName,
     groupMember: AccountId
-  ): Promise<AccountInfo> {
+  ): Promise<AccountInfo | undefined> {
     const encodeName = encodeURIComponent(groupName);
     const encodeMember = encodeURIComponent(`${groupMember}`);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: {
+        method: HttpMethod.PUT,
+      },
       url: `/groups/${encodeName}/members/${encodeMember}`,
-      parseResponse: true,
       anonymizedUrl: '/groups/*/members/*',
-    }) as unknown as Promise<AccountInfo>;
+    }) as unknown as Promise<AccountInfo | undefined>;
   }
 
   saveIncludedGroup(
@@ -616,19 +586,16 @@
     const encodeName = encodeURIComponent(groupName);
     const encodeIncludedGroup = encodeURIComponent(includedGroup);
     const req = {
-      method: HttpMethod.PUT,
+      fetchOptions: {
+        method: HttpMethod.PUT,
+      },
       url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
       errFn,
       anonymizedUrl: '/groups/*/groups/*',
     };
-    return this._restApiHelper.send(req).then(response => {
-      if (response?.ok) {
-        return this.getResponseObject(
-          response
-        ) as unknown as Promise<GroupInfo>;
-      }
-      return undefined;
-    });
+    return this._restApiHelper.fetchJSON(req) as unknown as Promise<
+      GroupInfo | undefined
+    >;
   }
 
   deleteGroupMember(
@@ -637,10 +604,13 @@
   ): Promise<Response> {
     const encodeName = encodeURIComponent(groupName);
     const encodeMember = encodeURIComponent(`${groupMember}`);
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
+    return this._restApiHelper.fetch({
+      fetchOptions: {
+        method: HttpMethod.DELETE,
+      },
       url: `/groups/${encodeName}/members/${encodeMember}`,
       anonymizedUrl: '/groups/*/members/*',
+      reportServerError: true,
     });
   }
 
@@ -650,15 +620,18 @@
   ): Promise<Response> {
     const encodeName = encodeURIComponent(groupName);
     const encodeIncludedGroup = encodeURIComponent(includedGroup);
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
+    return this._restApiHelper.fetch({
+      fetchOptions: {
+        method: HttpMethod.DELETE,
+      },
       url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
       anonymizedUrl: '/groups/*/groups/*',
+      reportServerError: true,
     });
   }
 
   getVersion(): Promise<string | undefined> {
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: '/config/server/version',
       reportUrlAsIs: true,
     }) as Promise<string | undefined>;
@@ -667,7 +640,7 @@
   getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> {
     return this.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
-        return this._fetchSharedCacheURL({
+        return this._restApiHelper.fetchCacheJSON({
           url: '/accounts/self/preferences.diff',
           reportUrlAsIs: true,
         }) as Promise<DiffPreferencesInfo | undefined>;
@@ -679,7 +652,7 @@
   getEditPreferences(): Promise<EditPreferencesInfo | undefined> {
     return this.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
-        return this._fetchSharedCacheURL({
+        return this._restApiHelper.fetchCacheJSON({
           url: '/accounts/self/preferences.edit',
           reportUrlAsIs: true,
         }) as Promise<EditPreferencesInfo | undefined>;
@@ -697,44 +670,46 @@
       prefs.download_scheme = prefs.download_scheme.toLowerCase();
     }
 
-    return this._restApiHelper
-      .send({
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
         method: HttpMethod.PUT,
-        url: '/accounts/self/preferences',
         body: prefs,
-        reportUrlAsIs: true,
-      })
-      .then((response: Response) =>
-        this.getResponseObject(response).then(
-          obj => obj as unknown as PreferencesInfo
-        )
-      );
+      }),
+      url: '/accounts/self/preferences',
+      reportUrlAsIs: true,
+    }) as unknown as Promise<PreferencesInfo | undefined>;
   }
 
   saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response> {
     // Invalidate the cache.
     this._cache.delete('/accounts/self/preferences.diff');
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: prefs,
+      }),
       url: '/accounts/self/preferences.diff',
-      body: prefs,
       reportUrlAsIs: true,
+      reportServerError: true,
     });
   }
 
   saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response> {
     // Invalidate the cache.
     this._cache.delete('/accounts/self/preferences.edit');
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: prefs,
+      }),
       url: '/accounts/self/preferences.edit',
-      body: prefs,
       reportUrlAsIs: true,
+      reportServerError: true,
     });
   }
 
   getAccount(): Promise<AccountDetailInfo | undefined> {
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: '/accounts/self/detail',
       reportUrlAsIs: true,
       errFn: resp => {
@@ -746,7 +721,7 @@
   }
 
   getAvatarChangeUrl() {
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: '/accounts/self/avatar.change.url',
       reportUrlAsIs: true,
       errFn: resp => {
@@ -764,29 +739,34 @@
     }) as Promise<AccountExternalIdInfo[] | undefined>;
   }
 
-  deleteAccount() {
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
+  deleteAccount(): Promise<Response> {
+    return this._restApiHelper.fetch({
+      fetchOptions: {
+        method: HttpMethod.DELETE,
+      },
       url: '/accounts/self',
       reportUrlAsIs: true,
+      reportServerError: true,
     });
   }
 
-  deleteAccountIdentity(id: string[]) {
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
+  deleteAccountIdentity(id: string[]): Promise<Response> {
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: id,
+      }),
       url: '/accounts/self/external.ids:delete',
-      body: id,
-      parseResponse: true,
       reportUrlAsIs: true,
-    }) as Promise<unknown>;
+      reportServerError: true,
+    });
   }
 
   getAccountDetails(
     userId: AccountId | EmailAddress,
     errFn?: ErrorCallback
   ): Promise<AccountDetailInfo | undefined> {
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: `/accounts/${encodeURIComponent(userId)}/detail`,
       anonymizedUrl: '/accounts/*/detail',
       errFn,
@@ -796,7 +776,7 @@
   async getAccountEmails() {
     const isloggedIn = await this.getLoggedIn();
     if (isloggedIn) {
-      return this._fetchSharedCacheURL({
+      return this._restApiHelper.fetchCacheJSON({
         url: '/accounts/self/emails',
         reportUrlAsIs: true,
       }) as Promise<EmailInfo[] | undefined>;
@@ -814,7 +794,7 @@
       })
       .then((capabilities: AccountCapabilityInfo | undefined) => {
         if (capabilities && capabilities.viewSecondaryEmails) {
-          return this._fetchSharedCacheURL({
+          return this._restApiHelper.fetchCacheJSON({
             url: '/accounts/' + email + '/emails',
             reportUrlAsIs: true,
             errFn,
@@ -825,44 +805,51 @@
   }
 
   addAccountEmail(email: string): Promise<Response> {
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: {
+        method: HttpMethod.PUT,
+      },
       url: '/accounts/self/emails/' + encodeURIComponent(email),
       anonymizedUrl: '/account/self/emails/*',
+      reportServerError: true,
     });
   }
 
   deleteAccountEmail(email: string): Promise<Response> {
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
+    return this._restApiHelper.fetch({
+      fetchOptions: {
+        method: HttpMethod.DELETE,
+      },
       url: '/accounts/self/emails/' + encodeURIComponent(email),
       anonymizedUrl: '/accounts/self/email/*',
+      reportServerError: true,
     });
   }
 
-  setPreferredAccountEmail(email: string): Promise<void> {
-    // TODO(TS): add correct error handling
-    const encodedEmail = encodeURIComponent(email);
-    const req = {
-      method: HttpMethod.PUT,
-      url: `/accounts/self/emails/${encodedEmail}/preferred`,
+  async setPreferredAccountEmail(email: string): Promise<void> {
+    await this._restApiHelper.fetch({
+      fetchOptions: {
+        method: HttpMethod.PUT,
+      },
+      url: `/accounts/self/emails/${encodeURIComponent(email)}/preferred`,
       anonymizedUrl: '/accounts/self/emails/*/preferred',
-    };
-    return this._restApiHelper.send(req).then(() => {
-      // If result of getAccountEmails is in cache, update it in the cache
-      // so we don't have to invalidate it.
-      const cachedEmails = this._cache.get('/accounts/self/emails');
-      if (cachedEmails) {
-        const emails = cachedEmails.map(entry => {
-          if (entry.email === email) {
-            return {email: entry.email, preferred: true};
-          } else {
-            return {email: entry.email, preferred: false};
-          }
-        });
-        this._cache.set('/accounts/self/emails', emails);
-      }
+      reportServerError: true,
     });
+    // If result of getAccountEmails is in cache, update it in the cache
+    // so we don't have to invalidate it.
+    const cachedEmails = this._cache.get(
+      '/accounts/self/emails'
+    ) as unknown as EmailInfo[];
+    if (cachedEmails) {
+      const emails = cachedEmails.map(entry => {
+        if (entry.email === email) {
+          return {email: entry.email, preferred: true};
+        } else {
+          return {email: entry.email, preferred: false};
+        }
+      });
+      this._cache.set('/accounts/self/emails', emails as unknown as ParsedJSON);
+    }
   }
 
   _updateCachedAccount(obj: Partial<AccountDetailInfo>): void {
@@ -875,68 +862,90 @@
     }
   }
 
-  setAccountName(name: string): Promise<void> {
-    // TODO(TS): add correct error handling
-    const req: SendJSONRequest = {
-      method: HttpMethod.PUT,
+  async setAccountName(name: string): Promise<void> {
+    const response = await this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {name},
+      }),
       url: '/accounts/self/name',
-      body: {name},
-      parseResponse: true,
       reportUrlAsIs: true,
-    };
-    return this._restApiHelper
-      .send(req)
-      .then(newName =>
-        this._updateCachedAccount({name: newName as unknown as string})
-      );
+      reportServerError: true,
+    });
+    if (!response.ok) {
+      return;
+    }
+    let newName = undefined;
+    // If the name was deleted server returns 204
+    if (response.status !== 204) {
+      newName = (await readJSONResponsePayload(response))
+        .parsed as unknown as string;
+    }
+    this._updateCachedAccount({name: newName});
   }
 
-  setAccountUsername(username: string): Promise<void> {
-    // TODO(TS): add correct error handling
-    const req: SendJSONRequest = {
-      method: HttpMethod.PUT,
+  async setAccountUsername(username: string): Promise<void> {
+    const response = await this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {username},
+      }),
       url: '/accounts/self/username',
-      body: {username},
-      parseResponse: true,
       reportUrlAsIs: true,
-    };
-    return this._restApiHelper
-      .send(req)
-      .then(newName =>
-        this._updateCachedAccount({username: newName as unknown as string})
-      );
+    });
+    if (!response.ok) {
+      return;
+    }
+    let newName = undefined;
+    // If the name was deleted server returns 204
+    if (response.status !== 204) {
+      newName = (await readJSONResponsePayload(response))
+        .parsed as unknown as string;
+    }
+    this._updateCachedAccount({username: newName});
   }
 
-  setAccountDisplayName(displayName: string): Promise<void> {
-    // TODO(TS): add correct error handling
-    const req: SendJSONRequest = {
-      method: HttpMethod.PUT,
+  async setAccountDisplayName(displayName: string): Promise<void> {
+    const response = await this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {display_name: displayName},
+      }),
       url: '/accounts/self/displayname',
-      body: {display_name: displayName},
-      parseResponse: true,
       reportUrlAsIs: true,
-    };
-    return this._restApiHelper.send(req).then(newName =>
-      this._updateCachedAccount({
-        display_name: newName as unknown as string,
-      })
-    );
+      reportServerError: true,
+    });
+    if (!response.ok) {
+      return;
+    }
+    let newName = undefined;
+    // If the name was deleted server returns 204
+    if (response.status !== 204) {
+      newName = (await readJSONResponsePayload(response))
+        .parsed as unknown as string;
+    }
+    this._updateCachedAccount({display_name: newName});
   }
 
-  setAccountStatus(status: string): Promise<void> {
-    // TODO(TS): add correct error handling
-    const req: SendJSONRequest = {
-      method: HttpMethod.PUT,
+  async setAccountStatus(status: string): Promise<void> {
+    const response = await this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {status},
+      }),
       url: '/accounts/self/status',
-      body: {status},
-      parseResponse: true,
       reportUrlAsIs: true,
-    };
-    return this._restApiHelper
-      .send(req)
-      .then(newStatus =>
-        this._updateCachedAccount({status: newStatus as unknown as string})
-      );
+    });
+    if (!response.ok) {
+      return;
+    }
+    let newStatus = undefined;
+    // If the status was deleted server returns 204
+    if (response.status !== 204) {
+      newStatus = (await readJSONResponsePayload(response))
+        .parsed as unknown as string;
+    }
+    this._updateCachedAccount({status: newStatus});
   }
 
   getAccountStatus(userId: AccountId) {
@@ -961,11 +970,14 @@
   }
 
   saveAccountAgreement(name: ContributorAgreementInput): Promise<Response> {
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: name,
+      }),
       url: '/accounts/self/agreements',
-      body: name,
       reportUrlAsIs: true,
+      reportServerError: true,
     });
   }
 
@@ -977,7 +989,7 @@
       queryString =
         '?q=' + params.map(param => encodeURIComponent(param)).join('&q=');
     }
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: '/accounts/self/capabilities' + queryString,
       anonymizedUrl: '/accounts/self/capabilities?q=*',
     }) as Promise<AccountCapabilityInfo | undefined>;
@@ -1003,7 +1015,7 @@
   }
 
   getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: '/config/server/preferences',
       reportUrlAsIs: true,
     }) as Promise<PreferencesInfo | undefined>;
@@ -1013,7 +1025,7 @@
     return this.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
-        return this._fetchSharedCacheURL(req).then(res => {
+        return this._restApiHelper.fetchCacheJSON(req).then(res => {
           if (!res) {
             return res;
           }
@@ -1026,7 +1038,7 @@
   }
 
   getWatchedProjects() {
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: '/accounts/self/watched.projects',
       reportUrlAsIs: true,
     }) as unknown as Promise<ProjectWatchInfo[] | undefined>;
@@ -1034,22 +1046,26 @@
 
   saveWatchedProjects(
     projects: ProjectWatchInfo[]
-  ): Promise<ProjectWatchInfo[]> {
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
+  ): Promise<ProjectWatchInfo[] | undefined> {
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: projects,
+      }),
       url: '/accounts/self/watched.projects',
-      body: projects,
-      parseResponse: true,
       reportUrlAsIs: true,
-    }) as unknown as Promise<ProjectWatchInfo[]>;
+    }) as unknown as Promise<ProjectWatchInfo[] | undefined>;
   }
 
   deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response> {
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: projects,
+      }),
       url: '/accounts/self/watched.projects:delete',
-      body: projects,
       reportUrlAsIs: true,
+      reportServerError: true,
     });
   }
 
@@ -1198,7 +1214,7 @@
    */
   _maybeInsertInLookup(change: ChangeInfo): void {
     if (change?.project && change._number) {
-      this.setInProjectLookup(change._number, change.project);
+      this.addRepoNameToCache(change._number, change.project);
     }
   }
 
@@ -1214,18 +1230,12 @@
 
   async getChangeDetail(
     changeNum?: NumericChangeId,
-    errFn?: ErrorCallback,
-    cancelCondition?: CancelConditionCallback
+    errFn?: ErrorCallback
   ): Promise<ParsedChangeInfo | undefined> {
     if (!changeNum) return;
     const optionsHex = await this.getChangeOptionsHex();
 
-    return this._getChangeDetail(
-      changeNum,
-      optionsHex,
-      errFn,
-      cancelCondition
-    ).then(detail =>
+    return this._getChangeDetail(changeNum, optionsHex, errFn).then(detail =>
       // detail has ChangeViewChangeInfo type because the optionsHex always
       // includes ALL_REVISIONS flag.
       GrReviewerUpdatesParser.parse(detail as ChangeViewChangeInfo)
@@ -1270,6 +1280,7 @@
       ListChangesOption.DETAILED_LABELS,
       ListChangesOption.DOWNLOAD_COMMANDS,
       ListChangesOption.MESSAGES,
+      ListChangesOption.REVIEWER_UPDATES,
       ListChangesOption.SUBMITTABLE,
       ListChangesOption.WEB_LINKS,
       ListChangesOption.SKIP_DIFFSTAT,
@@ -1298,6 +1309,7 @@
       'DETAILED_ACCOUNTS',
       'DOWNLOAD_COMMANDS',
       'MESSAGES',
+      'REVIEWER_UPDATES',
       'SUBMITTABLE',
       'WEB_LINKS',
       'SKIP_DIFFSTAT',
@@ -1318,22 +1330,20 @@
   _getChangeDetail(
     changeNum: NumericChangeId,
     optionsHex: string,
-    errFn?: ErrorCallback,
-    cancelCondition?: CancelConditionCallback
+    errFn?: ErrorCallback
   ): Promise<ChangeInfo | undefined> {
     return this.getChangeActionURL(changeNum, undefined, '/detail').then(
       url => {
         const params: FetchParams = {O: optionsHex};
         const urlWithParams = this._restApiHelper.urlWithParams(url, params);
-        const req: FetchJSONRequest = {
+        const req: FetchRequest = {
           url,
           errFn,
-          cancelCondition,
           params,
           fetchOptions: this._etags.getOptions(urlWithParams),
           anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
         };
-        return this._restApiHelper.fetchRawJSON(req).then(response => {
+        return this._restApiHelper.fetch(req).then(response => {
           if (response?.status === 304) {
             return parsePrefixedJSON(
               // urlWithParams already cached
@@ -1354,32 +1364,34 @@
             return Promise.resolve(undefined);
           }
 
-          return readResponsePayload(response).then(payload => {
-            if (!payload) {
-              return undefined;
-            }
-            this._etags.collect(urlWithParams, response, payload.raw);
-            // TODO(TS): Why it is always change info?
-            this._maybeInsertInLookup(payload.parsed as unknown as ChangeInfo);
+          return readJSONResponsePayload(response)
+            .then(payload => {
+              this._etags.collect(urlWithParams, response, payload.raw);
+              this._maybeInsertInLookup(
+                payload.parsed as unknown as ChangeInfo
+              );
 
-            return payload.parsed as unknown as ChangeInfo;
-          });
+              return payload.parsed as unknown as ChangeInfo;
+            })
+            .catch(() => undefined);
         });
       }
     );
   }
 
-  getChangeCommitInfo(changeNum: NumericChangeId, patchNum: PatchSetNum) {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/commit?links',
-      revision: patchNum,
-      reportEndpointAsIs: true,
+  async getChangeCommitInfo(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<CommitInfo | undefined> {
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetchJSON({
+      url: `${url}/commit?links`,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/commit?links`,
       errFn: suppress404s,
     }) as Promise<CommitInfo | undefined>;
   }
 
-  getChangeFiles(
+  async getChangeFiles(
     changeNum: NumericChangeId,
     patchRange: PatchRange
   ): Promise<FileNameToFileInfoMap | undefined> {
@@ -1389,44 +1401,45 @@
     } else if (patchRange.basePatchNum !== PARENT) {
       params = {base: patchRange.basePatchNum};
     }
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/files',
-      revision: patchRange.patchNum,
+    const url = await this._changeBaseURL(changeNum, patchRange.patchNum);
+    return this._restApiHelper.fetchJSON({
+      url: `${url}/files`,
       params,
-      reportEndpointAsIs: true,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/files`,
     }) as Promise<FileNameToFileInfoMap | undefined>;
   }
 
-  // TODO(TS): The output type is unclear
-  getChangeEditFiles(
+  async getChangeEditFiles(
     changeNum: NumericChangeId,
     patchRange: PatchRange
   ): Promise<{files: FileNameToFileInfoMap} | undefined> {
-    let endpoint = '/edit?list';
-    let anonymizedEndpoint = endpoint;
+    const changeUrl = await this._changeBaseURL(changeNum);
+    let url = `${changeUrl}/edit?list`;
+    let anonymizedUrl = `${changeUrl}/edit?list`;
     if (patchRange.basePatchNum !== PARENT) {
-      endpoint += '&base=' + encodeURIComponent(`${patchRange.basePatchNum}`);
-      anonymizedEndpoint += '&base=*';
+      url += '&base=' + encodeURIComponent(`${patchRange.basePatchNum}`);
+      anonymizedUrl += '&base=*';
     }
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint,
-      anonymizedEndpoint,
-    }) as Promise<{files: FileNameToFileInfoMap} | undefined>;
+
+    const response = await this._restApiHelper.fetch({url, anonymizedUrl});
+    if (!response.ok || response.status === 204) {
+      return undefined;
+    }
+    return (await readJSONResponsePayload(response)).parsed as unknown as
+      | {files: FileNameToFileInfoMap}
+      | undefined;
   }
 
-  queryChangeFiles(
+  async queryChangeFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     query: string,
     errFn?: ErrorCallback
-  ) {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: `/files?q=${encodeURIComponent(query)}`,
-      revision: patchNum,
-      anonymizedEndpoint: '/files?q=*',
+  ): Promise<string[] | undefined> {
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetchJSON({
+      url: `${url}/files?q=${encodeURIComponent(query)}`,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/files?q=*`,
       errFn,
     }) as Promise<string[] | undefined>;
   }
@@ -1443,19 +1456,15 @@
     return this.getChangeFiles(changeNum, patchRange);
   }
 
-  getChangeRevisionActions(
+  async getChangeRevisionActions(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
   ): Promise<ActionNameToActionInfoMap | undefined> {
-    const req: FetchChangeJSON = {
-      changeNum,
-      endpoint: '/actions',
-      revision: patchNum,
-      reportEndpointAsIs: true,
-    };
-    return this._getChangeURLAndFetch(req) as Promise<
-      ActionNameToActionInfoMap | undefined
-    >;
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetchJSON({
+      url: `${url}/actions`,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/actions`,
+    }) as Promise<ActionNameToActionInfoMap | undefined>;
   }
 
   getChangeSuggestedReviewers(
@@ -1484,7 +1493,7 @@
     );
   }
 
-  _getChangeSuggestedGroup(
+  async _getChangeSuggestedGroup(
     reviewerState: ReviewerState,
     changeNum: NumericChangeId,
     inputVal: string,
@@ -1499,22 +1508,22 @@
     if (inputVal) {
       params.q = inputVal;
     }
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/suggest_reviewers',
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetchJSON({
+      url: `${url}/suggest_reviewers`,
       params,
-      reportEndpointAsIs: true,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/suggest_reviewers`,
       errFn,
     }) as Promise<SuggestedReviewerInfo[] | undefined>;
   }
 
-  getChangeIncludedIn(
+  async getChangeIncludedIn(
     changeNum: NumericChangeId
   ): Promise<IncludedInInfo | undefined> {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/in',
-      reportEndpointAsIs: true,
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetchJSON({
+      url: `${url}/in`,
+      anonymizedUrl: `${url}/in`,
     }) as Promise<IncludedInInfo | undefined>;
   }
 
@@ -1583,7 +1592,7 @@
   getGroups(filter: string, groupsPerPage: number, offset?: number) {
     const url = this._getGroupsUrl(filter, groupsPerPage, offset);
 
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url,
       anonymizedUrl: '/groups/?*',
     }) as Promise<GroupNameToGroupInfoMap | undefined>;
@@ -1596,21 +1605,17 @@
     errFn?: ErrorCallback
   ): Promise<ProjectInfoWithName[] | undefined> {
     const [isQuery, url] = this._getReposUrl(filter, reposPerPage, offset);
-
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-
     // If the request is a query then return the response directly as the result
     // will already be the expected array. If it is not a query, transform the
     // map to an array.
     if (isQuery) {
-      return this._fetchSharedCacheURL({
+      return this._restApiHelper.fetchCacheJSON({
         url,
         anonymizedUrl: '/projects/?*',
         errFn,
       }) as Promise<ProjectInfoWithName[] | undefined>;
     } else {
-      const result = await (this._fetchSharedCacheURL({
+      const result = await (this._restApiHelper.fetchCacheJSON({
         url,
         anonymizedUrl: '/projects/?*',
         errFn,
@@ -1626,13 +1631,14 @@
   }
 
   setRepoHead(repo: RepoName, ref: GitRef) {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {ref},
+      }),
       url: `/projects/${encodeURIComponent(repo)}/HEAD`,
-      body: {ref},
       anonymizedUrl: '/projects/*/HEAD',
+      reportServerError: true,
     });
   }
 
@@ -1648,8 +1654,6 @@
     filter = this._computeFilter(filter);
     const encodedRepo = encodeURIComponent(repo);
     const url = `/projects/${encodedRepo}/branches?n=${count}&S=${offset}${filter}`;
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
     return this._restApiHelper.fetchJSON({
       url,
       errFn,
@@ -1670,8 +1674,6 @@
     const encodedFilter = this._computeFilter(filter);
     const url =
       `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` + encodedFilter;
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
     return this._restApiHelper.fetchJSON({
       url,
       errFn,
@@ -1700,8 +1702,6 @@
     repoName: RepoName,
     errFn?: ErrorCallback
   ): Promise<ProjectAccessInfo | undefined> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
     return this._restApiHelper.fetchJSON({
       url: `/projects/${encodeURIComponent(repoName)}/access`,
       errFn,
@@ -1713,27 +1713,29 @@
     repoName: RepoName,
     repoInfo: ProjectAccessInput
   ): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: repoInfo,
+      }),
       url: `/projects/${encodeURIComponent(repoName)}/access`,
-      body: repoInfo,
       anonymizedUrl: '/projects/*/access',
+      reportServerError: true,
     });
   }
 
   setRepoAccessRightsForReview(
     projectName: RepoName,
     projectInfo: ProjectAccessInput
-  ): Promise<ChangeInfo> {
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+  ): Promise<ChangeInfo | undefined> {
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: projectInfo,
+      }),
       url: `/projects/${encodeURIComponent(projectName)}/access:review`,
-      body: projectInfo,
-      parseResponse: true,
       anonymizedUrl: '/projects/*/access:review',
-    }) as unknown as Promise<ChangeInfo>;
+    }) as unknown as Promise<ChangeInfo | undefined>;
   }
 
   getSuggestedGroups(
@@ -1778,7 +1780,7 @@
     });
   }
 
-  async getSuggestedAccounts(
+  async queryAccounts(
     inputVal: string,
     n?: number,
     canSee?: NumericChangeId,
@@ -1796,7 +1798,7 @@
       queryParams.push(`${escapeAndWrapSearchOperatorValue(inputVal)}`);
     }
     if (canSee) {
-      const project = await this.getFromProjectLookup(canSee);
+      const project = await this.getRepoName(canSee);
       queryParams.push(`cansee:${project}~${canSee}`);
     }
     if (filterActive) {
@@ -1815,6 +1817,19 @@
     }) as Promise<AccountInfo[] | undefined>;
   }
 
+  getAccountSuggestions(inputVal: string): Promise<AccountInfo[] | undefined> {
+    const params: QueryAccountsParams = {suggest: undefined, q: ''};
+    inputVal = inputVal?.trim() ?? '';
+    if (inputVal.length > 0) {
+      params.q = inputVal;
+    }
+    if (!params.q) return Promise.resolve([]);
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/',
+      params,
+    }) as Promise<AccountInfo[] | undefined>;
+  }
+
   addChangeReviewer(
     changeNum: NumericChangeId,
     reviewerID: AccountId | EmailAddress | GroupId
@@ -1856,32 +1871,36 @@
             assertNever(method, `Unsupported HTTP method: ${method}`);
         }
 
-        return this._restApiHelper.send({method, url, body});
+        return this._restApiHelper.fetch({
+          fetchOptions: getFetchOptions({method, body}),
+          url,
+          reportServerError: true,
+        });
       }
     );
   }
 
-  getRelatedChanges(
+  async getRelatedChanges(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
   ): Promise<RelatedChangesInfo | undefined> {
     const options = '?o=SUBMITTABLE';
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: `/related${options}`,
-      revision: patchNum,
-      reportEndpointAsIs: true,
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetchJSON({
+      url: `${url}/related${options}`,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/related${options}`,
     }) as Promise<RelatedChangesInfo | undefined>;
   }
 
-  getChangesSubmittedTogether(
+  async getChangesSubmittedTogether(
     changeNum: NumericChangeId,
     options: string[] = ['NON_VISIBLE_CHANGES']
   ): Promise<SubmittedTogetherInfo | undefined> {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: `/submitted_together?o=${options.join('&o=')}`,
-      reportEndpointAsIs: true,
+    const url = await this._changeBaseURL(changeNum);
+    const endpoint = `/submitted_together?o=${options.join('&o=')}`;
+    return this._restApiHelper.fetchJSON({
+      url: `${url}${endpoint}`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}${endpoint}`,
     }) as Promise<SubmittedTogetherInfo | undefined>;
   }
 
@@ -1992,30 +2011,29 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
-  getReviewedFiles(
+  async getReviewedFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
   ): Promise<string[] | undefined> {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/files?reviewed',
-      revision: patchNum,
-      reportEndpointAsIs: true,
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetchJSON({
+      url: `${url}/files?reviewed`,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/files?reviewed`,
     }) as Promise<string[] | undefined>;
   }
 
-  saveFileReviewed(
+  async saveFileReviewed(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     path: string,
     reviewed: boolean
   ): Promise<Response> {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE,
-      patchNum,
-      endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
-      anonymizedEndpoint: '/files/*/reviewed',
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: {method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE},
+      url: `${url}/files/${encodeURIComponent(path)}/reviewed`,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/files/*/reviewed`,
+      reportServerError: true,
     });
   }
 
@@ -2025,7 +2043,7 @@
     review: ReviewInput,
     errFn?: ErrorCallback,
     fetchDetail?: boolean
-  ) {
+  ): Promise<ReviewResult | undefined> {
     if (fetchDetail) {
       review.response_format_options = await this.getResponseFormatOptions();
     }
@@ -2033,41 +2051,39 @@
       this.awaitPendingDiffDrafts(),
       this.getChangeActionURL(changeNum, patchNum, '/review'),
     ];
-    return Promise.all(promises)
-      .then(([, url]) =>
-        this._restApiHelper.send({
+    return Promise.all(promises).then(([, url]) =>
+      this._restApiHelper.fetchJSON({
+        fetchOptions: getFetchOptions({
           method: HttpMethod.POST,
-          url,
           body: review,
-          errFn,
-          parseResponse: true,
-        })
-      )
-      .then(payload => {
-        if (!payload) {
-          return undefined;
-        }
-        return payload as unknown as ReviewResult;
-      });
+        }),
+        url,
+        errFn,
+      })
+    ) as unknown as Promise<ReviewResult | undefined>;
   }
 
-  getChangeEdit(changeNum?: NumericChangeId): Promise<EditInfo | undefined> {
-    if (!changeNum) return Promise.resolve(undefined);
+  async getChangeEdit(
+    changeNum?: NumericChangeId
+  ): Promise<EditInfo | undefined> {
+    if (!changeNum) return undefined;
     const params = {'download-commands': true};
-    return this.getLoggedIn().then(loggedIn => {
-      if (!loggedIn) {
-        return Promise.resolve(undefined);
-      }
-      return this._getChangeURLAndFetch(
-        {
-          changeNum,
-          endpoint: '/edit/',
-          params,
-          reportEndpointAsIs: true,
-        },
-        true
-      ) as Promise<EditInfo | undefined>;
+    const loggedIn = await this.getLoggedIn();
+    if (!loggedIn) {
+      return undefined;
+    }
+    const url = await this._changeBaseURL(changeNum);
+    const response = await this._restApiHelper.fetch({
+      url: `${url}/edit/`,
+      params,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit/`,
     });
+    // If there is no edit patchset 204 is returned.
+    if (!response.ok || response.status === 204) {
+      return undefined;
+    }
+    return (await readJSONResponsePayload(response))
+      .parsed as unknown as EditInfo;
   }
 
   createChange(
@@ -2079,21 +2095,22 @@
     workInProgress?: boolean,
     baseChange?: ChangeId,
     baseCommit?: string
-  ) {
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
+  ): Promise<ChangeInfo | undefined> {
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: {
+          project: repo,
+          branch,
+          subject,
+          topic,
+          is_private: isPrivate,
+          work_in_progress: workInProgress,
+          base_change: baseChange,
+          base_commit: baseCommit,
+        },
+      }),
       url: '/changes/',
-      body: {
-        project: repo,
-        branch,
-        subject,
-        topic,
-        is_private: isPrivate,
-        work_in_progress: workInProgress,
-        base_change: baseChange,
-        base_commit: baseCommit,
-      },
-      parseResponse: true,
       reportUrlAsIs: true,
     }) as unknown as Promise<ChangeInfo | undefined>;
   }
@@ -2111,15 +2128,15 @@
         : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
 
     return promise.then(res => {
-      if (!res || !res.ok) {
+      if (!res.ok) {
         return res;
       }
 
       // The file type (used for syntax highlighting) is identified in the
       // X-FYI-Content-Type header of the response.
       const type = res.headers.get('X-FYI-Content-Type');
-      return this.getResponseObject(res).then(content => {
-        const strContent = content as unknown as string | null;
+      return readJSONResponsePayload(res).then(content => {
+        const strContent = content.parsed as unknown as string | null;
         return {content: strContent, type, ok: true};
       });
     });
@@ -2128,200 +2145,260 @@
   /**
    * Gets a file in a specific change and revision.
    */
-  _getFileInRevision(
+  async _getFileInRevision(
     changeNum: NumericChangeId,
     path: string,
     patchNum: PatchSetNum,
     errFn?: ErrorCallback
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.GET,
-      patchNum,
-      endpoint: `/files/${encodeURIComponent(path)}/content`,
+  ): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        headers: {Accept: 'application/json'},
+      }),
+      url: `${url}/files/${encodeURIComponent(path)}/content`,
       errFn,
-      headers: {Accept: 'application/json'},
-      anonymizedEndpoint: '/files/*/content',
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/files/*/content`,
+      reportServerError: true,
     });
   }
 
   /**
    * Gets a file in a change edit.
    */
-  _getFileInChangeEdit(changeNum: NumericChangeId, path: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.GET,
-      endpoint: '/edit/' + encodeURIComponent(path),
-      headers: {Accept: 'application/json'},
-      anonymizedEndpoint: '/edit/*',
+  async _getFileInChangeEdit(
+    changeNum: NumericChangeId,
+    path: string
+  ): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        headers: {Accept: 'application/json'},
+      }),
+      url: `${url}/edit/${encodeURIComponent(path)}`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit/*`,
+      reportServerError: true,
     });
   }
 
-  rebaseChangeEdit(changeNum: NumericChangeId) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/edit:rebase',
-      reportEndpointAsIs: true,
+  async rebaseChangeEdit(changeNum: NumericChangeId): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: {method: HttpMethod.POST},
+      url: `${url}/edit:rebase`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit:rebase`,
+      reportServerError: true,
     });
   }
 
-  deleteChangeEdit(changeNum: NumericChangeId) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.DELETE,
-      endpoint: '/edit',
-      reportEndpointAsIs: true,
+  async deleteChangeEdit(changeNum: NumericChangeId): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: {method: HttpMethod.DELETE},
+      url: `${url}/edit`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit`,
+      reportServerError: true,
     });
   }
 
-  restoreFileInChangeEdit(changeNum: NumericChangeId, restore_path: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/edit',
-      body: {restore_path},
-      reportEndpointAsIs: true,
+  async restoreFileInChangeEdit(
+    changeNum: NumericChangeId,
+    restore_path: string
+  ): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: {restore_path},
+      }),
+      url: `${url}/edit`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit`,
+      reportServerError: true,
     });
   }
 
-  renameFileInChangeEdit(
+  async renameFileInChangeEdit(
     changeNum: NumericChangeId,
     old_path: string,
     new_path: string
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/edit',
-      body: {old_path, new_path},
-      reportEndpointAsIs: true,
+  ): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: {old_path, new_path},
+      }),
+      url: `${url}/edit`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit`,
+      reportServerError: true,
     });
   }
 
-  deleteFileInChangeEdit(changeNum: NumericChangeId, path: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.DELETE,
-      endpoint: '/edit/' + encodeURIComponent(path),
-      anonymizedEndpoint: '/edit/*',
+  async deleteFileInChangeEdit(
+    changeNum: NumericChangeId,
+    path: string
+  ): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: {method: HttpMethod.DELETE},
+      url: `${url}/edit/${encodeURIComponent(path)}`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit/*`,
+      reportServerError: true,
     });
   }
 
-  saveChangeEdit(changeNum: NumericChangeId, path: string, contents: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      endpoint: '/edit/' + encodeURIComponent(path),
-      body: contents,
-      contentType: 'text/plain',
-      anonymizedEndpoint: '/edit/*',
+  async saveChangeEdit(
+    changeNum: NumericChangeId,
+    path: string,
+    contents: string
+  ): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: contents,
+        contentType: 'text/plain',
+      }),
+      url: `${url}/edit/${encodeURIComponent(path)}`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit/*`,
+      reportServerError: true,
     });
   }
 
-  saveFileUploadChangeEdit(
+  async saveFileUploadChangeEdit(
     changeNum: NumericChangeId,
     path: string,
     content: string
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      endpoint: '/edit/' + encodeURIComponent(path),
-      body: {binary_content: content},
-      anonymizedEndpoint: '/edit/*',
+  ): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {binary_content: content},
+      }),
+      url: `${url}/edit/${encodeURIComponent(path)}`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit/*`,
+      reportServerError: true,
     });
   }
 
-  getFixPreview(
+  async getFixPreview(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     fixReplacementInfos: FixReplacementInfo[]
   ): Promise<FilePathToDiffInfoMap | undefined> {
-    return this._getChangeURLAndSend({
-      method: HttpMethod.POST,
-      changeNum,
-      patchNum,
-      endpoint: '/fix:preview',
-      reportEndpointAsId: true,
-      headers: {Accept: 'application/json'},
-      parseResponse: true,
-      body: {fix_replacement_infos: fixReplacementInfos},
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: {fix_replacement_infos: fixReplacementInfos},
+      }),
+      url: `${url}/fix:preview`,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/fix:preview`,
     }) as Promise<FilePathToDiffInfoMap | undefined>;
   }
 
-  getRobotCommentFixPreview(
+  async getRobotCommentFixPreview(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     fixId: FixId
   ): Promise<FilePathToDiffInfoMap | undefined> {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      revision: patchNum,
-      endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
-      reportEndpointAsId: true,
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    const endpoint = `/fixes/${encodeURIComponent(fixId)}/preview`;
+    return this._restApiHelper.fetchJSON({
+      url: `${url}${endpoint}`,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}${endpoint}`,
     }) as Promise<FilePathToDiffInfoMap | undefined>;
   }
 
-  applyFixSuggestion(
+  async applyFixSuggestion(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     fixReplacementInfos: FixReplacementInfo[]
   ): Promise<Response> {
-    return this._getChangeURLAndSend({
-      method: HttpMethod.POST,
-      changeNum,
-      patchNum,
-      endpoint: '/fix:apply',
-      reportEndpointAsId: true,
-      headers: {Accept: 'application/json'},
-      body: {fix_replacement_infos: fixReplacementInfos},
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        headers: {Accept: 'application/json'},
+        body: {fix_replacement_infos: fixReplacementInfos},
+      }),
+      url: `${url}/fix:apply`,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/fix:apply`,
+      reportServerError: true,
     });
   }
 
-  applyRobotFixSuggestion(
+  async applyRobotFixSuggestion(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     fixId: string
   ): Promise<Response> {
-    return this._getChangeURLAndSend({
-      method: HttpMethod.POST,
-      changeNum,
-      patchNum,
-      endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`,
-      reportEndpointAsId: true,
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    const endpoint = `/fixes/${encodeURIComponent(fixId)}/apply`;
+    return this._restApiHelper.fetch({
+      fetchOptions: {method: HttpMethod.POST},
+      url: `${url}${endpoint}`,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}${endpoint}`,
+      reportServerError: true,
     });
   }
 
-  publishChangeEdit(changeNum: NumericChangeId) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/edit:publish',
-      reportEndpointAsIs: true,
+  async publishChangeEdit(changeNum: NumericChangeId) {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: {method: HttpMethod.POST},
+      url: `${url}/edit:publish`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit:publish`,
+      reportServerError: true,
     });
   }
 
-  putChangeCommitMessage(changeNum: NumericChangeId, message: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      endpoint: '/message',
-      body: {message},
-      reportEndpointAsIs: true,
+  async putChangeCommitMessage(
+    changeNum: NumericChangeId,
+    message: string,
+    committerEmail: string | null
+  ) {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {message, committer_email: committerEmail},
+      }),
+      url: `${url}/message`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/message`,
+      reportServerError: true,
     });
   }
 
-  deleteChangeCommitMessage(
+  async updateIdentityInChangeEdit(
+    changeNum: NumericChangeId,
+    name: string,
+    email: string,
+    type: string
+  ) {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {name, email, type},
+      }),
+      url: `${url}/edit:identity`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/edit:identity`,
+      reportServerError: true,
+    });
+  }
+
+  async deleteChangeCommitMessage(
     changeNum: NumericChangeId,
     messageId: ChangeMessageId
   ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.DELETE,
-      endpoint: `/messages/${messageId}`,
-      reportEndpointAsIs: true,
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: {method: HttpMethod.DELETE},
+      url: `${url}/messages/${messageId}`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/messages/${messageId}`,
+      reportServerError: true,
     });
   }
 
@@ -2332,12 +2409,12 @@
     // Some servers may require the project name to be provided
     // alongside the change number, so resolve the project name
     // first.
-    return this.getFromProjectLookup(changeNum).then(project => {
-      const encodedRepoName = project ? encodeURIComponent(project) + '~' : '';
+    return this.getRepoName(changeNum).then(project => {
+      const encodedRepoName = encodeURIComponent(project) + '~';
       const url = `/accounts/self/starred.changes/${encodedRepoName}${changeNum}`;
       return this._serialScheduler.schedule(() =>
-        this._restApiHelper.send({
-          method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
+        this._restApiHelper.fetch({
+          fetchOptions: {method: starred ? HttpMethod.PUT : HttpMethod.DELETE},
           url,
           anonymizedUrl: '/accounts/self/starred.changes/*',
         })
@@ -2378,24 +2455,27 @@
     contentType?: string,
     headers?: Record<string, string>
   ): Promise<Response | undefined> {
-    return this._restApiHelper.send({
-      method,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method,
+        body,
+        contentType,
+        headers,
+      }),
       url,
-      body,
       errFn,
-      contentType,
-      headers,
+      reportServerError: true,
     });
   }
 
-  getDiff(
+  async getDiff(
     changeNum: NumericChangeId,
     basePatchNum: PatchSetNum,
     patchNum: PatchSetNum,
     path: string,
     whitespace?: IgnoreWhitespaceType,
     errFn?: ErrorCallback
-  ) {
+  ): Promise<DiffInfo | undefined> {
     const params: GetDiffParams = {
       intraline: null,
       whitespace: whitespace || 'IGNORE_NONE',
@@ -2405,24 +2485,19 @@
     } else if (basePatchNum !== PARENT) {
       params.base = basePatchNum;
     }
-    const endpoint = `/files/${encodeURIComponent(path)}/diff`;
-    const req: FetchChangeJSON = {
-      changeNum,
-      endpoint,
-      revision: patchNum,
-      errFn,
+
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetchJSON({
+      // Invalidate the cache if this is the edit patch to make sure we always
+      // get latest.
+      fetchOptions: getFetchOptions({
+        headers: patchNum === EDIT ? {'Cache-Control': 'no-cache'} : undefined,
+      }),
+      url: `${url}/files/${encodeURIComponent(path)}/diff`,
       params,
-      anonymizedEndpoint: '/files/*/diff',
-    };
-
-    // Invalidate the cache if its edit patch to make sure we always get latest.
-    if (patchNum === EDIT) {
-      if (!req.fetchOptions) req.fetchOptions = {};
-      if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
-      req.fetchOptions.headers.append('Cache-Control', 'no-cache');
-    }
-
-    return this._getChangeURLAndFetch(req) as Promise<DiffInfo | undefined>;
+      errFn,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}'/files/*/diff`,
+    }) as Promise<DiffInfo | undefined>;
   }
 
   getDiffComments(
@@ -2553,6 +2628,10 @@
     path?: string
   ): Promise<GetDiffRobotCommentsOutput>;
 
+  /**
+   * Fetches the comments for a given patchNum.
+   * Helper function to make promises more legible.
+   */
   _getDiffComments(
     changeNum: NumericChangeId,
     endpoint: string,
@@ -2567,23 +2646,19 @@
     | PathToRobotCommentsInfoMap
     | undefined
   > {
-    /**
-     * Fetches the comments for a given patchNum.
-     * Helper function to make promises more legible.
-     */
     // We don't want to add accept header, since preloading of comments is
     // working only without accept header.
     const noAcceptHeader = true;
     const fetchComments = (patchNum?: PatchSetNum) =>
-      this._getChangeURLAndFetch(
-        {
-          changeNum,
-          endpoint,
-          revision: patchNum,
-          reportEndpointAsIs: true,
-          params,
-        },
-        noAcceptHeader
+      this._changeBaseURL(changeNum, patchNum).then(url =>
+        this._restApiHelper.fetchJSON(
+          {
+            url: `${url}${endpoint}`,
+            anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}${endpoint}`,
+            params,
+          },
+          noAcceptHeader
+        )
       ) as Promise<
         {[path: string]: CommentInfo[]} | PathToRobotCommentsInfoMap | undefined
       >;
@@ -2607,8 +2682,6 @@
     let fetchPromise;
     fetchPromise = fetchComments(patchNum).then(response => {
       comments = (response && path && response[path]) || [];
-      // TODO(kaspern): Implement this on in the backend so this can
-      // be removed.
       // Sort comments by date so that parent ranges can be propagated
       // in a single pass.
       comments = this._setRanges(comments);
@@ -2642,15 +2715,7 @@
     );
   }
 
-  _getDiffCommentsFetchURL(
-    changeNum: NumericChangeId,
-    endpoint: string,
-    patchNum?: RevisionId
-  ) {
-    return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
-  }
-
-  getPortedComments(
+  async getPortedComments(
     changeNum: NumericChangeId,
     revision: RevisionId
   ): Promise<{[path: string]: CommentInfo[]} | undefined> {
@@ -2659,10 +2724,9 @@
       if (response)
         console.info(`Fetching ported comments failed, ${response.status}`);
     };
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/ported_comments/',
-      revision,
+    const url = await this._changeBaseURL(changeNum, revision);
+    return this._restApiHelper.fetchJSON({
+      url: `${url}/ported_comments/`,
       errFn,
     });
   }
@@ -2678,10 +2742,9 @@
     };
     const loggedIn = await this.getLoggedIn();
     if (!loggedIn) return {};
-    const comments = (await this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/ported_drafts/',
-      revision,
+    const url = await this._changeBaseURL(changeNum, revision);
+    const comments = (await this._restApiHelper.fetchJSON({
+      url: `${url}/ported_drafts/`,
       errFn,
     })) as {[path: string]: CommentInfo[]} | undefined;
     return addDraftProp(comments);
@@ -2753,25 +2816,25 @@
       endpoint += `/${draft.id}`;
       anonymizedEndpoint += '/*';
     }
-    let body;
-    if (method === HttpMethod.PUT) {
-      body = draft;
-    }
 
     if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
       this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
     }
 
-    const req = {
-      changeNum,
-      method,
-      patchNum,
-      endpoint,
-      body,
-      anonymizedEndpoint,
-    };
+    const fetchOptions =
+      method === HttpMethod.PUT
+        ? getFetchOptions({method, body: draft})
+        : {method};
 
-    const promise = this._getChangeURLAndSend(req);
+    const promise = this._changeBaseURL(changeNum, patchNum).then(url =>
+      this._restApiHelper.fetch({
+        fetchOptions,
+        url: `${url}${endpoint}`,
+        anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}${anonymizedEndpoint}`,
+        reportServerError: true,
+      })
+    );
+
     this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
 
     if (isCreate) {
@@ -2888,11 +2951,8 @@
     changeNum: NumericChangeId,
     revisionId?: RevisionId
   ): Promise<string> {
-    return this.getFromProjectLookup(changeNum).then(project => {
-      // TODO(TS): unclear why project can't be null here. Fix it
-      let url = `/changes/${encodeURIComponent(
-        project as RepoName
-      )}~${changeNum}`;
+    return this.getRepoName(changeNum).then(project => {
+      let url = `/changes/${encodeURIComponent(project)}~${changeNum}`;
       if (revisionId) {
         url += `/revisions/${revisionId}`;
       }
@@ -2900,115 +2960,136 @@
     });
   }
 
-  addToAttentionSet(
+  async addToAttentionSet(
     changeNum: NumericChangeId,
     user: AccountId | undefined | null,
     reason: string
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/attention',
-      body: {user, reason},
-      reportUrlAsIs: true,
+  ): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: {user, reason},
+      }),
+      url: `${url}/attention`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/attention`,
+      reportServerError: true,
     });
   }
 
-  removeFromAttentionSet(
+  async removeFromAttentionSet(
     changeNum: NumericChangeId,
     user: AccountId,
     reason: string
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.DELETE,
-      endpoint: `/attention/${user}`,
-      anonymizedEndpoint: '/attention/*',
-      body: {reason},
+  ): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.DELETE,
+        body: {reason},
+      }),
+      url: `${url}/attention/${user}`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/attention/*`,
+      reportServerError: true,
     });
   }
 
-  setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string> {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      endpoint: '/topic',
-      body: {topic},
-      parseResponse: true,
-      reportUrlAsIs: true,
-    }) as unknown as Promise<string>;
-  }
-
-  setChangeHashtag(
+  async setChangeTopic(
     changeNum: NumericChangeId,
-    hashtag: HashtagsInput
-  ): Promise<Hashtag[]> {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/hashtags',
-      body: hashtag,
-      parseResponse: true,
-      reportUrlAsIs: true,
-    }) as unknown as Promise<Hashtag[]>;
+    topic?: string,
+    errFn?: ErrorCallback
+  ): Promise<string | undefined> {
+    const url = await this._changeBaseURL(changeNum);
+    const response = await this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {topic},
+      }),
+      url: `${url}/topic`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/topic`,
+      errFn,
+    });
+    if (!response.ok) {
+      return undefined;
+    }
+    if (response.status === 204) {
+      return '';
+    }
+    return (await readJSONResponsePayload(response))
+      .parsed as unknown as string;
   }
 
-  deleteAccountHttpPassword() {
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
+  removeChangeTopic(
+    changeNum: NumericChangeId,
+    errFn?: ErrorCallback
+  ): Promise<string | undefined> {
+    return this.setChangeTopic(changeNum, '', errFn);
+  }
+
+  async setChangeHashtag(
+    changeNum: NumericChangeId,
+    hashtag: HashtagsInput,
+    errFn?: ErrorCallback
+  ): Promise<Hashtag[] | undefined> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: hashtag,
+      }),
+      url: `${url}/hashtags`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/hashtags`,
+      errFn,
+    }) as unknown as Promise<Hashtag[] | undefined>;
+  }
+
+  deleteAccountHttpPassword(): Promise<Response> {
+    return this._restApiHelper.fetch({
+      fetchOptions: {method: HttpMethod.DELETE},
       url: '/accounts/self/password.http',
       reportUrlAsIs: true,
+      reportServerError: true,
     });
   }
 
-  generateAccountHttpPassword(): Promise<Password> {
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
+  generateAccountHttpPassword(): Promise<Password | undefined> {
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {generate: true},
+      }),
       url: '/accounts/self/password.http',
-      body: {generate: true},
-      parseResponse: true,
       reportUrlAsIs: true,
     }) as Promise<unknown> as Promise<Password>;
   }
 
   getAccountSSHKeys() {
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: '/accounts/self/sshkeys',
       reportUrlAsIs: true,
     }) as Promise<unknown> as Promise<SshKeyInfo[] | undefined>;
   }
 
   addAccountSSHKey(key: string): Promise<SshKeyInfo> {
-    const req = {
-      method: HttpMethod.POST,
+    // By passing throwingErrorCallback we guarantee that response is not-null.
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: key,
+        contentType: 'text/plain',
+      }),
       url: '/accounts/self/sshkeys',
-      body: key,
-      contentType: 'text/plain',
       reportUrlAsIs: true,
-    };
-    return this._restApiHelper
-      .send(req)
-      .then((response: Response | undefined) => {
-        if (!response || (response.status < 200 && response.status >= 300)) {
-          return Promise.reject(new Error('error'));
-        }
-        return this.getResponseObject(
-          response
-        ) as unknown as Promise<SshKeyInfo>;
-      })
-      .then(obj => {
-        if (!obj || !obj.valid) {
-          return Promise.reject(new Error('error'));
-        }
-        return obj;
-      });
+      errFn: throwingErrorCallback,
+    }) as Promise<unknown> as Promise<SshKeyInfo>;
   }
 
-  deleteAccountSSHKey(id: string) {
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
+  deleteAccountSSHKey(id: string): Promise<Response> {
+    return this._restApiHelper.fetch({
+      fetchOptions: {method: HttpMethod.DELETE},
       url: '/accounts/self/sshkeys/' + id,
       anonymizedUrl: '/accounts/self/sshkeys/*',
+      reportServerError: true,
     });
   }
 
@@ -3019,74 +3100,73 @@
     }) as Promise<unknown> as Promise<Record<string, GpgKeyInfo>>;
   }
 
-  addAccountGPGKey(key: GpgKeysInput) {
-    const req = {
-      method: HttpMethod.POST,
+  addAccountGPGKey(key: GpgKeysInput): Promise<Record<string, GpgKeyInfo>> {
+    // By passing throwingErrorCallback we guarantee that response is not-null.
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: key,
+      }),
       url: '/accounts/self/gpgkeys',
-      body: key,
       reportUrlAsIs: true,
-    };
-    return this._restApiHelper
-      .send(req)
-      .then(response => {
-        if (!response || (response.status < 200 && response.status >= 300)) {
-          return Promise.reject(new Error('error'));
-        }
-        return this.getResponseObject(response);
-      })
-      .then(obj => {
-        if (!obj) {
-          return Promise.reject(new Error('error'));
-        }
-        return obj;
-      });
+      errFn: throwingErrorCallback,
+    }) as Promise<unknown> as Promise<Record<string, GpgKeyInfo>>;
   }
 
   deleteAccountGPGKey(id: GpgKeyId) {
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
+    return this._restApiHelper.fetch({
+      fetchOptions: {method: HttpMethod.DELETE},
       url: `/accounts/self/gpgkeys/${id}`,
       anonymizedUrl: '/accounts/self/gpgkeys/*',
+      reportServerError: true,
     });
   }
 
-  deleteVote(changeNum: NumericChangeId, account: AccountId, label: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.DELETE,
-      endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
-      anonymizedEndpoint: '/reviewers/*/votes/*',
+  async deleteVote(
+    changeNum: NumericChangeId,
+    account: AccountId,
+    label: string
+  ): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: {method: HttpMethod.DELETE},
+      url: `${url}/reviewers/${account}/votes/${encodeURIComponent(label)}`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/reviewers/*/votes/*`,
+      reportServerError: true,
     });
   }
 
-  setDescription(
+  async setDescription(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     desc: string
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      patchNum,
-      endpoint: '/description',
-      body: {description: desc},
-      reportUrlAsIs: true,
+  ): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {description: desc},
+      }),
+      url: `${url}/description`,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/description`,
+      reportServerError: true,
     });
   }
 
-  confirmEmail(token: string): Promise<string | null> {
-    const req = {
-      method: HttpMethod.PUT,
+  async confirmEmail(token: string): Promise<string | null> {
+    const response = await this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.PUT,
+        body: {token},
+      }),
       url: '/config/server/email.confirm',
-      body: {token},
       reportUrlAsIs: true,
-    };
-    return this._restApiHelper.send(req).then(response => {
-      if (response?.status === 204) {
-        return 'Email confirmed successfully.';
-      }
-      return null;
+      reportServerError: true,
     });
+    if (response?.status === 204) {
+      return 'Email confirmed successfully.';
+    }
+    return null;
   }
 
   getCapabilities(
@@ -3100,7 +3180,7 @@
   }
 
   getTopMenus(): Promise<TopMenuEntryInfo[] | undefined> {
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: '/config/server/top-menus',
       reportUrlAsIs: true,
     }) as Promise<TopMenuEntryInfo[] | undefined>;
@@ -3112,41 +3192,41 @@
     );
   }
 
-  startWorkInProgress(
+  async startWorkInProgress(
     changeNum: NumericChangeId,
     message?: string
   ): Promise<string | undefined> {
-    const body = message ? {message} : {};
-    const req: SendRawChangeRequest = {
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/wip',
-      body,
-      reportUrlAsIs: true,
-    };
-    return this._getChangeURLAndSend(req).then(response => {
-      if (response?.status === 204) {
-        return 'Change marked as Work In Progress.';
-      }
-      return undefined;
+    const url = await this._changeBaseURL(changeNum);
+    const response = await this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: message ? {message} : {},
+      }),
+      url: `${url}/wip`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/wip`,
+      reportServerError: true,
     });
+    if (response.status === 204) {
+      return 'Change marked as Work In Progress.';
+    }
+    return undefined;
   }
 
-  deleteComment(
+  async deleteComment(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     commentID: UrlEncodedCommentId,
     reason: string
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      patchNum,
-      endpoint: `/comments/${commentID}/delete`,
-      body: {reason},
-      parseResponse: true,
-      anonymizedEndpoint: '/comments/*/delete',
-    }) as unknown as Promise<CommentInfo>;
+  ): Promise<CommentInfo | undefined> {
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetchJSON({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body: {reason},
+      }),
+      url: `${url}/comments/${commentID}/delete`,
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/comments/*/delete`,
+    }) as unknown as Promise<CommentInfo | undefined>;
   }
 
   getChange(
@@ -3193,14 +3273,12 @@
    * Then we don't need to make a dedicated REST API call or have a fallback,
    * if that fails.
    */
-  setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
+  addRepoNameToCache(changeNum: NumericChangeId, project: RepoName) {
     this._projectLookup[changeNum] = Promise.resolve(project);
   }
 
-  getFromProjectLookup(
-    changeNum: NumericChangeId
-  ): Promise<RepoName | undefined> {
-    // Hopefully setInProjectLookup() has already been called. Then we don't
+  getRepoName(changeNum: NumericChangeId): Promise<RepoName> {
+    // Hopefully addRepoNameToCache() has already been called. Then we don't
     // have to make a dedicated REST API call to look up the project.
     let projectPromise = this._projectLookup[changeNum];
     if (projectPromise) return projectPromise;
@@ -3212,9 +3290,9 @@
 
       // In the very rare case that the change index cannot provide an answer
       // (e.g. stale index) we should check, if the router has called
-      // setInProjectLookup() in the meantime. Then we can fall back to that.
+      // addRepoNameToCache() in the meantime. Then we can fall back to that.
       const currentProjectPromise = this._projectLookup[changeNum];
-      if (currentProjectPromise !== projectPromise) {
+      if (currentProjectPromise && currentProjectPromise !== projectPromise) {
         return currentProjectPromise;
       }
 
@@ -3225,124 +3303,49 @@
           {status: 404}
         )
       );
-      return undefined;
+      // Don't store failed lookups in the lookup.
+      this._projectLookup[changeNum] = undefined;
+      throw new Error(
+        `Failed to lookup the repo for change number ${changeNum}`
+      );
     });
     this._projectLookup[changeNum] = projectPromise;
     return projectPromise;
   }
 
-  // if errFn is not set, then only Response possible
-  _getChangeURLAndSend(
-    req: SendRawChangeRequest & {errFn?: undefined}
-  ): Promise<Response>;
-
-  _getChangeURLAndSend(
-    req: SendRawChangeRequest
-  ): Promise<Response | undefined>;
-
-  _getChangeURLAndSend(req: SendJSONChangeRequest): Promise<ParsedJSON>;
-
-  _getChangeURLAndSend(
-    req: SendChangeRequest
-  ): Promise<ParsedJSON | Response | undefined> {
-    const anonymizedBaseUrl = req.patchNum
-      ? ANONYMIZED_REVISION_BASE_URL
-      : ANONYMIZED_CHANGE_BASE_URL;
-    const anonymizedEndpoint = req.reportEndpointAsIs
-      ? req.endpoint
-      : req.anonymizedEndpoint;
-
-    return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
-      const request: SendRequest = {
-        method: req.method,
-        url: url + req.endpoint,
-        body: req.body,
-        errFn: req.errFn,
-        contentType: req.contentType,
-        headers: req.headers,
-        parseResponse: req.parseResponse,
-        anonymizedUrl: anonymizedEndpoint
-          ? `${anonymizedBaseUrl}${anonymizedEndpoint}`
-          : undefined,
-      };
-      return this._restApiHelper.send(request);
-    });
-  }
-
-  _getChangeURLAndFetch(
-    req: FetchChangeJSON,
-    noAcceptHeader?: boolean
-  ): Promise<ParsedJSON | undefined> {
-    const anonymizedEndpoint = req.reportEndpointAsIs
-      ? req.endpoint
-      : req.anonymizedEndpoint;
-    const anonymizedBaseUrl = req.revision
-      ? ANONYMIZED_REVISION_BASE_URL
-      : ANONYMIZED_CHANGE_BASE_URL;
-    return this._changeBaseURL(req.changeNum, req.revision).then(url =>
-      this._restApiHelper.fetchJSON(
-        {
-          url: url + req.endpoint,
-          errFn: req.errFn,
-          params: req.params,
-          fetchOptions: req.fetchOptions,
-          anonymizedUrl: anonymizedEndpoint
-            ? anonymizedBaseUrl + anonymizedEndpoint
-            : undefined,
-        },
-        noAcceptHeader
-      )
-    );
-  }
-
-  executeChangeAction(
-    changeNum: NumericChangeId,
-    method: HttpMethod | undefined,
-    endpoint: string,
-    patchNum?: PatchSetNum,
-    payload?: RequestPayload
-  ): Promise<Response>;
-
-  executeChangeAction(
-    changeNum: NumericChangeId,
-    method: HttpMethod | undefined,
-    endpoint: string,
-    patchNum: PatchSetNum | undefined,
-    payload: RequestPayload | undefined,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  executeChangeAction(
+  async executeChangeAction(
     changeNum: NumericChangeId,
     method: HttpMethod | undefined,
     endpoint: string,
     patchNum?: PatchSetNum,
     payload?: RequestPayload,
     errFn?: ErrorCallback
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method,
-      patchNum,
-      endpoint,
-      body: payload,
+  ): Promise<Response> {
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    // No anonymizedUrl specified so the request will not be logged.
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method,
+        body: payload,
+      }),
+      url: url + endpoint,
       errFn,
+      reportServerError: true,
     });
   }
 
-  getBlame(
+  async getBlame(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     path: string,
     base?: boolean
-  ) {
+  ): Promise<BlameInfo[] | undefined> {
     const encodedPath = encodeURIComponent(path);
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: `/files/${encodedPath}/blame`,
-      revision: patchNum,
+    const url = await this._changeBaseURL(changeNum, patchNum);
+    return this._restApiHelper.fetchJSON({
+      url: `${url}/files/${encodedPath}/blame`,
       params: base ? {base: 't'} : undefined,
-      anonymizedEndpoint: '/files/*/blame',
+      anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/files/*/blame`,
     }) as Promise<BlameInfo[] | undefined>;
   }
 
@@ -3390,7 +3393,7 @@
       encodeURIComponent(repo) +
       '/dashboards/' +
       encodeURIComponent(dashboard);
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url,
       errFn,
       anonymizedUrl: '/projects/*/dashboards/*',
@@ -3401,28 +3404,30 @@
     filter = filter.trim();
     const encodedFilter = encodeURIComponent(filter);
 
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._fetchSharedCacheURL({
+    return this._restApiHelper.fetchCacheJSON({
       url: `/Documentation/?q=${encodedFilter}`,
       anonymizedUrl: '/Documentation/?*',
     }) as Promise<DocResult[] | undefined>;
   }
 
-  getMergeable(changeNum: NumericChangeId) {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/revisions/current/mergeable',
-      reportEndpointAsIs: true,
+  async getMergeable(
+    changeNum: NumericChangeId
+  ): Promise<MergeableInfo | undefined> {
+    const url = await this._changeBaseURL(changeNum);
+    return this._restApiHelper.fetchJSON({
+      url: `${url}/revisions/current/mergeable`,
+      anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/revisions/current/mergeable`,
     }) as Promise<MergeableInfo | undefined>;
   }
 
   deleteDraftComments(query: string): Promise<Response> {
     const body: DeleteDraftCommentsInput = {query};
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
+    return this._restApiHelper.fetch({
+      fetchOptions: getFetchOptions({
+        method: HttpMethod.POST,
+        body,
+      }),
       url: '/accounts/self/drafts:delete',
-      body,
     });
   }
 }
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 fe52529..af12a9c 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
@@ -7,6 +7,7 @@
 import {
   addListenerForTest,
   assertFails,
+  makePrefixedJSON,
   MockPromise,
   mockPromise,
   waitEventLoop,
@@ -15,17 +16,15 @@
 import {listChangesOptionsToHex} from '../../utils/change-util';
 import {
   createAccountDetailWithId,
+  createAccountWithId,
   createChange,
   createComment,
+  createEditInfo,
   createParsedChange,
   createServerInfo,
+  TEST_PROJECT_NAME,
 } from '../../test/test-data-generators';
 import {CURRENT} from '../../utils/patch-set-util';
-import {
-  parsePrefixedJSON,
-  readResponsePayload,
-  JSON_PREFIX,
-} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {GrRestApiServiceImpl} from './gr-rest-api-impl';
 import {
   CommentSide,
@@ -33,24 +32,21 @@
   HttpMethod,
 } from '../../constants/constants';
 import {
+  AccountDetailInfo,
   BasePatchSetNum,
   ChangeInfo,
   ChangeMessageId,
   CommentInfo,
+  CommentInput,
   DashboardId,
-  DiffPreferenceInput,
   EDIT,
-  EditPreferencesInfo,
   Hashtag,
-  HashtagsInput,
   ListChangesOption,
   NumericChangeId,
   PARENT,
   ParsedJSON,
   PatchSetNum,
-  PreferencesInfo,
   RepoName,
-  RevisionId,
   RevisionPatchSetNum,
   RobotCommentInfo,
   Timestamp,
@@ -59,7 +55,6 @@
 import {assert} from '@open-wc/testing';
 import {AuthService} from '../gr-auth/gr-auth';
 import {GrAuthMock} from '../gr-auth/gr-auth_mock';
-import {getBaseUrl} from '../../utils/url-util';
 import {FlagsServiceImplementation} from '../flags/flags_impl';
 
 const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
@@ -103,6 +98,7 @@
   });
 
   test('parent diff comments are properly grouped', async () => {
+    element.addRepoNameToCache(42 as NumericChangeId, TEST_PROJECT_NAME);
     sinon.stub(element._restApiHelper, 'fetchJSON').resolves({
       '/COMMIT_MSG': [],
       'sieve.go': [
@@ -246,7 +242,7 @@
   });
 
   test('differing patch diff comments are properly grouped', async () => {
-    sinon.stub(element, 'getFromProjectLookup').resolves('test' as RepoName);
+    sinon.stub(element, 'getRepoName').resolves('test' as RepoName);
     sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(async request => {
       const url = request.url;
       if (url === '/changes/test~42/revisions/1/comments') {
@@ -313,20 +309,6 @@
     } as RobotCommentInfo);
   });
 
-  test('server error', async () => {
-    const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
-    sinon
-      .stub(authService, 'fetch')
-      .resolves(new Response(undefined, {status: 502}));
-    const serverErrorEventPromise = new Promise(resolve => {
-      addListenerForTest(document, 'server-error', resolve);
-    });
-    const response = await element._restApiHelper.fetchJSON({url: ''});
-    assert.isUndefined(response);
-    assert.isTrue(getResponseObjectStub.notCalled);
-    await serverErrorEventPromise;
-  });
-
   test('legacy n,z key in change url is replaced', async () => {
     const stub = sinon
       .stub(element._restApiHelper, 'fetchJSON')
@@ -337,73 +319,85 @@
 
   test('saveDiffPreferences invalidates cache line', () => {
     const cacheKey = '/accounts/self/preferences.diff';
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    const fetchStub = sinon.stub(element._restApiHelper, 'fetch');
     element._cache.set(cacheKey, {tab_size: 4} as unknown as ParsedJSON);
     element.saveDiffPreferences({
       tab_size: 8,
       ignore_whitespace: 'IGNORE_NONE',
     });
-    assert.isTrue(sendStub.called);
+    assert.isTrue(fetchStub.called);
     assert.isFalse(element._cache.has(cacheKey));
   });
 
-  suite('getAccountSuggestions', () => {
+  suite('queryAccounts', () => {
     let fetchStub: sinon.SinonStub;
     const testProject = 'testproject';
     const testChangeNumber = 341682;
     setup(() => {
       fetchStub = sinon
         .stub(element._restApiHelper, 'fetch')
-        .resolves(new Response());
-      element.setInProjectLookup(
+        .resolves(new Response(makePrefixedJSON(createAccountWithId())));
+      element.addRepoNameToCache(
         testChangeNumber as NumericChangeId,
         testProject as RepoName
       );
     });
 
     test('url with just email', async () => {
-      await element.getSuggestedAccounts('bro');
+      await element.queryAccounts('bro');
       assert.isTrue(fetchStub.calledOnce);
-      assert.equal(
-        fetchStub.firstCall.args[0].url,
-        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22`
-      );
+      assert.deepEqual(fetchStub.firstCall.args[0].params, {
+        o: 'DETAILS',
+        q: '"bro"',
+      });
     });
 
     test('url with email and canSee changeId', async () => {
-      await element.getSuggestedAccounts(
+      await element.queryAccounts(
         'bro',
         undefined,
         testChangeNumber as NumericChangeId
       );
       assert.isTrue(fetchStub.calledOnce);
-      assert.equal(
-        fetchStub.firstCall.args[0].url,
-        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A${testProject}~${testChangeNumber}`
-      );
+      assert.deepEqual(fetchStub.firstCall.args[0].params, {
+        o: 'DETAILS',
+        q: `"bro" and cansee:${testProject}~${testChangeNumber}`,
+      });
     });
 
     test('url with email and canSee changeId and isActive', async () => {
-      await element.getSuggestedAccounts(
+      await element.queryAccounts(
         'bro',
         undefined,
         testChangeNumber as NumericChangeId,
         true
       );
       assert.isTrue(fetchStub.calledOnce);
-      assert.equal(
-        fetchStub.firstCall.args[0].url,
-        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A${testProject}~${testChangeNumber}%20and%20is%3Aactive`
-      );
+      assert.deepEqual(fetchStub.firstCall.args[0].params, {
+        o: 'DETAILS',
+        q: `"bro" and cansee:${testProject}~${testChangeNumber} and is:active`,
+      });
+    });
+  });
+
+  test('getAccountSuggestions using suggest query param', () => {
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response());
+    element.getAccountSuggestions('user');
+    assert.isTrue(fetchStub.calledOnce);
+    assert.deepEqual(fetchStub.firstCall.args[0].params, {
+      suggest: undefined,
+      q: 'user',
     });
   });
 
   test('getAccount when resp is undefined clears cache', async () => {
     const cacheKey = '/accounts/self/detail';
     const account = createAccountDetailWithId();
-    element._cache.set(cacheKey, account);
+    element._cache.set(cacheKey, account as unknown as ParsedJSON);
     const stub = sinon
-      .stub(element._restApiHelper, 'fetchCacheURL')
+      .stub(element._restApiHelper, 'fetchCacheJSON')
       .callsFake(async req => {
         req.errFn!(undefined);
         return undefined;
@@ -418,9 +412,9 @@
   test('getAccount when status is 403 clears cache', async () => {
     const cacheKey = '/accounts/self/detail';
     const account = createAccountDetailWithId();
-    element._cache.set(cacheKey, account);
+    element._cache.set(cacheKey, account as unknown as ParsedJSON);
     const stub = sinon
-      .stub(element._restApiHelper, 'fetchCacheURL')
+      .stub(element._restApiHelper, 'fetchCacheJSON')
       .callsFake(async req => {
         req.errFn!(new Response(undefined, {status: 403}));
         return undefined;
@@ -436,16 +430,19 @@
     const cacheKey = '/accounts/self/detail';
     const account = createAccountDetailWithId();
     const stub = sinon
-      .stub(element._restApiHelper, 'fetchCacheURL')
+      .stub(element._restApiHelper, 'fetchCacheJSON')
       .callsFake(async () => {
-        element._cache.set(cacheKey, account);
+        element._cache.set(cacheKey, account as unknown as ParsedJSON);
         return undefined;
       });
     assert.isFalse(element._cache.has(cacheKey));
 
     await element.getAccount();
     assert.isTrue(stub.called);
-    assert.equal(element._cache.get(cacheKey), account);
+    assert.equal(
+      element._cache.get(cacheKey),
+      account as unknown as ParsedJSON
+    );
   });
 
   const preferenceSetup = function (testJSON: unknown, loggedIn: boolean) {
@@ -453,7 +450,7 @@
       .stub(element, 'getLoggedIn')
       .callsFake(() => Promise.resolve(loggedIn));
     sinon
-      .stub(element._restApiHelper, 'fetchCacheURL')
+      .stub(element._restApiHelper, 'fetchCacheJSON')
       .callsFake(() => Promise.resolve(testJSON as ParsedJSON));
   };
 
@@ -487,14 +484,14 @@
     assert.equal(obj!.diff_view, 'SIDE_BY_SIDE');
   });
 
-  test('savPreferences normalizes download scheme', () => {
-    const sendStub = sinon
-      .stub(element._restApiHelper, 'send')
+  test('savePreferences normalizes download scheme', () => {
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
       .resolves(new Response());
     element.savePreferences({download_scheme: 'HTTP'});
-    assert.isTrue(sendStub.called);
+    assert.isTrue(fetchStub.called);
     assert.equal(
-      (sendStub.lastCall.args[0].body as Partial<PreferencesInfo>)
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string)
         .download_scheme,
       'http'
     );
@@ -518,14 +515,14 @@
   });
 
   test('saveDiffPreferences set show_tabs to false', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    const fetchStub = sinon.stub(element._restApiHelper, 'fetch');
     element.saveDiffPreferences({
       show_tabs: false,
       ignore_whitespace: 'IGNORE_NONE',
     });
-    assert.isTrue(sendStub.called);
+    assert.isTrue(fetchStub.called);
     assert.equal(
-      (sendStub.lastCall.args[0].body as Partial<DiffPreferenceInput>)
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string)
         .show_tabs,
       false
     );
@@ -554,40 +551,53 @@
   });
 
   test('saveEditPreferences set show_tabs to false', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    const fetchStub = sinon.stub(element._restApiHelper, 'fetch');
     element.saveEditPreferences({
       ...createDefaultEditPrefs(),
       show_tabs: false,
     });
-    assert.isTrue(sendStub.called);
+    assert.isTrue(fetchStub.called);
     assert.equal(
-      (sendStub.lastCall.args[0].body as EditPreferencesInfo).show_tabs,
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string)
+        .show_tabs,
       false
     );
   });
 
   test('confirmEmail', () => {
-    const sendStub = sinon.spy(element._restApiHelper, 'send');
+    const fetchStub = sinon.stub(element._restApiHelper, 'fetch').resolves();
     element.confirmEmail('foo');
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
-    assert.equal(sendStub.lastCall.args[0].url, '/config/server/email.confirm');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
+    assert.equal(
+      fetchStub.lastCall.args[0].url,
+      '/config/server/email.confirm'
+    );
+    assert.deepEqual(
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+      {token: 'foo'}
+    );
   });
 
   test('setPreferredAccountEmail', async () => {
     const email1 = 'email1@example.com';
     const email2 = 'email2@example.com';
     const encodedEmail = encodeURIComponent(email2);
-    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+    const sendStub = sinon.stub(element._restApiHelper, 'fetch').resolves();
     element._cache.set('/accounts/self/emails', [
       {email: email1, preferred: true},
       {email: email2, preferred: false},
-    ]);
+    ] as unknown as ParsedJSON);
 
     await element.setPreferredAccountEmail(email2);
     assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(
+      sendStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
     assert.equal(
       sendStub.lastCall.args[0].url,
       `/accounts/self/emails/${encodedEmail}/preferred`
@@ -595,38 +605,223 @@
     assert.deepEqual(element._cache.get('/accounts/self/emails'), [
       {email: email1, preferred: false},
       {email: email2, preferred: true},
-    ]);
+    ] as unknown as ParsedJSON);
+  });
+
+  test('setAccountUsername', async () => {
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response(makePrefixedJSON('john')));
+    element._cache.set(
+      '/accounts/self/detail',
+      createAccountDetailWithId() as unknown as ParsedJSON
+    );
+    await element.setAccountUsername('john');
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
+    assert.equal(fetchStub.lastCall.args[0].url, '/accounts/self/username');
+    assert.deepEqual(
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+      {username: 'john'}
+    );
+    assert.deepEqual(
+      (element._cache.get(
+        '/accounts/self/detail'
+      ) as unknown as AccountDetailInfo)!.username,
+      'john'
+    );
+  });
+
+  test('setAccountUsername empty unsets field', async () => {
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response(undefined, {status: 204}));
+    element._cache.set('/accounts/self/detail', {
+      ...createAccountDetailWithId(),
+      username: 'john',
+    } as unknown as ParsedJSON);
+    await element.setAccountUsername('');
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
+    assert.isUndefined(
+      (element._cache.get(
+        '/accounts/self/detail'
+      ) as unknown as AccountDetailInfo)!.username
+    );
+  });
+
+  test('setAccountDisplayName', async () => {
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response(makePrefixedJSON('john')));
+    element._cache.set(
+      '/accounts/self/detail',
+      createAccountDetailWithId() as unknown as ParsedJSON
+    );
+    await element.setAccountDisplayName('john');
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
+    assert.equal(fetchStub.lastCall.args[0].url, '/accounts/self/displayname');
+    assert.deepEqual(
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+      {display_name: 'john'}
+    );
+    assert.deepEqual(
+      (element._cache.get(
+        '/accounts/self/detail'
+      ) as unknown as AccountDetailInfo)!.display_name,
+      'john'
+    );
+  });
+
+  test('setAccountDisplayName empty unsets field', async () => {
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response(undefined, {status: 204}));
+    element._cache.set('/accounts/self/detail', {
+      ...createAccountDetailWithId(),
+      display_name: 'john',
+    } as unknown as ParsedJSON);
+    await element.setAccountDisplayName('');
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
+    assert.isUndefined(
+      (element._cache.get(
+        '/accounts/self/detail'
+      ) as unknown as AccountDetailInfo)!.display_name
+    );
+  });
+
+  test('setAccountName', async () => {
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response(makePrefixedJSON('john')));
+    element._cache.set(
+      '/accounts/self/detail',
+      createAccountDetailWithId() as unknown as ParsedJSON
+    );
+    await element.setAccountName('john');
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
+    assert.equal(fetchStub.lastCall.args[0].url, '/accounts/self/name');
+    assert.deepEqual(
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+      {name: 'john'}
+    );
+    assert.deepEqual(
+      (element._cache.get(
+        '/accounts/self/detail'
+      ) as unknown as AccountDetailInfo)!.name,
+      'john'
+    );
+  });
+
+  test('setAccountName empty unsets field', async () => {
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response(undefined, {status: 204}));
+    element._cache.set('/accounts/self/detail', {
+      ...createAccountDetailWithId(),
+      name: 'john',
+    } as unknown as ParsedJSON);
+    await element.setAccountName('');
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
+    assert.isUndefined(
+      (element._cache.get(
+        '/accounts/self/detail'
+      ) as unknown as AccountDetailInfo)!.name
+    );
   });
 
   test('setAccountStatus', async () => {
-    const sendStub = sinon
-      .stub(element._restApiHelper, 'send')
-      .resolves('OOO' as unknown as ParsedJSON);
-    element._cache.set('/accounts/self/detail', createAccountDetailWithId());
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response(makePrefixedJSON('OOO')));
+    element._cache.set(
+      '/accounts/self/detail',
+      createAccountDetailWithId() as unknown as ParsedJSON
+    );
     await element.setAccountStatus('OOO');
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
-    assert.equal(sendStub.lastCall.args[0].url, '/accounts/self/status');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {status: 'OOO'});
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
+    assert.equal(fetchStub.lastCall.args[0].url, '/accounts/self/status');
     assert.deepEqual(
-      element._cache.get('/accounts/self/detail')!.status,
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+      {status: 'OOO'}
+    );
+    assert.deepEqual(
+      (element._cache.get(
+        '/accounts/self/detail'
+      ) as unknown as AccountDetailInfo)!.status,
       'OOO'
     );
   });
 
+  test('setAccountStatus empty unsets field', async () => {
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response(undefined, {status: 204}));
+    element._cache.set('/accounts/self/detail', {
+      ...createAccountDetailWithId(),
+      status: 'OOO',
+    } as unknown as ParsedJSON);
+    await element.setAccountStatus('');
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
+    assert.isUndefined(
+      (element._cache.get(
+        '/accounts/self/detail'
+      ) as unknown as AccountDetailInfo)!.status
+    );
+  });
+
   suite('draft comments', () => {
     test('_sendDiffDraftRequest pending requests tracked', async () => {
       const obj = element._pendingRequests;
-      sinon
-        .stub(element, '_getChangeURLAndSend')
-        .callsFake(() => mockPromise());
-      assert.notOk(element.hasPendingDiffDrafts());
+      const promises: MockPromise<string>[] = [];
+      sinon.stub(element, '_changeBaseURL').callsFake(() => {
+        promises.push(mockPromise<string>());
+        return promises[promises.length - 1];
+      });
+      const fetchStub = sinon
+        .stub(element._restApiHelper, 'fetch')
+        .resolves(new Response(undefined, {status: 201}));
+      const draft: CommentInput = {
+        id: 'draft-id' as UrlEncodedCommentId,
+        message: 'draft message',
+      };
+      assert.isFalse(!!element.hasPendingDiffDrafts());
 
       element._sendDiffDraftRequest(
         HttpMethod.PUT,
         123 as NumericChangeId,
         1 as PatchSetNum,
-        {}
+        draft
       );
       assert.equal(obj.sendDiffDraft.length, 1);
       assert.isTrue(!!element.hasPendingDiffDrafts());
@@ -635,24 +830,30 @@
         HttpMethod.PUT,
         123 as NumericChangeId,
         1 as PatchSetNum,
-        {}
+        draft
       );
       assert.equal(obj.sendDiffDraft.length, 2);
       assert.isTrue(!!element.hasPendingDiffDrafts());
 
-      for (const promise of obj.sendDiffDraft) {
-        (promise as MockPromise<void>).resolve();
+      for (const promise of promises) {
+        promise.resolve('');
       }
 
       await element.awaitPendingDiffDrafts();
       assert.equal(obj.sendDiffDraft.length, 0);
       assert.isFalse(!!element.hasPendingDiffDrafts());
+
+      assert.isTrue(fetchStub.called);
+      assert.deepEqual(
+        JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+        draft
+      );
     });
 
     suite('_failForCreate200', () => {
       test('_sendDiffDraftRequest checks for 200 on create', async () => {
-        const sendPromise = Promise.resolve({} as unknown as ParsedJSON);
-        sinon.stub(element, '_getChangeURLAndSend').returns(sendPromise);
+        element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+        sinon.stub(element._restApiHelper, 'fetch').resolves(new Response());
         const failStub = sinon.stub(element, '_failForCreate200').resolves();
         await element._sendDiffDraftRequest(
           HttpMethod.PUT,
@@ -661,11 +862,11 @@
           {}
         );
         assert.isTrue(failStub.calledOnce);
-        assert.isTrue(failStub.calledWithExactly(sendPromise));
       });
 
       test('_sendDiffDraftRequest no checks for 200 on non create', async () => {
-        sinon.stub(element, '_getChangeURLAndSend').resolves();
+        element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+        sinon.stub(element._restApiHelper, 'fetch').resolves(new Response());
         const failStub = sinon.stub(element, '_failForCreate200').resolves();
         await element._sendDiffDraftRequest(
           HttpMethod.PUT,
@@ -707,100 +908,137 @@
     const change_num = 1 as NumericChangeId;
     const file_name = 'index.php';
     const file_contents = '<?php';
-    const sendStub = sinon
-      .stub(element._restApiHelper, 'send')
-      .resolves([
-        change_num,
-        file_name,
-        file_contents,
-      ] as unknown as ParsedJSON);
-    sinon
-      .stub(element, 'getResponseObject')
-      .resolves([
-        change_num,
-        file_name,
-        file_contents,
-      ] as unknown as ParsedJSON);
+    const fetchStub = sinon.stub(element._restApiHelper, 'fetch').resolves();
     element._cache.set(
       `/changes/${change_num}/edit/${file_name}`,
       {} as unknown as ParsedJSON
     );
     await element.saveChangeEdit(change_num, file_name, file_contents);
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.isTrue(fetchStub.calledOnce);
     assert.equal(
-      sendStub.lastCall.args[0].url,
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
+    assert.equal(
+      fetchStub.lastCall.args[0].url,
       '/changes/test~1/edit/' + file_name
     );
-    assert.equal(sendStub.lastCall.args[0].body, file_contents);
+    assert.equal(fetchStub.lastCall.args[0].fetchOptions?.body, file_contents);
   });
 
   test('putChangeCommitMessage', async () => {
     element._projectLookup = {1: Promise.resolve('test' as RepoName)};
     const change_num = 1 as NumericChangeId;
     const message = 'this is a commit message';
-    const sendStub = sinon
-      .stub(element._restApiHelper, 'send')
-      .resolves([change_num, message] as unknown as ParsedJSON);
-    sinon
-      .stub(element, 'getResponseObject')
-      .resolves([change_num, message] as unknown as ParsedJSON);
+    const committer_email = 'test@example.com';
+    const fetchStub = sinon.stub(element._restApiHelper, 'fetch').resolves();
     element._cache.set(
       `/changes/${change_num}/message`,
       {} as unknown as ParsedJSON
     );
-    await element.putChangeCommitMessage(change_num, message);
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
-    assert.equal(sendStub.lastCall.args[0].url, '/changes/test~1/message');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {
-      message,
-    });
+    await element.putChangeCommitMessage(change_num, message, committer_email);
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
+    assert.equal(fetchStub.lastCall.args[0].url, '/changes/test~1/message');
+    assert.deepEqual(
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+      {
+        message,
+        committer_email,
+      }
+    );
+  });
+
+  test('updateIdentityInChangeEdit', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const change_num = 1 as NumericChangeId;
+    const name = 'user';
+    const email = 'user@example.com';
+    const type = 'AUTHOR';
+    const fetchStub = sinon.stub(element._restApiHelper, 'fetch').resolves();
+    await element.updateIdentityInChangeEdit(change_num, name, email, type);
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.PUT
+    );
+    assert.equal(
+      fetchStub.lastCall.args[0].url,
+      '/changes/test~1/edit:identity'
+    );
+    assert.deepEqual(
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+      {
+        email: 'user@example.com',
+        name: 'user',
+        type: 'AUTHOR',
+      }
+    );
   });
 
   test('deleteChangeCommitMessage', async () => {
     element._projectLookup = {1: Promise.resolve('test' as RepoName)};
     const change_num = 1 as NumericChangeId;
     const messageId = 'abc' as ChangeMessageId;
-    const sendStub = sinon
-      .stub(element._restApiHelper, 'send')
-      .resolves([change_num, messageId] as unknown as ParsedJSON);
-    sinon
-      .stub(element, 'getResponseObject')
-      .resolves([change_num, messageId] as unknown as ParsedJSON);
+    const fetchStub = sinon.stub(element._restApiHelper, 'fetch').resolves();
     await element.deleteChangeCommitMessage(change_num, messageId);
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.DELETE);
-    assert.equal(sendStub.lastCall.args[0].url, '/changes/test~1/messages/abc');
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.DELETE
+    );
+    assert.equal(
+      fetchStub.lastCall.args[0].url,
+      '/changes/test~1/messages/abc'
+    );
   });
 
-  test('startWorkInProgress', () => {
-    const sendStub = sinon
-      .stub(element, '_getChangeURLAndSend')
-      .resolves('ok' as unknown as ParsedJSON);
-    element.startWorkInProgress(42 as NumericChangeId);
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
-    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
-    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {});
+  test('startWorkInProgress', async () => {
+    element.addRepoNameToCache(42 as NumericChangeId, TEST_PROJECT_NAME);
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response());
+    const urlSpy = sinon.spy(element, '_changeBaseURL');
+    await element.startWorkInProgress(42 as NumericChangeId);
+    assert.isTrue(fetchStub.calledOnce);
+    assert.isTrue(urlSpy.calledOnce);
+    assert.equal(urlSpy.lastCall.args[0], 42 as NumericChangeId);
+    assert.isNotOk(urlSpy.lastCall.args[1]);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.POST
+    );
+    assert.isTrue(fetchStub.lastCall.args[0].url.endsWith('/wip'));
+    assert.deepEqual(
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+      {}
+    );
 
-    element.startWorkInProgress(42 as NumericChangeId, 'revising...');
-    assert.isTrue(sendStub.calledTwice);
-    assert.equal(sendStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
-    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
-    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {
-      message: 'revising...',
-    });
+    await element.startWorkInProgress(42 as NumericChangeId, 'revising...');
+    assert.isTrue(fetchStub.calledTwice);
+    assert.equal(urlSpy.lastCall.args[0], 42 as NumericChangeId);
+    assert.isNotOk(urlSpy.lastCall.args[1]);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.POST
+    );
+    assert.isTrue(fetchStub.lastCall.args[0].url.endsWith('/wip'));
+    assert.deepEqual(
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+      {
+        message: 'revising...',
+      }
+    );
   });
 
   test('deleteComment', async () => {
+    element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
     const comment = createComment();
-    const sendStub = sinon
-      .stub(element, '_getChangeURLAndSend')
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
       .resolves(comment as unknown as ParsedJSON);
     const response = await element.deleteComment(
       123 as NumericChangeId,
@@ -809,32 +1047,40 @@
       'removal reason'
     );
     assert.equal(response, comment);
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].changeNum, 123 as NumericChangeId);
-    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
-    assert.equal(sendStub.lastCall.args[0].patchNum, 1 as PatchSetNum);
-    assert.equal(sendStub.lastCall.args[0].endpoint, '/comments/01234/delete');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {
-      reason: 'removal reason',
-    });
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(
+      fetchStub.lastCall.args[0].fetchOptions?.method,
+      HttpMethod.POST
+    );
+    assert.equal(
+      fetchStub.lastCall.args[0].url,
+      '/changes/test-project~123/revisions/1/comments/01234/delete'
+    );
+    assert.deepEqual(
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+      {
+        reason: 'removal reason',
+      }
+    );
   });
 
   test('createRepo encodes name', async () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+    const fetchStub = sinon.stub(element._restApiHelper, 'fetch').resolves();
     await element.createRepo({name: 'x/y' as RepoName});
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
+    assert.isTrue(fetchStub.calledOnce);
+    assert.equal(fetchStub.lastCall.args[0].url, '/projects/x%2Fy');
   });
 
   test('queryChangeFiles', async () => {
-    const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+    element.addRepoNameToCache(42 as NumericChangeId, TEST_PROJECT_NAME);
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves();
     await element.queryChangeFiles(42 as NumericChangeId, EDIT, 'test/path.js');
-    assert.equal(fetchStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
     assert.equal(
-      fetchStub.lastCall.args[0].endpoint,
-      '/files?q=test%2Fpath.js'
+      fetchStub.lastCall.args[0].url,
+      '/changes/test-project~42/revisions/edit/files?q=test%2Fpath.js'
     );
-    assert.equal(fetchStub.lastCall.args[0].revision, EDIT);
   });
 
   test('normal use', () => {
@@ -887,29 +1133,29 @@
 
   suite('getRepos', () => {
     const defaultQuery = '';
-    let fetchCacheURLStub: sinon.SinonStub;
+    let fetchCacheJSONStub: sinon.SinonStub;
     setup(() => {
-      fetchCacheURLStub = sinon
-        .stub(element._restApiHelper, 'fetchCacheURL')
+      fetchCacheJSONStub = sinon
+        .stub(element._restApiHelper, 'fetchCacheJSON')
         .resolves([] as unknown as ParsedJSON);
     });
 
     test('normal use', () => {
       element.getRepos('test', 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/projects/?n=26&S=0&d=&m=test'
       );
 
       element.getRepos(undefined, 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         `/projects/?n=26&S=0&d=&m=${defaultQuery}`
       );
 
       element.getRepos('test', 25, 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/projects/?n=26&S=25&d=&m=test'
       );
     });
@@ -917,7 +1163,7 @@
     test('with blank', () => {
       element.getRepos('test/test', 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/projects/?n=26&S=0&d=&m=test%2Ftest'
       );
     });
@@ -925,7 +1171,7 @@
     test('with hyphen', () => {
       element.getRepos('foo-bar', 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/projects/?n=26&S=0&d=&m=foo-bar'
       );
     });
@@ -933,7 +1179,7 @@
     test('with leading hyphen', () => {
       element.getRepos('-bar', 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/projects/?n=26&S=0&d=&m=-bar'
       );
     });
@@ -941,7 +1187,7 @@
     test('with trailing hyphen', () => {
       element.getRepos('foo-bar-', 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/projects/?n=26&S=0&d=&m=foo-bar-'
       );
     });
@@ -949,7 +1195,7 @@
     test('with underscore', () => {
       element.getRepos('foo_bar', 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/projects/?n=26&S=0&d=&m=foo_bar'
       );
     });
@@ -957,7 +1203,7 @@
     test('with underscore', () => {
       element.getRepos('foo_bar', 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/projects/?n=26&S=0&d=&m=foo_bar'
       );
     });
@@ -965,7 +1211,7 @@
     test('hyphen only', () => {
       element.getRepos('-', 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/projects/?n=26&S=0&d=&m=-'
       );
     });
@@ -973,7 +1219,7 @@
     test('using query', () => {
       element.getRepos('description:project', 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/projects/?n=26&S=0&query=description%3Aproject'
       );
     });
@@ -1003,24 +1249,27 @@
   });
 
   suite('getGroups', () => {
-    let fetchCacheURLStub: sinon.SinonStub;
+    let fetchCacheJSONStub: sinon.SinonStub;
     setup(() => {
-      fetchCacheURLStub = sinon.stub(element._restApiHelper, 'fetchCacheURL');
+      fetchCacheJSONStub = sinon.stub(element._restApiHelper, 'fetchCacheJSON');
     });
 
     test('normal use', () => {
       element.getGroups('test', 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/groups/?n=26&S=0&m=test'
       );
 
       element.getGroups('', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url, '/groups/?n=26&S=0');
+      assert.equal(
+        fetchCacheJSONStub.lastCall.args[0].url,
+        '/groups/?n=26&S=0'
+      );
 
       element.getGroups('test', 25, 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/groups/?n=26&S=25&m=test'
       );
     });
@@ -1028,13 +1277,13 @@
     test('regex', () => {
       element.getGroups('^test.*', 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/groups/?n=26&S=0&r=%5Etest.*'
       );
 
       element.getGroups('^test.*', 25, 25);
       assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
+        fetchCacheJSONStub.lastCall.args[0].url,
         '/groups/?n=26&S=25&r=%5Etest.*'
       );
     });
@@ -1046,18 +1295,18 @@
     assert(fetchStub.called);
   });
 
-  test('getSuggestedAccounts does not return fetchJSON', async () => {
+  test('queryAccounts does not return fetchJSON', async () => {
     const fetchJSONSpy = sinon.spy(element._restApiHelper, 'fetchJSON');
-    const accts = await element.getSuggestedAccounts('');
+    const accts = await element.queryAccounts('');
     assert.isFalse(fetchJSONSpy.called);
     assert.equal(accts!.length, 0);
   });
 
-  test('fetchJSON gets called by getSuggestedAccounts', async () => {
+  test('fetchJSON gets called by queryAccounts', async () => {
     const fetchJSONStub = sinon
       .stub(element._restApiHelper, 'fetchJSON')
       .resolves();
-    await element.getSuggestedAccounts('own');
+    await element.queryAccounts('own');
     assert.deepEqual(fetchJSONStub.lastCall.args[0].params, {
       q: '"own"',
       o: 'DETAILS',
@@ -1065,6 +1314,14 @@
   });
 
   suite('getChangeDetail', () => {
+    let getConfigStub: sinon.SinonStub;
+
+    setup(() => {
+      getConfigStub = sinon
+        .stub(element, 'getConfig')
+        .resolves(createServerInfo());
+    });
+
     suite('change detail options', () => {
       let changeDetailStub: sinon.SinonStub;
       setup(() => {
@@ -1074,7 +1331,7 @@
       });
 
       test('signed pushes disabled', async () => {
-        sinon.stub(element, 'getConfig').resolves({
+        getConfigStub.resolves({
           ...createServerInfo(),
           receive: {enable_signed_push: undefined},
         });
@@ -1087,7 +1344,7 @@
       });
 
       test('signed pushes enabled', async () => {
-        sinon.stub(element, 'getConfig').resolves({
+        getConfigStub.resolves({
           ...createServerInfo(),
           receive: {enable_signed_push: 'true'},
         });
@@ -1101,6 +1358,7 @@
     });
 
     test('GrReviewerUpdatesParser.parse is used', async () => {
+      element.addRepoNameToCache(42 as NumericChangeId, TEST_PROJECT_NAME);
       const changeInfo = createParsedChange();
       const parseStub = sinon
         .stub(GrReviewerUpdatesParser, 'parse')
@@ -1116,6 +1374,14 @@
       const expectedUrl = `${window.CANONICAL_PATH}/changes/test~4321/detail?O=516714`;
       const optionsStub = sinon.stub(element._etags, 'getOptions');
       const collectStub = sinon.stub(element._etags, 'collect');
+      sinon.stub(element._restApiHelper, 'fetch').resolves(
+        new Response(
+          makePrefixedJSON({
+            ...createChange(),
+            _number: 123 as NumericChangeId,
+          })
+        )
+      );
       await element._getChangeDetail(changeNum, '516714');
       assert.isTrue(optionsStub.calledWithExactly(expectedUrl));
       assert.equal(collectStub.lastCall.args[0], expectedUrl);
@@ -1125,7 +1391,7 @@
       const errFn = sinon.stub();
       sinon.stub(element, 'getChangeActionURL').resolves('');
       sinon
-        .stub(element._restApiHelper, 'fetchRawJSON')
+        .stub(element._restApiHelper, 'fetch')
         .resolves(new Response(undefined, {status: 500}));
       await element._getChangeDetail(123 as NumericChangeId, '516714', errFn);
       assert.isTrue(errFn.called);
@@ -1133,7 +1399,7 @@
 
     test('_getChangeDetail populates _projectLookup', async () => {
       sinon.stub(element, 'getChangeActionURL').resolves('');
-      sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+      sinon.stub(element._restApiHelper, 'fetch').resolves(
         new Response(')]}\'{"_number":1,"project":"test"}', {
           status: 200,
         })
@@ -1152,7 +1418,7 @@
       setup(() => {
         requestUrl = '/foo/bar';
         const mockResponse = {foo: 'bar', baz: 42};
-        mockResponseSerial = JSON_PREFIX + JSON.stringify(mockResponse);
+        mockResponseSerial = makePrefixedJSON(mockResponse);
         sinon.stub(element._restApiHelper, 'urlWithParams').returns(requestUrl);
         sinon.stub(element, 'getChangeActionURL').resolves(requestUrl);
         collectSpy = sinon.spy(element._etags, 'collect');
@@ -1160,7 +1426,7 @@
 
       test('contributes to cache', async () => {
         const getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
-        sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+        sinon.stub(element._restApiHelper, 'fetch').resolves(
           new Response(mockResponseSerial, {
             status: 200,
           })
@@ -1176,7 +1442,7 @@
       test('uses cache on HTTP 304', async () => {
         const getPayloadStub = sinon.stub(element._etags, 'getCachedPayload');
         getPayloadStub.returns(mockResponseSerial);
-        sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+        sinon.stub(element._restApiHelper, 'fetch').resolves(
           new Response(undefined, {
             status: 304,
           })
@@ -1189,13 +1455,13 @@
     });
   });
 
-  test('setInProjectLookup', async () => {
-    element.setInProjectLookup(555 as NumericChangeId, 'project' as RepoName);
-    const project = await element.getFromProjectLookup(555 as NumericChangeId);
+  test('addRepoNameToCache', async () => {
+    element.addRepoNameToCache(555 as NumericChangeId, 'project' as RepoName);
+    const project = await element.getRepoName(555 as NumericChangeId);
     assert.deepEqual(project, 'project' as RepoName);
   });
 
-  suite('getFromProjectLookup', () => {
+  suite('getRepoName', () => {
     const changeNum = 555 as NumericChangeId;
     const repo = 'test-repo' as RepoName;
 
@@ -1203,29 +1469,33 @@
       const promise = mockPromise<undefined>();
       sinon.stub(element, 'getChange').returns(promise);
 
-      const projectLookup = element.getFromProjectLookup(changeNum);
+      const projectLookup = element.getRepoName(changeNum);
       promise.resolve(undefined);
 
-      assert.isUndefined(await projectLookup);
+      const err: Error = await assertFails(projectLookup);
+      assert.equal(
+        err.message,
+        'Failed to lookup the repo for change number 555'
+      );
     });
 
     test('getChange succeeds with project', async () => {
       const promise = mockPromise<undefined | ChangeInfo>();
       sinon.stub(element, 'getChange').returns(promise);
 
-      const projectLookup = element.getFromProjectLookup(changeNum);
+      const projectLookup = element.getRepoName(changeNum);
       promise.resolve({...createChange(), project: repo});
 
       assert.equal(await projectLookup, repo);
       assert.deepEqual(element._projectLookup, {'555': projectLookup});
     });
 
-    test('getChange fails, but a setInProjectLookup() call is used as fallback', async () => {
+    test('getChange fails, but a addRepoNameToCache() call is used as fallback', async () => {
       const promise = mockPromise<undefined>();
       sinon.stub(element, 'getChange').returns(promise);
 
-      const projectLookup = element.getFromProjectLookup(changeNum);
-      element.setInProjectLookup(changeNum, repo);
+      const projectLookup = element.getRepoName(changeNum);
+      element.addRepoNameToCache(changeNum, repo);
       promise.resolve(undefined);
 
       assert.equal(await projectLookup, repo);
@@ -1245,11 +1515,11 @@
       // Array<Array<Object>>.
       await element.getChangesForMultipleQueries(undefined, []);
       assert.equal(Object.keys(element._projectLookup).length, 3);
-      const project1 = await element.getFromProjectLookup(1 as NumericChangeId);
+      const project1 = await element.getRepoName(1 as NumericChangeId);
       assert.equal(project1, 'test' as RepoName);
-      const project2 = await element.getFromProjectLookup(2 as NumericChangeId);
+      const project2 = await element.getRepoName(2 as NumericChangeId);
       assert.equal(project2, 'test' as RepoName);
-      const project3 = await element.getFromProjectLookup(3 as NumericChangeId);
+      const project3 = await element.getRepoName(3 as NumericChangeId);
       assert.equal(project3, 'test/test' as RepoName);
     });
 
@@ -1263,11 +1533,11 @@
       // When query !instanceof Array, fetchJSON returns Array<Object>.
       await element.getChanges();
       assert.equal(Object.keys(element._projectLookup).length, 3);
-      const project1 = await element.getFromProjectLookup(1 as NumericChangeId);
+      const project1 = await element.getRepoName(1 as NumericChangeId);
       assert.equal(project1, 'test' as RepoName);
-      const project2 = await element.getFromProjectLookup(2 as NumericChangeId);
+      const project2 = await element.getRepoName(2 as NumericChangeId);
       assert.equal(project2, 'test' as RepoName);
-      const project3 = await element.getFromProjectLookup(3 as NumericChangeId);
+      const project3 = await element.getRepoName(3 as NumericChangeId);
       assert.equal(project3, 'test/test' as RepoName);
     });
   });
@@ -1290,122 +1560,95 @@
     assert.isTrue(getChangesStub.calledOnce);
   });
 
-  test('_getChangeURLAndFetch', async () => {
-    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
-    const fetchStub = sinon
-      .stub(element._restApiHelper, 'fetchJSON')
-      .resolves();
-    const req = {
-      changeNum: 1 as NumericChangeId,
-      endpoint: '/test',
-      revision: 1 as RevisionId,
-    };
-    await element._getChangeURLAndFetch(req);
-    assert.equal(
-      fetchStub.lastCall.args[0].url,
-      '/changes/test~1/revisions/1/test'
-    );
-  });
-
-  test('_getChangeURLAndSend', async () => {
-    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
-    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
-
-    const req = {
-      changeNum: 1 as NumericChangeId,
-      method: HttpMethod.POST,
-      patchNum: 1 as PatchSetNum,
-      endpoint: '/test',
-    };
-    await element._getChangeURLAndSend(req);
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
-    assert.equal(
-      sendStub.lastCall.args[0].url,
-      '/changes/test~1/revisions/1/test'
-    );
-  });
-
-  suite('reading responses', () => {
-    test('_readResponsePayload', async () => {
-      const mockObject = {foo: 'bar', baz: 'foo'} as unknown as ParsedJSON;
-      const serial = JSON_PREFIX + JSON.stringify(mockObject);
-      const response = new Response(serial);
-      const payload = await readResponsePayload(response);
-      assert.deepEqual(payload.parsed, mockObject);
-      assert.equal(payload.raw, serial);
-    });
-
-    test('_parsePrefixedJSON', () => {
-      const obj = {x: 3, y: {z: 4}, w: 23} as unknown as ParsedJSON;
-      const serial = JSON_PREFIX + JSON.stringify(obj);
-      const result = parsePrefixedJSON(serial);
-      assert.deepEqual(result, obj);
-    });
-  });
-
   test('setChangeTopic', async () => {
-    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
+    element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response(makePrefixedJSON('foo-bar')));
     await element.setChangeTopic(123 as NumericChangeId, 'foo-bar');
-    assert.isTrue(sendSpy.calledOnce);
-    assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
+    assert.isTrue(fetchStub.calledOnce);
+    assert.deepEqual(
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+      {topic: 'foo-bar'}
+    );
   });
 
   test('setChangeHashtag', async () => {
-    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
+    element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+    const fetchStub = sinon.stub(element._restApiHelper, 'fetchJSON');
     await element.setChangeHashtag(123 as NumericChangeId, {
       add: ['foo-bar' as Hashtag],
     });
-    assert.isTrue(sendSpy.calledOnce);
+    assert.isTrue(fetchStub.calledOnce);
     assert.sameDeepMembers(
-      (sendSpy.lastCall.args[0].body! as HashtagsInput).add!,
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string).add!,
       ['foo-bar']
     );
   });
 
   test('generateAccountHttpPassword', async () => {
-    const sendSpy = sinon.spy(element._restApiHelper, 'send');
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves();
     await element.generateAccountHttpPassword();
-    assert.isTrue(sendSpy.calledOnce);
-    assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
+    assert.isTrue(fetchStub.calledOnce);
+    assert.deepEqual(
+      JSON.parse(fetchStub.lastCall.args[0].fetchOptions?.body as string),
+      {generate: true}
+    );
   });
 
   suite('getChangeFiles', () => {
     test('patch only', async () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+      const fetchStub = sinon
+        .stub(element._restApiHelper, 'fetchJSON')
+        .resolves();
       const range = {basePatchNum: PARENT, patchNum: 2 as RevisionPatchSetNum};
       await element.getChangeFiles(123 as NumericChangeId, range);
       assert.isTrue(fetchStub.calledOnce);
       assert.equal(
-        fetchStub.lastCall.args[0].revision,
-        2 as RevisionPatchSetNum
+        fetchStub.lastCall.args[0].url,
+        '/changes/test-project~123/revisions/2/files'
       );
       assert.isNotOk(fetchStub.lastCall.args[0].params);
     });
 
     test('simple range', async () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+      const fetchStub = sinon
+        .stub(element._restApiHelper, 'fetchJSON')
+        .resolves();
       const range = {
         basePatchNum: 4 as BasePatchSetNum,
         patchNum: 5 as RevisionPatchSetNum,
       };
       await element.getChangeFiles(123 as NumericChangeId, range);
       assert.isTrue(fetchStub.calledOnce);
-      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.equal(
+        fetchStub.lastCall.args[0].url,
+        '/changes/test-project~123/revisions/5/files'
+      );
       assert.isOk(fetchStub.lastCall.args[0].params);
       assert.equal(fetchStub.lastCall.args[0].params!.base, 4);
       assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
     });
 
     test('parent index', async () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+      const fetchStub = sinon
+        .stub(element._restApiHelper, 'fetchJSON')
+        .resolves();
       const range = {
         basePatchNum: -3 as BasePatchSetNum,
         patchNum: 5 as RevisionPatchSetNum,
       };
       await element.getChangeFiles(123 as NumericChangeId, range);
       assert.isTrue(fetchStub.calledOnce);
-      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.equal(
+        fetchStub.lastCall.args[0].url,
+        '/changes/test-project~123/revisions/5/files'
+      );
       assert.isOk(fetchStub.lastCall.args[0].params);
       assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
       assert.equal(fetchStub.lastCall.args[0].params!.parent, 3);
@@ -1414,7 +1657,10 @@
 
   suite('getDiff', () => {
     test('patchOnly', async () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+      const fetchStub = sinon
+        .stub(element._restApiHelper, 'fetchJSON')
+        .resolves();
       await element.getDiff(
         123 as NumericChangeId,
         PARENT,
@@ -1422,14 +1668,20 @@
         'foo/bar.baz'
       );
       assert.isTrue(fetchStub.calledOnce);
-      assert.equal(fetchStub.lastCall.args[0].revision, 2 as RevisionId);
+      assert.equal(
+        fetchStub.lastCall.args[0].url,
+        '/changes/test-project~123/revisions/2/files/foo%2Fbar.baz/diff'
+      );
       assert.isOk(fetchStub.lastCall.args[0].params);
       assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
       assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
     });
 
     test('simple range', async () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+      const fetchStub = sinon
+        .stub(element._restApiHelper, 'fetchJSON')
+        .resolves();
       await element.getDiff(
         123 as NumericChangeId,
         4 as PatchSetNum,
@@ -1437,14 +1689,20 @@
         'foo/bar.baz'
       );
       assert.isTrue(fetchStub.calledOnce);
-      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.equal(
+        fetchStub.lastCall.args[0].url,
+        '/changes/test-project~123/revisions/5/files/foo%2Fbar.baz/diff'
+      );
       assert.isOk(fetchStub.lastCall.args[0].params);
       assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
       assert.equal(fetchStub.lastCall.args[0].params!.base, 4);
     });
 
     test('parent index', async () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+      const fetchStub = sinon
+        .stub(element._restApiHelper, 'fetchJSON')
+        .resolves();
       await element.getDiff(
         123 as NumericChangeId,
         -3 as PatchSetNum,
@@ -1452,7 +1710,10 @@
         'foo/bar.baz'
       );
       assert.isTrue(fetchStub.calledOnce);
-      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.equal(
+        fetchStub.lastCall.args[0].url,
+        '/changes/test-project~123/revisions/5/files/foo%2Fbar.baz/diff'
+      );
       assert.isOk(fetchStub.lastCall.args[0].params);
       assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
       assert.equal(fetchStub.lastCall.args[0].params!.parent, 3);
@@ -1460,35 +1721,34 @@
   });
 
   test('getDashboard', () => {
-    const fetchCacheURLStub = sinon.stub(
+    const fetchCacheJSONStub = sinon.stub(
       element._restApiHelper,
-      'fetchCacheURL'
+      'fetchCacheJSON'
     );
     element.getDashboard(
       'gerrit/project' as RepoName,
       'default:main' as DashboardId
     );
-    assert.isTrue(fetchCacheURLStub.calledOnce);
+    assert.isTrue(fetchCacheJSONStub.calledOnce);
     assert.equal(
-      fetchCacheURLStub.lastCall.args[0].url,
+      fetchCacheJSONStub.lastCall.args[0].url,
       '/projects/gerrit%2Fproject/dashboards/default%3Amain'
     );
   });
 
   test('getFileContent', async () => {
-    sinon.stub(element, '_getChangeURLAndSend').resolves(
-      new Response(undefined, {
-        status: 200,
-        headers: {
-          'X-FYI-Content-Type': 'text/java',
-        },
-      }) as unknown as ParsedJSON
+    element.addRepoNameToCache(1 as NumericChangeId, TEST_PROJECT_NAME);
+    sinon.stub(element._restApiHelper, 'fetch').callsFake(() =>
+      Promise.resolve(
+        new Response(makePrefixedJSON('new content'), {
+          status: 200,
+          headers: {
+            'X-FYI-Content-Type': 'text/java',
+          },
+        })
+      )
     );
 
-    sinon
-      .stub(element, 'getResponseObject')
-      .resolves('new content' as unknown as ParsedJSON);
-
     const edit = await element.getFileContent(
       1 as NumericChangeId,
       'tst/path',
@@ -1560,35 +1820,34 @@
     assert.isTrue(getChangeFilesStub.calledOnce);
   });
 
-  test('_fetch forwards request and logs', async () => {
-    const logStub = sinon.stub(element._restApiHelper, '_logCall');
-    const response = new Response(undefined, {status: 404});
-    const url = 'my url';
-    const fetchOptions = {method: 'DELETE'};
-    sinon.stub(authService, 'fetch').resolves(response);
-    const startTime = 123;
-    sinon.stub(Date, 'now').returns(startTime);
-    const req = {url, fetchOptions};
-    await element._restApiHelper.fetch(req);
-    assert.isTrue(logStub.calledOnce);
-    assert.isTrue(logStub.calledWith(req, startTime, response.status));
+  test('getChangeEdit not logged in returns undefined', async () => {
+    element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+    sinon.stub(element, 'getLoggedIn').resolves(false);
+    const fetchSpy = sinon.spy(element._restApiHelper, 'fetch');
+    const edit = await element.getChangeEdit(123 as NumericChangeId);
+    assert.isUndefined(edit);
+    assert.isFalse(fetchSpy.called);
   });
 
-  test('_logCall only reports requests with anonymized URLss', async () => {
-    sinon.stub(Date, 'now').returns(200);
-    const handler = sinon.stub();
-    addListenerForTest(document, 'gr-rpc-log', handler);
+  test('getChangeEdit no edit patchset returns undefined', async () => {
+    element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+    sinon.stub(element, 'getLoggedIn').resolves(true);
+    sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response(undefined, {status: 204}));
+    const edit = await element.getChangeEdit(123 as NumericChangeId);
+    assert.isUndefined(edit);
+  });
 
-    element._restApiHelper._logCall({url: 'url'}, 100, 200);
-    assert.isFalse(handler.called);
-
-    element._restApiHelper._logCall(
-      {url: 'url', anonymizedUrl: 'not url'},
-      100,
-      200
-    );
-    await waitEventLoop();
-    assert.isTrue(handler.calledOnce);
+  test('getChangeEdit returns edit patchset', async () => {
+    element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME);
+    sinon.stub(element, 'getLoggedIn').resolves(true);
+    const expected = createEditInfo();
+    sinon
+      .stub(element._restApiHelper, 'fetch')
+      .resolves(new Response(makePrefixedJSON(expected)));
+    const edit = await element.getChangeEdit(123 as NumericChangeId);
+    assert.deepEqual(edit, expected);
   });
 
   test('ported comment errors do not trigger error dialog', () => {
@@ -1606,10 +1865,11 @@
 
   test('ported drafts are not requested user is not logged in', () => {
     const change = createChange();
+    element.addRepoNameToCache(change._number, TEST_PROJECT_NAME);
     sinon.stub(element, 'getLoggedIn').resolves(false);
     const getChangeURLAndFetchStub = sinon.stub(
-      element,
-      '_getChangeURLAndFetch'
+      element._restApiHelper,
+      'fetchJSON'
     );
 
     element.getPortedDrafts(change._number, CURRENT);
@@ -1618,21 +1878,22 @@
   });
 
   test('saveChangeStarred', async () => {
-    sinon.stub(element, 'getFromProjectLookup').resolves('test' as RepoName);
-    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+    element.addRepoNameToCache(123 as NumericChangeId, 'test' as RepoName);
+    element.addRepoNameToCache(456 as NumericChangeId, 'test' as RepoName);
+    const fetchStub = sinon.stub(element._restApiHelper, 'fetch').resolves();
 
     await element.saveChangeStarred(123 as NumericChangeId, true);
-    assert.isTrue(sendStub.calledOnce);
-    assert.deepEqual(sendStub.lastCall.args[0], {
-      method: HttpMethod.PUT,
+    assert.isTrue(fetchStub.calledOnce);
+    assert.deepEqual(fetchStub.lastCall.args[0], {
+      fetchOptions: {method: HttpMethod.PUT},
       url: '/accounts/self/starred.changes/test~123',
       anonymizedUrl: '/accounts/self/starred.changes/*',
     });
 
     await element.saveChangeStarred(456 as NumericChangeId, false);
-    assert.isTrue(sendStub.calledTwice);
-    assert.deepEqual(sendStub.lastCall.args[0], {
-      method: HttpMethod.DELETE,
+    assert.isTrue(fetchStub.calledTwice);
+    assert.deepEqual(fetchStub.lastCall.args[0], {
+      fetchOptions: {method: HttpMethod.DELETE},
       url: '/accounts/self/starred.changes/test~456',
       anonymizedUrl: '/accounts/self/starred.changes/*',
     });
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 546f06a0..947952c 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
@@ -40,7 +40,6 @@
   FileNameToFileInfoMap,
   FilePathToDiffInfoMap,
   FixId,
-  FixReplacementInfo,
   GitRef,
   GpgKeyId,
   GpgKeyInfo,
@@ -99,8 +98,7 @@
 } from '../../types/diff';
 import {Finalizable, ParsedChangeInfo} from '../../types/types';
 import {ErrorCallback} from '../../api/rest';
-
-export type CancelConditionCallback = () => boolean;
+import {FixReplacementInfo} from '../../api/rest-api';
 
 export interface GetDiffCommentsOutput {
   baseComments: CommentInfo[];
@@ -122,8 +120,8 @@
     params?: string[]
   ): Promise<AccountCapabilityInfo | undefined>;
   getExternalIds(): Promise<AccountExternalIdInfo[] | undefined>;
-  deleteAccountIdentity(id: string[]): Promise<unknown>;
-  deleteAccount(): Promise<unknown>;
+  deleteAccountIdentity(id: string[]): Promise<Response>;
+  deleteAccount(): Promise<Response>;
   getRepos(
     filter: string | undefined,
     reposPerPage: number,
@@ -149,6 +147,11 @@
     headers?: Record<string, string>
   ): Promise<Response | void>;
 
+  /**
+   * DEPRECATED: Use functions from gr-rest-api-helper directly.
+   *
+   * Preserved for plugins that use it.
+   */
   getResponseObject(response: Response): Promise<ParsedJSON>;
 
   getChangeSuggestedReviewers(
@@ -165,13 +168,14 @@
    * Request list of accounts via https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#query-account
    * Operators defined here https://gerrit-review.googlesource.com/Documentation/user-search-accounts.html#_search_operators
    */
-  getSuggestedAccounts(
+  queryAccounts(
     input: string,
     n?: number,
     canSee?: NumericChangeId,
     filterActive?: boolean,
     errFn?: ErrorCallback
   ): Promise<AccountInfo[] | undefined>;
+  getAccountSuggestions(input: string): Promise<AccountInfo[] | undefined>;
   getSuggestedGroups(
     input: string,
     project?: RepoName,
@@ -188,7 +192,7 @@
     patchNum?: PatchSetNum,
     payload?: RequestPayload,
     errFn?: ErrorCallback
-  ): Promise<Response | undefined>;
+  ): Promise<Response>;
   getRepoBranches(
     filter: string,
     repo: RepoName,
@@ -199,8 +203,7 @@
 
   getChangeDetail(
     changeNum?: number | string,
-    errFn?: ErrorCallback,
-    cancelCondition?: Function
+    errFn?: ErrorCallback
   ): Promise<ParsedChangeInfo | undefined>;
 
   /**
@@ -321,7 +324,7 @@
   setRepoAccessRightsForReview(
     projectName: RepoName,
     projectInfo: ProjectAccessInput
-  ): Promise<ChangeInfo>;
+  ): Promise<ChangeInfo | undefined>;
 
   getGroups(
     filter: string,
@@ -390,14 +393,24 @@
   ): Promise<IncludedInInfo | undefined>;
 
   /**
-   * Checks in projectLookup map shared across instances for the changeNum.
-   * If it exists, returns the project. If not, calls the restAPI to get the
-   * change, populates projectLookup with the project for that change, and
-   * returns the project.
+   * Looks up repo name in which change is located.
+   *
+   * Change -> repo association is cached. This will only make restAPI call (and
+   * cache the result) if the repo name for the change is not already known.
+   *
+   * addRepoNameToCache can be used to add entry to the cache manually.
+   *
+   * If the lookup fails the promise rejects and result is not cached.
    */
-  getFromProjectLookup(
-    changeNum: NumericChangeId
-  ): Promise<RepoName | undefined>;
+  getRepoName(changeNum: NumericChangeId): Promise<RepoName>;
+
+  /**
+   * Populates cache for the future getRepoName(changeNum) lookup.
+   *
+   * The repo name is used for constructing of url for all change-based
+   * endpoints.
+   */
+  addRepoNameToCache(changeNum: NumericChangeId, repo: RepoName): void;
 
   saveDiffDraft(
     changeNum: NumericChangeId,
@@ -505,7 +518,7 @@
 
   saveAccountAgreement(name: ContributorAgreementInput): Promise<Response>;
 
-  generateAccountHttpPassword(): Promise<Password>;
+  generateAccountHttpPassword(): Promise<Password | undefined>;
 
   setAccountName(name: string): Promise<void>;
 
@@ -515,7 +528,7 @@
 
   saveWatchedProjects(
     projects: ProjectWatchInfo[]
-  ): Promise<ProjectWatchInfo[]>;
+  ): Promise<ProjectWatchInfo[] | undefined>;
 
   deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response>;
 
@@ -558,7 +571,7 @@
     patchNum: PatchSetNum,
     commentID: UrlEncodedCommentId,
     reason: string
-  ): Promise<CommentInfo>;
+  ): Promise<CommentInfo | undefined>;
   deleteDiffDraft(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
@@ -589,7 +602,7 @@
   saveGroupMember(
     groupName: GroupId | GroupName,
     groupMember: AccountId
-  ): Promise<AccountInfo>;
+  ): Promise<AccountInfo | undefined>;
 
   saveIncludedGroup(
     groupName: GroupId | GroupName,
@@ -784,10 +797,30 @@
 
   setChangeHashtag(
     changeNum: NumericChangeId,
-    hashtag: HashtagsInput
-  ): Promise<Hashtag[]>;
+    hashtag: HashtagsInput,
+    errFn?: ErrorCallback
+  ): Promise<Hashtag[] | undefined>;
 
-  setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string>;
+  /**
+   * Set change topic.
+   *
+   * Returns topic that the change has after the requests.
+   */
+  setChangeTopic(
+    changeNum: NumericChangeId,
+    topic?: string,
+    errFn?: ErrorCallback
+  ): Promise<string | undefined>;
+
+  /**
+   * Remove change topic.
+   *
+   * Returns topic that the change has after the requests. (ie. '' on success)
+   */
+  removeChangeTopic(
+    changeNum: NumericChangeId,
+    errFn?: ErrorCallback
+  ): Promise<string | undefined>;
 
   getChangeFiles(
     changeNum: NumericChangeId,
@@ -813,14 +846,21 @@
 
   getTopMenus(): Promise<TopMenuEntryInfo[] | undefined>;
 
-  setInProjectLookup(changeNum: NumericChangeId, repo: RepoName): void;
   getMergeable(changeNum: NumericChangeId): Promise<MergeableInfo | undefined>;
 
   putChangeCommitMessage(
     changeNum: NumericChangeId,
-    message: string
+    message: string,
+    committerEmail: string | null
   ): Promise<Response>;
 
+  updateIdentityInChangeEdit(
+    changeNum: NumericChangeId,
+    name: string,
+    email: string,
+    type: string
+  ): Promise<Response | undefined>;
+
   getChangeCommitInfo(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum
diff --git a/polygerrit-ui/app/services/scheduler/retry-scheduler.ts b/polygerrit-ui/app/services/scheduler/retry-scheduler.ts
index 04ced2f..d96fc0b 100644
--- a/polygerrit-ui/app/services/scheduler/retry-scheduler.ts
+++ b/polygerrit-ui/app/services/scheduler/retry-scheduler.ts
@@ -15,6 +15,16 @@
   return new Promise(resolve => window.setTimeout(resolve, ms));
 }
 
+/**
+ * The scheduler that retries tasks on RetryError.
+ *
+ * The task is only retried if the RetryError was thrown, all other errors cause
+ * the worker to stop and the error is re-thrown.
+ *
+ * The number of retries are limited by maxRetry, the retries are performed
+ * according to exponential backoff, configured by backoffIntervalMs
+ * and backoffFactor.
+ */
 export class RetryScheduler<T> implements Scheduler<T> {
   constructor(
     private readonly base: Scheduler<T>,
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index db9d8fe..a352f68 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -175,6 +175,12 @@
       if (optPreventDefault) e.preventDefault();
       if (optPreventDefault) e.stopPropagation();
       this.reportTriggered(e);
+      if (shortcut.combo) {
+        // Do not reset immediately, otherwise other shortcut might be triggered.
+        setTimeout(() => {
+          this.comboKeyLastPressed = {};
+        });
+      }
       listener(e);
     };
     element.addEventListener('keydown', wrappedListener);
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 7969264..77f2498 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -61,7 +61,6 @@
   DraftInfo,
 } from '../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../types/diff';
-import {readResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {
   createAccountDetailWithId,
   createChange,
@@ -77,6 +76,8 @@
   createDefaultPreferences,
 } from '../../constants/constants';
 import {ParsedChangeInfo} from '../../types/types';
+import {readJSONResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {ErrorCallback} from '../../api/rest';
 
 export const grRestApiMock: RestApiService = {
   addAccountEmail(): Promise<Response> {
@@ -127,14 +128,14 @@
   deleteAccountGPGKey(): Promise<Response> {
     return Promise.resolve(new Response());
   },
-  deleteAccountIdentity(): Promise<unknown> {
+  deleteAccountIdentity(): Promise<Response> {
     return Promise.resolve(new Response());
   },
   deleteAccountSSHKey(): void {},
   deleteChangeCommitMessage(): Promise<Response> {
     return Promise.resolve(new Response());
   },
-  deleteComment(): Promise<CommentInfo> {
+  deleteComment(): Promise<CommentInfo | undefined> {
     throw new Error('deleteComment() not implemented by RestApiMock.');
   },
   deleteDiffDraft(): Promise<Response> {
@@ -164,11 +165,11 @@
   deleteWatchedProjects(): Promise<Response> {
     return Promise.resolve(new Response());
   },
-  executeChangeAction(): Promise<Response | undefined> {
+  executeChangeAction(): Promise<Response> {
     return Promise.resolve(new Response());
   },
   finalize(): void {},
-  generateAccountHttpPassword(): Promise<Password> {
+  generateAccountHttpPassword(): Promise<Password | undefined> {
     return Promise.resolve('asdf');
   },
   getAccount(): Promise<AccountDetailInfo | undefined> {
@@ -324,8 +325,8 @@
   getFileContent(): Promise<Response | Base64FileContent | undefined> {
     return Promise.resolve(new Response());
   },
-  getFromProjectLookup(): Promise<RepoName | undefined> {
-    throw new Error('getFromProjectLookup() not implemented by RestApiMock.');
+  getRepoName(): Promise<RepoName> {
+    throw new Error('getRepoName() not implemented by RestApiMock.');
   },
   getGroupAuditLog(): Promise<GroupAuditEventInfo[] | undefined> {
     return Promise.resolve([]);
@@ -402,7 +403,7 @@
     return Promise.resolve([]);
   },
   getResponseObject(response: Response): Promise<ParsedJSON> {
-    return readResponsePayload(response).then(payload => payload.parsed);
+    return readJSONResponsePayload(response).then(payload => payload.parsed);
   },
   getReviewedFiles(): Promise<string[] | undefined> {
     return Promise.resolve([]);
@@ -413,7 +414,10 @@
   getRobotCommentFixPreview(): Promise<FilePathToDiffInfoMap | undefined> {
     return Promise.resolve({});
   },
-  getSuggestedAccounts(): Promise<AccountInfo[] | undefined> {
+  queryAccounts(): Promise<AccountInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
+  getAccountSuggestions(): Promise<AccountInfo[] | undefined> {
     return Promise.resolve([]);
   },
   getSuggestedGroups(): Promise<GroupNameToGroupInfoMap | undefined> {
@@ -492,7 +496,7 @@
   saveGroupDescription(): Promise<Response> {
     return Promise.resolve(new Response());
   },
-  saveGroupMember(): Promise<AccountInfo> {
+  saveGroupMember(): Promise<AccountInfo | undefined> {
     return Promise.resolve({});
   },
   saveGroupName(): Promise<Response> {
@@ -514,7 +518,7 @@
   saveRepoConfig(): Promise<Response> {
     return Promise.resolve(new Response());
   },
-  saveWatchedProjects(): Promise<ProjectWatchInfo[]> {
+  saveWatchedProjects(): Promise<ProjectWatchInfo[] | undefined> {
     return Promise.resolve([]);
   },
   send() {
@@ -532,26 +536,35 @@
   setAccountUsername(): Promise<void> {
     return Promise.resolve();
   },
-  setChangeHashtag(): Promise<Hashtag[]> {
+  setChangeHashtag(): Promise<Hashtag[] | undefined> {
     return Promise.resolve([]);
   },
-  setChangeTopic(): Promise<string> {
+  setChangeTopic(): Promise<string | undefined> {
     return Promise.resolve('');
   },
+  removeChangeTopic(
+    changeNum: NumericChangeId,
+    errFn?: ErrorCallback
+  ): Promise<string | undefined> {
+    return this.setChangeTopic(changeNum, '', errFn);
+  },
   setDescription(): Promise<Response> {
     return Promise.resolve(new Response());
   },
-  setInProjectLookup(): void {},
+  addRepoNameToCache(): void {},
   setPreferredAccountEmail(): Promise<void> {
     return Promise.resolve();
   },
   setRepoAccessRights(): Promise<Response> {
     return Promise.resolve(new Response());
   },
-  setRepoAccessRightsForReview(): Promise<ChangeInfo> {
+  setRepoAccessRightsForReview(): Promise<ChangeInfo | undefined> {
     throw new Error('setRepoAccessRightsForReview() not implemented by mock.');
   },
   setRepoHead(): Promise<Response> {
     return Promise.resolve(new Response());
   },
+  updateIdentityInChangeEdit(): Promise<Response | undefined> {
+    return Promise.resolve(new Response());
+  },
 };
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index d941c9f..cba3a05 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -105,7 +105,7 @@
 } from '../api/rest-api';
 import {CheckResult, CheckRun, RunResult} from '../models/checks/checks-model';
 import {Category, Fix, Link, LinkIcon, RunStatus} from '../api/checks';
-import {DiffInfo} from '../api/diff';
+import {DiffInfo, GrDiffLineType} from '../api/diff';
 import {SearchViewState} from '../models/views/search';
 import {ChangeChildView, ChangeViewState} from '../models/views/change';
 import {NormalizedFileInfo} from '../models/change/files-model';
@@ -113,6 +113,11 @@
 import {RepoDetailView, RepoViewState} from '../models/views/repo';
 import {AdminChildView, AdminViewState} from '../models/views/admin';
 import {DashboardType, DashboardViewState} from '../models/views/dashboard';
+import {GrDiffLine} from '../embed/diff/gr-diff/gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+} from '../embed/diff/gr-diff/gr-diff-group';
 
 const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN';
 export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
@@ -457,6 +462,7 @@
 
 export function createChangeConfig(): ChangeConfigInfo {
   return {
+    allow_blame: true,
     large_change: 500,
     // The default update_delay is 5 minutes, but we don't want to accidentally
     // start polling in tests
@@ -662,6 +668,23 @@
   };
 }
 
+export function createContextGroup(options: {offset?: number; count?: number}) {
+  const offset = options.offset || 0;
+  const numLines = options.count || 10;
+  const lines = [];
+  for (let i = 0; i < numLines; i++) {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.beforeNumber = offset + i + 1;
+    line.afterNumber = offset + i + 1;
+    line.text = 'lorem upsum';
+    lines.push(line);
+  }
+  return new GrDiffGroup({
+    type: GrDiffGroupType.CONTEXT_CONTROL,
+    contextGroups: [new GrDiffGroup({type: GrDiffGroupType.BOTH, lines})],
+  });
+}
+
 export function createBlame(): BlameInfo {
   return {
     author: 'test-author',
@@ -686,6 +709,7 @@
     changes_per_page: 10,
     email_strategy: EmailStrategy.ENABLED,
     allow_browser_notifications: true,
+    allow_suggest_code_while_commenting: true,
   };
 }
 
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 6e20dd4..571edf0 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -17,6 +17,7 @@
 import {Route, ViewState} from '../models/views/base';
 import {PageContext} from '../elements/core/gr-router/gr-page';
 import {waitUntil} from '../utils/async-util';
+import {JSON_PREFIX} from '../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
 export {mockPromise, waitUntil} from '../utils/async-util';
 export type {MockPromise} from '../utils/async-util';
@@ -311,3 +312,7 @@
   const matches = ctx.match(route.urlPattern);
   assert.isFalse(matches);
 }
+
+export function makePrefixedJSON(obj: any) {
+  return JSON_PREFIX + JSON.stringify(obj);
+}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 9992f8b..0cf9cd9 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -119,6 +119,9 @@
   isQuickLabelInfo,
   Base64FileContent,
   CommentRange,
+  FixReplacementInfo,
+  FixSuggestionInfo,
+  FixId,
 } from '../api/rest-api';
 import {DiffInfo, IgnoreWhitespaceType} from './diff';
 import {PatchRange, LineNumber} from '../api/diff';
@@ -144,8 +147,8 @@
   ChangeMessageId,
   ChangeMessageInfo,
   ChangeSubmissionId,
-  CommentInfo,
   CommentLinkInfo,
+  CommentInfo,
   CommentLinks,
   CommentRange,
   CommitId,
@@ -163,6 +166,8 @@
   EditPatchSet,
   EmailAddress,
   FileInfo,
+  FixId,
+  FixSuggestionInfo,
   GerritInfo,
   GitPersonInfo,
   GitRef,
@@ -237,9 +242,6 @@
 // in our code, so it is not added here as a possible value.
 export type RevisionId = 'current' | CommitId | PatchSetNum;
 
-// The UUID of the suggested fix.
-export type FixId = BrandType<string, '_fixId'>;
-
 // The ID of the dashboard, in the form of '<ref>:<path>'
 export type DashboardId = BrandType<string, '_dahsboardId'>;
 
@@ -1201,6 +1203,7 @@
   message?: string;
   tag?: string;
   unresolved?: boolean;
+  fix_suggestions?: FixSuggestionInfo[];
 }
 
 /**
@@ -1335,6 +1338,7 @@
   // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
   email_format?: EmailFormat;
   allow_browser_notifications?: boolean;
+  allow_suggest_code_while_commenting?: boolean;
   diff_page_sidebar?: DiffPageSidebar;
 }
 
@@ -1428,26 +1432,10 @@
   robot_run_id: RobotRunId;
   url?: string;
   properties: {[propertyName: string]: string};
-  fix_suggestions: FixSuggestionInfo[];
 }
 export type PathToRobotCommentsInfoMap = {[path: string]: RobotCommentInfo[]};
 
 /**
- * The FixSuggestionInfo entity represents a suggested fix
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fix-suggestion-info
- */
-export interface FixSuggestionInfoInput {
-  description: string;
-  replacements: FixReplacementInfo[];
-}
-
-export interface FixSuggestionInfo extends FixSuggestionInfoInput {
-  fix_id: FixId;
-  description: string;
-  replacements: FixReplacementInfo[];
-}
-
-/**
  * The ApplyProvidedFixInput entity contains information for applying fixes, provided in the
  * request body, to a revision.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#apply-provided-fix
@@ -1457,16 +1445,6 @@
 }
 
 /**
- * The FixReplacementInfo entity describes how the content of a file should be replaced by another content
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fix-replacement-info
- */
-export interface FixReplacementInfo {
-  path: string;
-  range: CommentRange;
-  replacement: string;
-}
-
-/**
  * The NotifyInfo entity contains detailed information about who should be notified about an update
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#notify-info
  */
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index e59a066..6e4ce86 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -118,6 +118,7 @@
 
 export interface EditableContentSaveEventDetail {
   content: string;
+  committerEmail: string | null;
 }
 export type EditableContentSaveEvent =
   CustomEvent<EditableContentSaveEventDetail>;
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 5079abd..a78254d 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -87,10 +87,8 @@
   change: ChangeInfo,
   options?: ChangeStatusesOptions
 ): ChangeStates[] {
-  const states = [];
-  if (change.revert_of) {
-    states.push(ChangeStates.REVERT);
-  }
+  const states: ChangeStates[] = [];
+
   if (change.status === ChangeStatus.MERGED) {
     if (options?.revertingChangeStatus === ChangeStatus.MERGED) {
       return [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED];
@@ -103,6 +101,10 @@
   if (change.status === ChangeStatus.ABANDONED) {
     return [ChangeStates.ABANDONED];
   }
+
+  if (change.revert_of) {
+    states.push(ChangeStates.REVERT);
+  }
   if (change.mergeable === false || (options && options.mergeable === false)) {
     // 'mergeable' prop may not always exist (@see Issue 6819)
     states.push(ChangeStates.MERGE_CONFLICT);
@@ -116,15 +118,29 @@
     states.push(ChangeStates.PRIVATE);
   }
 
-  // If there are any pre-defined statuses, only return those. Otherwise,
-  // will determine the derived status.
-  if (states.length || !options) {
+  // The gr-change-list table does not want READY TO SUBMIT or ACTIVE and it
+  // does not pass options.
+  if (!options) {
+    return states;
+  }
+
+  // The change is not submittable if there are conflicts or is WIP/private even
+  // if the submit requirements are ok.
+  if (
+    [
+      ChangeStates.MERGE_CONFLICT,
+      ChangeStates.GIT_CONFLICT,
+      ChangeStates.WIP,
+      ChangeStates.PRIVATE,
+    ].some(unsubmittableState => states.includes(unsubmittableState))
+  ) {
     return states;
   }
 
   if (change.submittable) {
     states.push(ChangeStates.READY_TO_SUBMIT);
-  } else {
+  }
+  if (states.length === 0) {
     states.push(ChangeStates.ACTIVE);
   }
   return states;
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
index 6e53c16..51e935e 100644
--- a/polygerrit-ui/app/utils/change-util_test.ts
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -176,6 +176,18 @@
     ]);
   });
 
+  test('Revert that is submittable', () => {
+    const change = {
+      ...createChange(),
+      revert_of: 123 as NumericChangeId,
+      submittable: true,
+    };
+    assert.deepEqual(changeStatuses(change, {mergeable: true}), [
+      ChangeStates.REVERT,
+      ChangeStates.READY_TO_SUBMIT,
+    ]);
+  });
+
   test('Open status with private and wip', () => {
     const change = {
       ...createChange(),
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 7fad329..e82b328 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -262,6 +262,15 @@
   return isRobot(getFirstComment(thread));
 }
 
+export function hasSuggestion(thread: CommentThread): boolean {
+  const firstComment = getFirstComment(thread);
+  if (!firstComment) return false;
+  return (
+    hasUserSuggestion(firstComment) ||
+    firstComment.fix_suggestions?.[0] !== undefined
+  );
+}
+
 export function hasHumanReply(thread: CommentThread): boolean {
   return countComments(thread) > 1 && !isRobot(getLastComment(thread));
 }
@@ -589,5 +598,8 @@
   if (comment.tag) {
     output.tag = comment.tag;
   }
+  if (comment.fix_suggestions) {
+    output.fix_suggestions = comment.fix_suggestions;
+  }
   return output;
 }
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
index 850509f..5f56e51 100644
--- a/polygerrit-ui/app/utils/display-name-util.ts
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -63,7 +63,7 @@
     .join(' ');
 }
 
-function accountEmail(email?: string) {
+export function accountEmail(email?: string) {
   if (typeof email !== 'undefined') {
     return '<' + email + '>';
   }
diff --git a/polygerrit-ui/app/utils/file-util.ts b/polygerrit-ui/app/utils/file-util.ts
index 246ac20..a82ce10 100644
--- a/polygerrit-ui/app/utils/file-util.ts
+++ b/polygerrit-ui/app/utils/file-util.ts
@@ -43,3 +43,11 @@
   }
   return input;
 }
+
+export function getFileExtension(fileName: string): string {
+  const index = fileName.lastIndexOf('.');
+  if (index === -1) {
+    return '';
+  }
+  return fileName.substring(index + 1);
+}
diff --git a/polygerrit-ui/app/utils/file-util_test.ts b/polygerrit-ui/app/utils/file-util_test.ts
index aeab026..f725041 100644
--- a/polygerrit-ui/app/utils/file-util_test.ts
+++ b/polygerrit-ui/app/utils/file-util_test.ts
@@ -3,9 +3,14 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {assert} from '@open-wc/testing';
 import '../test/common-test-setup';
-import {expandFileMode, FileMode, fileModeToString} from './file-util';
+import {assert} from '@open-wc/testing';
+import {
+  expandFileMode,
+  FileMode,
+  fileModeToString,
+  getFileExtension,
+} from './file-util';
 
 suite('file-util tests', () => {
   test('fileModeToString', () => {
@@ -35,4 +40,15 @@
       ['old mode regular (100644)', 'new mode executable (100755)']
     );
   });
+
+  suite('getFileExtension', () => {
+    test('returns an empty string when the file name does not have an extension', () => {
+      assert.equal(getFileExtension('my_file'), '');
+    });
+    test('returns the extension when the file name has an extension', () => {
+      assert.equal(getFileExtension('my_file.txt'), 'txt');
+      assert.equal(getFileExtension('folder/my_file.java'), 'java');
+      assert.equal(getFileExtension('.hidden_file.ts'), 'ts');
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 27366d9..bdc7cb03 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -101,6 +101,32 @@
   return getLabelStatus(label, approvalInfo.value) === LabelStatus.NEUTRAL;
 }
 
+export function hasApprovedVote(labelInfo: LabelInfo) {
+  if (isDetailedLabelInfo(labelInfo)) {
+    return getAllUniqueApprovals(labelInfo).some(
+      approval =>
+        getLabelStatus(labelInfo, approval.value) === LabelStatus.APPROVED
+    );
+  } else if (isQuickLabelInfo(labelInfo)) {
+    return getLabelStatus(labelInfo) === LabelStatus.APPROVED;
+  } else {
+    return false;
+  }
+}
+
+export function hasRejectedVote(labelInfo: LabelInfo) {
+  if (isDetailedLabelInfo(labelInfo)) {
+    return getAllUniqueApprovals(labelInfo).some(
+      approval =>
+        getLabelStatus(labelInfo, approval.value) === LabelStatus.REJECTED
+    );
+  } else if (isQuickLabelInfo(labelInfo)) {
+    return getLabelStatus(labelInfo) === LabelStatus.REJECTED;
+  } else {
+    return false;
+  }
+}
+
 export function classForLabelStatus(status: LabelStatus) {
   switch (status) {
     case LabelStatus.APPROVED:
@@ -221,6 +247,21 @@
   return;
 }
 
+export function extractLabelsWithCountFrom(expression: string) {
+  const pattern = new RegExp(
+    'label[0-9]*:([\\w-]+)[^,]*,count>?=?([0-9])',
+    'g'
+  );
+  const labels = [];
+  let match;
+  while ((match = pattern.exec(expression)) !== null) {
+    if (match[2] && !isNaN(Number(match[2]))) {
+      labels.push({label: match[1], count: Number(match[2])});
+    }
+  }
+  return labels;
+}
+
 function extractLabelsFrom(expression: string) {
   const pattern = new RegExp('label[0-9]*:([\\w-]+)', 'g');
   const labels = [];
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
index c86bda9..a0b6c50 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -26,6 +26,7 @@
   valueString,
   hasVotes,
   hasVoted,
+  extractLabelsWithCountFrom,
 } from './label-util';
 import {
   AccountId,
@@ -505,6 +506,31 @@
     });
   });
 
+  suite('extractLabelsWithCountFrom', () => {
+    test('returns an empty array when the expression does not match the pattern', () => {
+      assert.deepEqual(extractLabelsWithCountFrom('foo'), []);
+      assert.deepEqual(
+        extractLabelsWithCountFrom('label:Verified=MAX -label:Code-Review=MIN'),
+        []
+      );
+    });
+
+    test('returns an empty array when count is not number', () => {
+      assert.deepEqual(extractLabelsWithCountFrom('label:name,count>=a'), []);
+    });
+
+    test('returns an array with label and count object when the expression matches the pattern', () => {
+      assert.deepEqual(extractLabelsWithCountFrom('label1:name=MIN,count>=1'), [
+        {label: 'name', count: 1},
+      ]);
+
+      assert.deepEqual(
+        extractLabelsWithCountFrom('label:Code-Review=MAX,count>=2'),
+        [{label: 'Code-Review', count: 2}]
+      );
+    });
+  });
+
   suite('getRequirements()', () => {
     function createChangeInfoWith(
       submit_requirements: SubmitRequirementResultInfo[]
diff --git a/polygerrit-ui/app/utils/location-util.ts b/polygerrit-ui/app/utils/location-util.ts
new file mode 100644
index 0000000..d0eac74
--- /dev/null
+++ b/polygerrit-ui/app/utils/location-util.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// This file adds some simple checks to match internal Google rules.
+// Internally at Google it has different a implementation.
+
+import {safeLocation} from 'safevalues/dom';
+
+export function setHref(loc: Location, url: string) {
+  safeLocation.setHref(loc, url);
+}
+
+export function replace(loc: Location, url: string) {
+  safeLocation.replace(loc, url);
+}
+
+export function assign(loc: Location, url: string) {
+  safeLocation.assign(loc, url);
+}
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 96edc7e..6c38e59 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -203,6 +203,15 @@
 
 export function sameOrigin(href: string) {
   if (!href) return false;
-  const url = new URL(href, window.location.origin);
+  let url;
+  try {
+    url = new URL(href, window.location.origin);
+  } catch (e) {
+    // If the link is not valid url consider to be not the same origin.
+    if (e instanceof TypeError) {
+      return false;
+    }
+    throw e;
+  }
   return url.origin === window.location.origin;
 }
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index a92d8b1..265becf 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -133,6 +133,7 @@
     assert.isTrue(sameOrigin('/asdf'));
     assert.isTrue(sameOrigin(window.location.origin + '/asdf'));
     assert.isFalse(sameOrigin('http://www.goole.com/asdf'));
+    assert.isFalse(sameOrigin('http://b]'));
   });
 
   test('toPathname', () => {
diff --git a/polygerrit-ui/app/workers/service-worker-class.ts b/polygerrit-ui/app/workers/service-worker-class.ts
index 260ee57..fa9dfd5 100644
--- a/polygerrit-ui/app/workers/service-worker-class.ts
+++ b/polygerrit-ui/app/workers/service-worker-class.ts
@@ -5,7 +5,7 @@
  */
 import {ParsedChangeInfo} from '../types/types';
 import {getReason} from '../utils/attention-set-util';
-import {readResponsePayload} from '../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {readJSONResponsePayload} from '../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {filterAttentionChangesAfter} from '../utils/service-worker-util';
 import {AccountDetailInfo} from '../api/rest-api';
 import {
@@ -177,9 +177,19 @@
     const response = await fetch(
       '/changes/?O=1000081&S=0&n=25&q=attention%3Aself'
     );
-    const payload = await readResponsePayload(response);
-    const changes = payload.parsed as unknown as ParsedChangeInfo[] | undefined;
-    return changes ?? [];
+
+    try {
+      // Throws an error if response payload is not prefixed JSON.
+      return (await readJSONResponsePayload(response))
+        .parsed as unknown as ParsedChangeInfo[];
+    } catch (err) {
+      if (err instanceof Error) {
+        console.warn(
+          `Request for latest attention set changes failed. Error: ${err.message}`
+        );
+      }
+      return [];
+    }
   }
 
   /**
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 9cca523..dcc7479 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -2,17 +2,17 @@
 # yarn lockfile v1
 
 
-"@lit-labs/ssr-dom-shim@^1.1.2-pre.0":
+"@lit-labs/ssr-dom-shim@^1.1.2":
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz#d693d972974a354034454ec1317eb6afd0b00312"
   integrity sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==
 
 "@lit/reactive-element@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.0.tgz#da14a256ac5533873b935840f306d572bac4a2ab"
-  integrity sha512-wn+2+uDcs62ROBmVAwssO4x5xue/uKD3MGGZOXL2sMxReTRIT0JXKyMXeu7gh0aJ4IJNEIG/3aOnUaQvM7BMzQ==
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.2.tgz#779ae9d265407daaf7737cb892df5ec2a86e22a0"
+  integrity sha512-SVOwLAWUQg3Ji1egtOt1UiFe4zdDpnWHyc5qctSceJ5XIu0Uc76YmGpIjZgx9YJ0XtdW0Jm507sDvjOu+HnB8w==
   dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.1.2-pre.0"
+    "@lit-labs/ssr-dom-shim" "^1.1.2"
 
 "@mapbox/node-pre-gyp@^1.0.0":
   version "1.0.11"
@@ -430,20 +430,20 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
-"@types/resemblejs@^4.1.0":
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/@types/resemblejs/-/resemblejs-4.1.0.tgz#1c150e0de4117b29f9d5d5231489edc7cef8263e"
-  integrity sha512-+MIkKy/UngDfhTnvn2yK/KSzlbtLeB5BU73qqZrzIF24+e2r8enJ4cW3UbtkstByYSDV8pbheGAqg7zT8ZZ2pA==
+"@types/resemblejs@^4.1.3":
+  version "4.1.3"
+  resolved "https://registry.yarnpkg.com/@types/resemblejs/-/resemblejs-4.1.3.tgz#46d16888952e377b9143484c206b63f6da56e91e"
+  integrity sha512-p0NA5aACdWCK+I4NJbwUvFoixwYxvfLu+UqaiZt/J3+3PJavMYOxRrdbeXbbiKiMGdKdDFjoxlFWkkaMU7SDxA==
 
-"@types/resize-observer-browser@^0.1.7":
-  version "0.1.8"
-  resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.8.tgz#db41a93f9f37dad8e6f0c7bd2f5bbc042bf714d1"
-  integrity sha512-OpjAd26fD1G2OWlYzkrapJ12n+kyi0znYgE2AHfNccHY/am3kG+lfJ5brfcZ7+1CIybkPWGKrW+Wm97kbcOQaQ==
+"@types/resize-observer-browser@^0.1.11":
+  version "0.1.11"
+  resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.11.tgz#d3c98d788489d8376b7beac23863b1eebdd3c13c"
+  integrity sha512-cNw5iH8JkMkb3QkCoe7DaZiawbDQEUX8t7iuQaRTyLOyQCR2h+ibBD4GJt7p5yhUHrlOeL7ZtbxNHeipqNsBzQ==
 
 "@types/trusted-types@^2.0.2":
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.4.tgz#2b38784cd16957d3782e8e2b31c03bc1d13b4d65"
-  integrity sha512-IDaobHimLQhjwsQ/NMwRVfa/yL7L/wriQPMhw1ZJall0KX6E1oxk29XMDeilW5qTIg5aoiqf5Udy8U/51aNoQQ==
+  version "2.0.7"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
+  integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
 
 "@webcomponents/shadycss@^1.11.2", "@webcomponents/shadycss@^1.9.1":
   version "1.11.2"
@@ -610,10 +610,10 @@
   resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
   integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
 
-"highlight.js@^11.5.0 || ^10.4.1", highlight.js@^11.8.0:
-  version "11.8.0"
-  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.8.0.tgz#966518ea83257bae2e7c9a48596231856555bb65"
-  integrity sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==
+"highlight.js@^11.5.0 || ^10.4.1", highlight.js@^11.9.0:
+  version "11.9.0"
+  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.9.0.tgz#04ab9ee43b52a41a047432c8103e2158a1b8b5b0"
+  integrity sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==
 
 "highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates":
   version "0.0.1"
@@ -659,29 +659,29 @@
   integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
 
 lit-element@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.0.0.tgz#8343891bc9159a5fcb7f534914b37f2c0161e036"
-  integrity sha512-N6+f7XgusURHl69DUZU6sTBGlIN+9Ixfs3ykkNDfgfTkDYGGOWwHAYBhDqVswnFGyWgQYR2KiSpu4J76Kccs/A==
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.0.2.tgz#1a519896d5ab7c7be7a8729f400499e38779c093"
+  integrity sha512-/W6WQZUa5VEXwC7H9tbtDMdSs9aWil3Ou8hU6z2cOKWbsm/tXPAcsoaHVEtrDo0zcOIE5GF6QgU55tlGL2Nihg==
   dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.1.2-pre.0"
+    "@lit-labs/ssr-dom-shim" "^1.1.2"
     "@lit/reactive-element" "^2.0.0"
-    lit-html "^3.0.0"
+    lit-html "^3.1.0"
 
-lit-html@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.0.0.tgz#77d6776ee488642c74c5575315ef81aa09d24ea9"
-  integrity sha512-DNJIE8dNY0dQF2Gs0sdMNUppMQT2/CvV4OVnSdg7BXAsGqkVwsE5bqQ04POfkYH5dBIuGnJYdFz5fYYyNnOxiA==
+lit-html@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.1.0.tgz#a7b93dd682073f2e2029656f4e9cd91e8034c196"
+  integrity sha512-FwAjq3iNsaO6SOZXEIpeROlJLUlrbyMkn4iuv4f4u1H40Jw8wkeR/OUXZUHUoiYabGk8Y4Y0F/rgq+R4MrOLmA==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
-lit@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-3.0.0.tgz#204bd65935892a73670471e893ee8ca55d2f9a3b"
-  integrity sha512-nQ0teRzU1Kdj++VdmttS2WvIen8M79wChJ6guRKIIym2M3Ansg3Adj9O6yuQh2IpjxiUXlNuS81WKlQ4iL3BmA==
+lit@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-3.1.0.tgz#76429b85dc1f5169fed499a0f7e89e2e619010c9"
+  integrity sha512-rzo/hmUqX8zmOdamDAeydfjsGXbbdtAFqMhmocnh2j9aDYqbu0fjXygjCa0T99Od9VQ/2itwaGrjZz/ZELVl7w==
   dependencies:
     "@lit/reactive-element" "^2.0.0"
     lit-element "^4.0.0"
-    lit-html "^3.0.0"
+    lit-html "^3.1.0"
 
 lru-cache@^6.0.0:
   version "6.0.0"
@@ -927,10 +927,10 @@
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
 
-web-vitals@^3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-3.4.0.tgz#45ed33a3a2e029dc38d36547eb5d71d1c7e2552d"
-  integrity sha512-n9fZ5/bG1oeDkyxLWyep0eahrNcPDF6bFqoyispt7xkW0xhDzpUBTgyDKqWDi1twT0MgH4HvvqzpUyh0ZxZV4A==
+web-vitals@^3.5.1:
+  version "3.5.1"
+  resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-3.5.1.tgz#af7a9dc60708b81007922ab55a23d963676ba30a"
+  integrity sha512-xQ9lvIpfLxUj0eSmT79ZjRoU5wIRfIr7pNukL7ZE4EcWZSmfZQqOlhuAGfkVa3EFmzPHZhWhXfm2i5ys+THVPg==
 
 webidl-conversions@^3.0.0:
   version "3.0.1"
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index 6e8259c..6f53c64 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -18,46 +18,46 @@
     "@jridgewell/gen-mapping" "^0.3.0"
     "@jridgewell/trace-mapping" "^0.3.9"
 
-"@babel/code-frame@^7.12.11", "@babel/code-frame@^7.22.13":
-  version "7.22.13"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
-  integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
+"@babel/code-frame@^7.12.11", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.23.5":
+  version "7.23.5"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244"
+  integrity sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==
   dependencies:
-    "@babel/highlight" "^7.22.13"
+    "@babel/highlight" "^7.23.4"
     chalk "^2.4.2"
 
-"@babel/compat-data@^7.22.20", "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.22.9":
-  version "7.22.20"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.22.20.tgz#8df6e96661209623f1975d66c35ffca66f3306d0"
-  integrity sha512-BQYjKbpXjoXwFW5jGqiizJQQT/aC7pFm9Ok1OWssonuguICi264lbgMzRp2ZMmRSlfkX6DsWDDcsrctK8Rwfiw==
+"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.23.3", "@babel/compat-data@^7.23.5":
+  version "7.23.5"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.23.5.tgz#ffb878728bb6bdcb6f4510aa51b1be9afb8cfd98"
+  integrity sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==
 
 "@babel/core@^7.11.1":
-  version "7.22.20"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.22.20.tgz#e3d0eed84c049e2a2ae0a64d27b6a37edec385b7"
-  integrity sha512-Y6jd1ahLubuYweD/zJH+vvOY141v4f9igNQAQ+MBgq9JlHS2iTsZKn1aMsb3vGccZsXI16VzTBw52Xx0DWmtnA==
+  version "7.23.7"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.23.7.tgz#4d8016e06a14b5f92530a13ed0561730b5c6483f"
+  integrity sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==
   dependencies:
     "@ampproject/remapping" "^2.2.0"
-    "@babel/code-frame" "^7.22.13"
-    "@babel/generator" "^7.22.15"
-    "@babel/helper-compilation-targets" "^7.22.15"
-    "@babel/helper-module-transforms" "^7.22.20"
-    "@babel/helpers" "^7.22.15"
-    "@babel/parser" "^7.22.16"
+    "@babel/code-frame" "^7.23.5"
+    "@babel/generator" "^7.23.6"
+    "@babel/helper-compilation-targets" "^7.23.6"
+    "@babel/helper-module-transforms" "^7.23.3"
+    "@babel/helpers" "^7.23.7"
+    "@babel/parser" "^7.23.6"
     "@babel/template" "^7.22.15"
-    "@babel/traverse" "^7.22.20"
-    "@babel/types" "^7.22.19"
-    convert-source-map "^1.7.0"
+    "@babel/traverse" "^7.23.7"
+    "@babel/types" "^7.23.6"
+    convert-source-map "^2.0.0"
     debug "^4.1.0"
     gensync "^1.0.0-beta.2"
     json5 "^2.2.3"
     semver "^6.3.1"
 
-"@babel/generator@^7.22.15", "@babel/generator@^7.4.0":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.22.15.tgz#1564189c7ec94cb8f77b5e8a90c4d200d21b2339"
-  integrity sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==
+"@babel/generator@^7.23.6", "@babel/generator@^7.4.0":
+  version "7.23.6"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.6.tgz#9e1fca4811c77a10580d17d26b57b036133f3c2e"
+  integrity sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==
   dependencies:
-    "@babel/types" "^7.22.15"
+    "@babel/types" "^7.23.6"
     "@jridgewell/gen-mapping" "^0.3.2"
     "@jridgewell/trace-mapping" "^0.3.17"
     jsesc "^2.5.1"
@@ -69,40 +69,40 @@
   dependencies:
     "@babel/types" "^7.22.5"
 
-"@babel/helper-builder-binary-assignment-operator-visitor@^7.22.5":
+"@babel/helper-builder-binary-assignment-operator-visitor@^7.22.15":
   version "7.22.15"
   resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz#5426b109cf3ad47b91120f8328d8ab1be8b0b956"
   integrity sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==
   dependencies:
     "@babel/types" "^7.22.15"
 
-"@babel/helper-compilation-targets@^7.22.15", "@babel/helper-compilation-targets@^7.22.5", "@babel/helper-compilation-targets@^7.22.6":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz#0698fc44551a26cf29f18d4662d5bf545a6cfc52"
-  integrity sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==
+"@babel/helper-compilation-targets@^7.22.15", "@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.23.6":
+  version "7.23.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz#4d79069b16cbcf1461289eccfbbd81501ae39991"
+  integrity sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==
   dependencies:
-    "@babel/compat-data" "^7.22.9"
-    "@babel/helper-validator-option" "^7.22.15"
-    browserslist "^4.21.9"
+    "@babel/compat-data" "^7.23.5"
+    "@babel/helper-validator-option" "^7.23.5"
+    browserslist "^4.22.2"
     lru-cache "^5.1.1"
     semver "^6.3.1"
 
-"@babel/helper-create-class-features-plugin@^7.22.11", "@babel/helper-create-class-features-plugin@^7.22.5":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.15.tgz#97a61b385e57fe458496fad19f8e63b63c867de4"
-  integrity sha512-jKkwA59IXcvSaiK2UN45kKwSC9o+KuoXsBDvHvU/7BecYIp8GQ2UwrVvFgJASUT+hBnwJx6MhvMCuMzwZZ7jlg==
+"@babel/helper-create-class-features-plugin@^7.22.15":
+  version "7.23.7"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.7.tgz#b2e6826e0e20d337143655198b79d58fdc9bd43d"
+  integrity sha512-xCoqR/8+BoNnXOY7RVSgv6X+o7pmT5q1d+gGcRlXYkI+9B31glE4jeejhKVpA04O1AtzOt7OSQ6VYKP5FcRl9g==
   dependencies:
     "@babel/helper-annotate-as-pure" "^7.22.5"
-    "@babel/helper-environment-visitor" "^7.22.5"
-    "@babel/helper-function-name" "^7.22.5"
-    "@babel/helper-member-expression-to-functions" "^7.22.15"
+    "@babel/helper-environment-visitor" "^7.22.20"
+    "@babel/helper-function-name" "^7.23.0"
+    "@babel/helper-member-expression-to-functions" "^7.23.0"
     "@babel/helper-optimise-call-expression" "^7.22.5"
-    "@babel/helper-replace-supers" "^7.22.9"
+    "@babel/helper-replace-supers" "^7.22.20"
     "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5"
     "@babel/helper-split-export-declaration" "^7.22.6"
     semver "^6.3.1"
 
-"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.22.5":
+"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.22.15", "@babel/helper-create-regexp-features-plugin@^7.22.5":
   version "7.22.15"
   resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz#5ee90093914ea09639b01c711db0d6775e558be1"
   integrity sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==
@@ -111,10 +111,10 @@
     regexpu-core "^5.3.1"
     semver "^6.3.1"
 
-"@babel/helper-define-polyfill-provider@^0.4.2":
-  version "0.4.2"
-  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.2.tgz#82c825cadeeeee7aad237618ebbe8fa1710015d7"
-  integrity sha512-k0qnnOqHn5dK9pZpfD5XXZ9SojAITdCKRn2Lp6rnDGzIbaP0rHyMPk/4wsSxVBVz4RfN0q6VpXWP2pDGIoQ7hw==
+"@babel/helper-define-polyfill-provider@^0.4.4":
+  version "0.4.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.4.tgz#64df615451cb30e94b59a9696022cffac9a10088"
+  integrity sha512-QcJMILQCu2jm5TFPGA3lCpJJTeEP+mqeXooG/NZbg/h5FTFi6V0+99ahlRsW8/kRLyb24LZVCCiclDedhLKcBA==
   dependencies:
     "@babel/helper-compilation-targets" "^7.22.6"
     "@babel/helper-plugin-utils" "^7.22.5"
@@ -122,18 +122,18 @@
     lodash.debounce "^4.0.8"
     resolve "^1.14.2"
 
-"@babel/helper-environment-visitor@^7.22.20", "@babel/helper-environment-visitor@^7.22.5":
+"@babel/helper-environment-visitor@^7.22.20":
   version "7.22.20"
   resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167"
   integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==
 
-"@babel/helper-function-name@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz#ede300828905bb15e582c037162f99d5183af1be"
-  integrity sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==
+"@babel/helper-function-name@^7.22.5", "@babel/helper-function-name@^7.23.0":
+  version "7.23.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759"
+  integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==
   dependencies:
-    "@babel/template" "^7.22.5"
-    "@babel/types" "^7.22.5"
+    "@babel/template" "^7.22.15"
+    "@babel/types" "^7.23.0"
 
 "@babel/helper-hoist-variables@^7.22.5":
   version "7.22.5"
@@ -142,24 +142,24 @@
   dependencies:
     "@babel/types" "^7.22.5"
 
-"@babel/helper-member-expression-to-functions@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.15.tgz#b95a144896f6d491ca7863576f820f3628818621"
-  integrity sha512-qLNsZbgrNh0fDQBCPocSL8guki1hcPvltGDv/NxvUoABwFq7GkKSu1nRXeJkVZc+wJvne2E0RKQz+2SQrz6eAA==
+"@babel/helper-member-expression-to-functions@^7.22.15", "@babel/helper-member-expression-to-functions@^7.23.0":
+  version "7.23.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz#9263e88cc5e41d39ec18c9a3e0eced59a3e7d366"
+  integrity sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==
   dependencies:
-    "@babel/types" "^7.22.15"
+    "@babel/types" "^7.23.0"
 
-"@babel/helper-module-imports@^7.22.15", "@babel/helper-module-imports@^7.22.5":
+"@babel/helper-module-imports@^7.22.15":
   version "7.22.15"
   resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0"
   integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==
   dependencies:
     "@babel/types" "^7.22.15"
 
-"@babel/helper-module-transforms@^7.22.15", "@babel/helper-module-transforms@^7.22.20", "@babel/helper-module-transforms@^7.22.5", "@babel/helper-module-transforms@^7.22.9":
-  version "7.22.20"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.22.20.tgz#da9edc14794babbe7386df438f3768067132f59e"
-  integrity sha512-dLT7JVWIUUxKOs1UnJUBR3S70YK+pKX6AbJgB2vMIvEkZkrfJDbYDJesnPshtKV4LhDOR3Oc5YULeDizRek+5A==
+"@babel/helper-module-transforms@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz#d7d12c3c5d30af5b3c0fcab2a6d5217773e2d0f1"
+  integrity sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==
   dependencies:
     "@babel/helper-environment-visitor" "^7.22.20"
     "@babel/helper-module-imports" "^7.22.15"
@@ -179,7 +179,7 @@
   resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz#dd7ee3735e8a313b9f7b05a773d892e88e6d7295"
   integrity sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==
 
-"@babel/helper-remap-async-to-generator@^7.22.5", "@babel/helper-remap-async-to-generator@^7.22.9":
+"@babel/helper-remap-async-to-generator@^7.22.20":
   version "7.22.20"
   resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz#7b68e1cb4fa964d2996fd063723fb48eca8498e0"
   integrity sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==
@@ -188,7 +188,7 @@
     "@babel/helper-environment-visitor" "^7.22.20"
     "@babel/helper-wrap-function" "^7.22.20"
 
-"@babel/helper-replace-supers@^7.22.5", "@babel/helper-replace-supers@^7.22.9":
+"@babel/helper-replace-supers@^7.22.20":
   version "7.22.20"
   resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz#e37d367123ca98fe455a9887734ed2e16eb7a793"
   integrity sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==
@@ -218,20 +218,20 @@
   dependencies:
     "@babel/types" "^7.22.5"
 
-"@babel/helper-string-parser@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f"
-  integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==
+"@babel/helper-string-parser@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83"
+  integrity sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==
 
-"@babel/helper-validator-identifier@^7.22.19", "@babel/helper-validator-identifier@^7.22.20", "@babel/helper-validator-identifier@^7.22.5":
+"@babel/helper-validator-identifier@^7.22.20":
   version "7.22.20"
   resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
   integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
 
-"@babel/helper-validator-option@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz#694c30dfa1d09a6534cdfcafbe56789d36aba040"
-  integrity sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==
+"@babel/helper-validator-option@^7.23.5":
+  version "7.23.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz#907a3fbd4523426285365d1206c423c4c5520307"
+  integrity sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==
 
 "@babel/helper-wrap-function@^7.22.20":
   version "7.22.20"
@@ -242,44 +242,52 @@
     "@babel/template" "^7.22.15"
     "@babel/types" "^7.22.19"
 
-"@babel/helpers@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.22.15.tgz#f09c3df31e86e3ea0b7ff7556d85cdebd47ea6f1"
-  integrity sha512-7pAjK0aSdxOwR+CcYAqgWOGy5dcfvzsTIfFTb2odQqW47MDfv14UaJDY6eng8ylM2EaeKXdxaSWESbkmaQHTmw==
+"@babel/helpers@^7.23.7":
+  version "7.23.7"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.23.7.tgz#eb543c36f81da2873e47b76ee032343ac83bba60"
+  integrity sha512-6AMnjCoC8wjqBzDHkuqpa7jAKwvMo4dC+lr/TFBz+ucfulO1XMpDnwWPGBNwClOKZ8h6xn5N81W/R5OrcKtCbQ==
   dependencies:
     "@babel/template" "^7.22.15"
-    "@babel/traverse" "^7.22.15"
-    "@babel/types" "^7.22.15"
+    "@babel/traverse" "^7.23.7"
+    "@babel/types" "^7.23.6"
 
-"@babel/highlight@^7.22.13":
-  version "7.22.20"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
-  integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
+"@babel/highlight@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz#edaadf4d8232e1a961432db785091207ead0621b"
+  integrity sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==
   dependencies:
     "@babel/helper-validator-identifier" "^7.22.20"
     chalk "^2.4.2"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.22.15", "@babel/parser@^7.22.16", "@babel/parser@^7.4.3":
-  version "7.22.16"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.16.tgz#180aead7f247305cce6551bea2720934e2fa2c95"
-  integrity sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==
+"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.22.15", "@babel/parser@^7.23.6", "@babel/parser@^7.4.3":
+  version "7.23.6"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.6.tgz#ba1c9e512bda72a47e285ae42aff9d2a635a9e3b"
+  integrity sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==
 
-"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.15.tgz#02dc8a03f613ed5fdc29fb2f728397c78146c962"
-  integrity sha512-FB9iYlz7rURmRJyXRKEnalYPPdn87H5no108cyuQQyMwlpJ2SJtpIUBI27kdTin956pz+LPypkPVPUTlxOmrsg==
+"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.23.3.tgz#5cd1c87ba9380d0afb78469292c954fee5d2411a"
+  integrity sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.15.tgz#2aeb91d337d4e1a1e7ce85b76a37f5301781200f"
-  integrity sha512-Hyph9LseGvAeeXzikV88bczhsrLrIZqDPxO+sSmAunMPaGrBGhfMWzCPYTtiW9t+HzSE2wtV8e5cc5P6r1xMDQ==
+"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.23.3.tgz#f6652bb16b94f8f9c20c50941e16e9756898dc5d"
+  integrity sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5"
-    "@babel/plugin-transform-optional-chaining" "^7.22.15"
+    "@babel/plugin-transform-optional-chaining" "^7.23.3"
+
+"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@^7.23.7":
+  version "7.23.7"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.23.7.tgz#516462a95d10a9618f197d39ad291a9b47ae1d7b"
+  integrity sha512-LlRT7HgaifEpQA1ZgLVOIJZZFVPWN5iReq/7/JixwBtwcoeVGDBD53ZV28rrsLYOZs1Y/EHhA8N/Z6aazHR8cw==
+  dependencies:
+    "@babel/helper-environment-visitor" "^7.22.20"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
 "@babel/plugin-proposal-dynamic-import@^7.10.4":
   version "7.18.6"
@@ -346,17 +354,17 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.8.3"
 
-"@babel/plugin-syntax-import-assertions@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.22.5.tgz#07d252e2aa0bc6125567f742cd58619cb14dce98"
-  integrity sha512-rdV97N7KqsRzeNGoWUOK6yUsWarLjE5Su/Snk9IYPU9CwkWHs4t+rTGOvffTR8XGkJMTAdLfO0xVnXm8wugIJg==
+"@babel/plugin-syntax-import-assertions@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.23.3.tgz#9c05a7f592982aff1a2768260ad84bcd3f0c77fc"
+  integrity sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-syntax-import-attributes@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.22.5.tgz#ab840248d834410b829f569f5262b9e517555ecb"
-  integrity sha512-KwvoWDeNKPETmozyFE0P2rOLqh39EoQHNjqizrI5B8Vt0ZNS7M56s7dAiAqbYfiAYOuIzIh96z3iR2ktgu3tEg==
+"@babel/plugin-syntax-import-attributes@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.23.3.tgz#992aee922cf04512461d7dae3ff6951b90a2dc06"
+  integrity sha512-pawnE0P9g10xgoP7yKr6CK63K2FMsTE+FZidZO/1PwRdzmAPVs+HS1mAURUsgaoxammTJvULUdIkEK0gOcU2tA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
@@ -438,211 +446,212 @@
     "@babel/helper-create-regexp-features-plugin" "^7.18.6"
     "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-arrow-functions@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.22.5.tgz#e5ba566d0c58a5b2ba2a8b795450641950b71958"
-  integrity sha512-26lTNXoVRdAnsaDXPpvCNUq+OVWEVC6bx7Vvz9rC53F2bagUWW4u4ii2+h8Fejfh7RYqPxn+libeFBBck9muEw==
+"@babel/plugin-transform-arrow-functions@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.23.3.tgz#94c6dcfd731af90f27a79509f9ab7fb2120fc38b"
+  integrity sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-async-generator-functions@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.15.tgz#3b153af4a6b779f340d5b80d3f634f55820aefa3"
-  integrity sha512-jBm1Es25Y+tVoTi5rfd5t1KLmL8ogLKpXszboWOTTtGFGz2RKnQe2yn7HbZ+kb/B8N0FVSGQo874NSlOU1T4+w==
+"@babel/plugin-transform-async-generator-functions@^7.23.7":
+  version "7.23.7"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.7.tgz#3aa0b4f2fa3788b5226ef9346cf6d16ec61f99cd"
+  integrity sha512-PdxEpL71bJp1byMG0va5gwQcXHxuEYC/BgI/e88mGTtohbZN28O5Yit0Plkkm/dBzCF/BxmbNcses1RH1T+urA==
   dependencies:
-    "@babel/helper-environment-visitor" "^7.22.5"
+    "@babel/helper-environment-visitor" "^7.22.20"
     "@babel/helper-plugin-utils" "^7.22.5"
-    "@babel/helper-remap-async-to-generator" "^7.22.9"
+    "@babel/helper-remap-async-to-generator" "^7.22.20"
     "@babel/plugin-syntax-async-generators" "^7.8.4"
 
-"@babel/plugin-transform-async-to-generator@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.22.5.tgz#c7a85f44e46f8952f6d27fe57c2ed3cc084c3775"
-  integrity sha512-b1A8D8ZzE/VhNDoV1MSJTnpKkCG5bJo+19R4o4oy03zM7ws8yEMK755j61Dc3EyvdysbqH5BOOTquJ7ZX9C6vQ==
+"@babel/plugin-transform-async-to-generator@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.23.3.tgz#d1f513c7a8a506d43f47df2bf25f9254b0b051fa"
+  integrity sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw==
   dependencies:
-    "@babel/helper-module-imports" "^7.22.5"
+    "@babel/helper-module-imports" "^7.22.15"
     "@babel/helper-plugin-utils" "^7.22.5"
-    "@babel/helper-remap-async-to-generator" "^7.22.5"
+    "@babel/helper-remap-async-to-generator" "^7.22.20"
 
-"@babel/plugin-transform-block-scoped-functions@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.22.5.tgz#27978075bfaeb9fa586d3cb63a3d30c1de580024"
-  integrity sha512-tdXZ2UdknEKQWKJP1KMNmuF5Lx3MymtMN/pvA+p/VEkhK8jVcQ1fzSy8KM9qRYhAf2/lV33hoMPKI/xaI9sADA==
+"@babel/plugin-transform-block-scoped-functions@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.23.3.tgz#fe1177d715fb569663095e04f3598525d98e8c77"
+  integrity sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-block-scoping@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.15.tgz#494eb82b87b5f8b1d8f6f28ea74078ec0a10a841"
-  integrity sha512-G1czpdJBZCtngoK1sJgloLiOHUnkb/bLZwqVZD8kXmq0ZnVfTTWUcs9OWtp0mBtYJ+4LQY1fllqBkOIPhXmFmw==
+"@babel/plugin-transform-block-scoping@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz#b2d38589531c6c80fbe25e6b58e763622d2d3cf5"
+  integrity sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-class-properties@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.5.tgz#97a56e31ad8c9dc06a0b3710ce7803d5a48cca77"
-  integrity sha512-nDkQ0NfkOhPTq8YCLiWNxp1+f9fCobEjCb0n8WdbNUBc4IB5V7P1QnX9IjpSoquKrXF5SKojHleVNs2vGeHCHQ==
+"@babel/plugin-transform-class-properties@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.23.3.tgz#35c377db11ca92a785a718b6aa4e3ed1eb65dc48"
+  integrity sha512-uM+AN8yCIjDPccsKGlw271xjJtGii+xQIF/uMPS8H15L12jZTsLfF4o5vNO7d/oUguOyfdikHGc/yi9ge4SGIg==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.22.5"
+    "@babel/helper-create-class-features-plugin" "^7.22.15"
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-class-static-block@^7.22.11":
-  version "7.22.11"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.11.tgz#dc8cc6e498f55692ac6b4b89e56d87cec766c974"
-  integrity sha512-GMM8gGmqI7guS/llMFk1bJDkKfn3v3C4KHK9Yg1ey5qcHcOlKb0QvcMrgzvxo+T03/4szNh5lghY+fEC98Kq9g==
+"@babel/plugin-transform-class-static-block@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.4.tgz#2a202c8787a8964dd11dfcedf994d36bfc844ab5"
+  integrity sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.22.11"
+    "@babel/helper-create-class-features-plugin" "^7.22.15"
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/plugin-syntax-class-static-block" "^7.14.5"
 
-"@babel/plugin-transform-classes@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.15.tgz#aaf4753aee262a232bbc95451b4bdf9599c65a0b"
-  integrity sha512-VbbC3PGjBdE0wAWDdHM9G8Gm977pnYI0XpqMd6LrKISj8/DJXEsWqgRuTYaNE9Bv0JGhTZUzHDlMk18IpOuoqw==
+"@babel/plugin-transform-classes@^7.23.5":
+  version "7.23.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.5.tgz#e7a75f815e0c534cc4c9a39c56636c84fc0d64f2"
+  integrity sha512-jvOTR4nicqYC9yzOHIhXG5emiFEOpappSJAl73SDSEDcybD+Puuze8Tnpb9p9qEyYup24tq891gkaygIFvWDqg==
   dependencies:
     "@babel/helper-annotate-as-pure" "^7.22.5"
     "@babel/helper-compilation-targets" "^7.22.15"
-    "@babel/helper-environment-visitor" "^7.22.5"
-    "@babel/helper-function-name" "^7.22.5"
+    "@babel/helper-environment-visitor" "^7.22.20"
+    "@babel/helper-function-name" "^7.23.0"
     "@babel/helper-optimise-call-expression" "^7.22.5"
     "@babel/helper-plugin-utils" "^7.22.5"
-    "@babel/helper-replace-supers" "^7.22.9"
+    "@babel/helper-replace-supers" "^7.22.20"
     "@babel/helper-split-export-declaration" "^7.22.6"
     globals "^11.1.0"
 
-"@babel/plugin-transform-computed-properties@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.22.5.tgz#cd1e994bf9f316bd1c2dafcd02063ec261bb3869"
-  integrity sha512-4GHWBgRf0krxPX+AaPtgBAlTgTeZmqDynokHOX7aqqAB4tHs3U2Y02zH6ETFdLZGcg9UQSD1WCmkVrE9ErHeOg==
+"@babel/plugin-transform-computed-properties@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz#652e69561fcc9d2b50ba4f7ac7f60dcf65e86474"
+  integrity sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
-    "@babel/template" "^7.22.5"
+    "@babel/template" "^7.22.15"
 
-"@babel/plugin-transform-destructuring@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.15.tgz#e7404ea5bb3387073b9754be654eecb578324694"
-  integrity sha512-HzG8sFl1ZVGTme74Nw+X01XsUTqERVQ6/RLHo3XjGRzm7XD6QTtfS3NJotVgCGy8BzkDqRjRBD8dAyJn5TuvSQ==
+"@babel/plugin-transform-destructuring@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.3.tgz#8c9ee68228b12ae3dff986e56ed1ba4f3c446311"
+  integrity sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-dotall-regex@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.22.5.tgz#dbb4f0e45766eb544e193fb00e65a1dd3b2a4165"
-  integrity sha512-5/Yk9QxCQCl+sOIB1WelKnVRxTJDSAIxtJLL2/pqL14ZVlbH0fUQUZa/T5/UnQtBNgghR7mfB8ERBKyKPCi7Vw==
+"@babel/plugin-transform-dotall-regex@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.23.3.tgz#3f7af6054882ede89c378d0cf889b854a993da50"
+  integrity sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.22.5"
+    "@babel/helper-create-regexp-features-plugin" "^7.22.15"
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-duplicate-keys@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.22.5.tgz#b6e6428d9416f5f0bba19c70d1e6e7e0b88ab285"
-  integrity sha512-dEnYD+9BBgld5VBXHnF/DbYGp3fqGMsyxKbtD1mDyIA7AkTSpKXFhCVuj/oQVOoALfBs77DudA0BE4d5mcpmqw==
+"@babel/plugin-transform-duplicate-keys@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.23.3.tgz#664706ca0a5dfe8d066537f99032fc1dc8b720ce"
+  integrity sha512-RrqQ+BQmU3Oyav3J+7/myfvRCq7Tbz+kKLLshUmMwNlDHExbGL7ARhajvoBJEvc+fCguPPu887N+3RRXBVKZUA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-dynamic-import@^7.22.11":
-  version "7.22.11"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.11.tgz#2c7722d2a5c01839eaf31518c6ff96d408e447aa"
-  integrity sha512-g/21plo58sfteWjaO0ZNVb+uEOkJNjAaHhbejrnBmu011l/eNDScmkbjCC3l4FKb10ViaGU4aOkFznSu2zRHgA==
+"@babel/plugin-transform-dynamic-import@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.4.tgz#c7629e7254011ac3630d47d7f34ddd40ca535143"
+  integrity sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
 
-"@babel/plugin-transform-exponentiation-operator@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.22.5.tgz#402432ad544a1f9a480da865fda26be653e48f6a"
-  integrity sha512-vIpJFNM/FjZ4rh1myqIya9jXwrwwgFRHPjT3DkUA9ZLHuzox8jiXkOLvwm1H+PQIP3CqfC++WPKeuDi0Sjdj1g==
+"@babel/plugin-transform-exponentiation-operator@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.23.3.tgz#ea0d978f6b9232ba4722f3dbecdd18f450babd18"
+  integrity sha512-5fhCsl1odX96u7ILKHBj4/Y8vipoqwsJMh4csSA8qFfxrZDEA4Ssku2DyNvMJSmZNOEBT750LfFPbtrnTP90BQ==
   dependencies:
-    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.22.5"
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.22.15"
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-export-namespace-from@^7.22.11":
-  version "7.22.11"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.11.tgz#b3c84c8f19880b6c7440108f8929caf6056db26c"
-  integrity sha512-xa7aad7q7OiT8oNZ1mU7NrISjlSkVdMbNxn9IuLZyL9AJEhs1Apba3I+u5riX1dIkdptP5EKDG5XDPByWxtehw==
+"@babel/plugin-transform-export-namespace-from@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.4.tgz#084c7b25e9a5c8271e987a08cf85807b80283191"
+  integrity sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
 
-"@babel/plugin-transform-for-of@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.15.tgz#f64b4ccc3a4f131a996388fae7680b472b306b29"
-  integrity sha512-me6VGeHsx30+xh9fbDLLPi0J1HzmeIIyenoOQHuw2D4m2SAU3NrspX5XxJLBpqn5yrLzrlw2Iy3RA//Bx27iOA==
+"@babel/plugin-transform-for-of@^7.23.6":
+  version "7.23.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.23.6.tgz#81c37e24171b37b370ba6aaffa7ac86bcb46f94e"
+  integrity sha512-aYH4ytZ0qSuBbpfhuofbg/e96oQ7U2w1Aw/UQmKT+1l39uEhUPoFS3fHevDc1G0OvewyDudfMKY1OulczHzWIw==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5"
 
-"@babel/plugin-transform-function-name@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.22.5.tgz#935189af68b01898e0d6d99658db6b164205c143"
-  integrity sha512-UIzQNMS0p0HHiQm3oelztj+ECwFnj+ZRV4KnguvlsD2of1whUeM6o7wGNj6oLwcDoAXQ8gEqfgC24D+VdIcevg==
+"@babel/plugin-transform-function-name@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.23.3.tgz#8f424fcd862bf84cb9a1a6b42bc2f47ed630f8dc"
+  integrity sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw==
   dependencies:
-    "@babel/helper-compilation-targets" "^7.22.5"
-    "@babel/helper-function-name" "^7.22.5"
+    "@babel/helper-compilation-targets" "^7.22.15"
+    "@babel/helper-function-name" "^7.23.0"
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-json-strings@^7.22.11":
-  version "7.22.11"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.11.tgz#689a34e1eed1928a40954e37f74509f48af67835"
-  integrity sha512-CxT5tCqpA9/jXFlme9xIBCc5RPtdDq3JpkkhgHQqtDdiTnTI0jtZ0QzXhr5DILeYifDPp2wvY2ad+7+hLMW5Pw==
+"@babel/plugin-transform-json-strings@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.4.tgz#a871d9b6bd171976efad2e43e694c961ffa3714d"
+  integrity sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/plugin-syntax-json-strings" "^7.8.3"
 
-"@babel/plugin-transform-literals@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.22.5.tgz#e9341f4b5a167952576e23db8d435849b1dd7920"
-  integrity sha512-fTLj4D79M+mepcw3dgFBTIDYpbcB9Sm0bpm4ppXPaO+U+PKFFyV9MGRvS0gvGw62sd10kT5lRMKXAADb9pWy8g==
+"@babel/plugin-transform-literals@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.23.3.tgz#8214665f00506ead73de157eba233e7381f3beb4"
+  integrity sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-logical-assignment-operators@^7.22.11":
-  version "7.22.11"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.11.tgz#24c522a61688bde045b7d9bc3c2597a4d948fc9c"
-  integrity sha512-qQwRTP4+6xFCDV5k7gZBF3C31K34ut0tbEcTKxlX/0KXxm9GLcO14p570aWxFvVzx6QAfPgq7gaeIHXJC8LswQ==
+"@babel/plugin-transform-logical-assignment-operators@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.4.tgz#e599f82c51d55fac725f62ce55d3a0886279ecb5"
+  integrity sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"
 
-"@babel/plugin-transform-member-expression-literals@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.22.5.tgz#4fcc9050eded981a468347dd374539ed3e058def"
-  integrity sha512-RZEdkNtzzYCFl9SE9ATaUMTj2hqMb4StarOJLrZRbqqU4HSBE7UlBw9WBWQiDzrJZJdUWiMTVDI6Gv/8DPvfew==
+"@babel/plugin-transform-member-expression-literals@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.23.3.tgz#e37b3f0502289f477ac0e776b05a833d853cabcc"
+  integrity sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-modules-amd@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.22.5.tgz#4e045f55dcf98afd00f85691a68fc0780704f526"
-  integrity sha512-R+PTfLTcYEmb1+kK7FNkhQ1gP4KgjpSO6HfH9+f8/yfp2Nt3ggBjiVpRwmwTlfqZLafYKJACy36yDXlEmI9HjQ==
+"@babel/plugin-transform-modules-amd@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.3.tgz#e19b55436a1416829df0a1afc495deedfae17f7d"
+  integrity sha512-vJYQGxeKM4t8hYCKVBlZX/gtIY2I7mRGFNcm85sgXGMTBcoV3QdVtdpbcWEbzbfUIUZKwvgFT82mRvaQIebZzw==
   dependencies:
-    "@babel/helper-module-transforms" "^7.22.5"
+    "@babel/helper-module-transforms" "^7.23.3"
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-modules-commonjs@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.15.tgz#b11810117ed4ee7691b29bd29fd9f3f98276034f"
-  integrity sha512-jWL4eh90w0HQOTKP2MoXXUpVxilxsB2Vl4ji69rSjS3EcZ/v4sBmn+A3NpepuJzBhOaEBbR7udonlHHn5DWidg==
+"@babel/plugin-transform-modules-commonjs@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.3.tgz#661ae831b9577e52be57dd8356b734f9700b53b4"
+  integrity sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==
   dependencies:
-    "@babel/helper-module-transforms" "^7.22.15"
+    "@babel/helper-module-transforms" "^7.23.3"
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/helper-simple-access" "^7.22.5"
 
-"@babel/plugin-transform-modules-systemjs@^7.22.11":
-  version "7.22.11"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.11.tgz#3386be5875d316493b517207e8f1931d93154bb1"
-  integrity sha512-rIqHmHoMEOhI3VkVf5jQ15l539KrwhzqcBO6wdCNWPWc/JWt9ILNYNUssbRpeq0qWns8svuw8LnMNCvWBIJ8wA==
+"@babel/plugin-transform-modules-systemjs@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.3.tgz#fa7e62248931cb15b9404f8052581c302dd9de81"
+  integrity sha512-ZxyKGTkF9xT9YJuKQRo19ewf3pXpopuYQd8cDXqNzc3mUNbOME0RKMoZxviQk74hwzfQsEe66dE92MaZbdHKNQ==
   dependencies:
     "@babel/helper-hoist-variables" "^7.22.5"
-    "@babel/helper-module-transforms" "^7.22.9"
+    "@babel/helper-module-transforms" "^7.23.3"
     "@babel/helper-plugin-utils" "^7.22.5"
-    "@babel/helper-validator-identifier" "^7.22.5"
+    "@babel/helper-validator-identifier" "^7.22.20"
 
-"@babel/plugin-transform-modules-umd@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.22.5.tgz#4694ae40a87b1745e3775b6a7fe96400315d4f98"
-  integrity sha512-+S6kzefN/E1vkSsKx8kmQuqeQsvCKCd1fraCM7zXm4SFoggI099Tr4G8U81+5gtMdUeMQ4ipdQffbKLX0/7dBQ==
+"@babel/plugin-transform-modules-umd@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.23.3.tgz#5d4395fccd071dfefe6585a4411aa7d6b7d769e9"
+  integrity sha512-zHsy9iXX2nIsCBFPud3jKn1IRPWg3Ing1qOZgeKV39m1ZgIdpJqvlWVeiHBZC6ITRG0MfskhYe9cLgntfSFPIg==
   dependencies:
-    "@babel/helper-module-transforms" "^7.22.5"
+    "@babel/helper-module-transforms" "^7.23.3"
     "@babel/helper-plugin-utils" "^7.22.5"
 
 "@babel/plugin-transform-named-capturing-groups-regex@^7.22.5":
@@ -653,198 +662,199 @@
     "@babel/helper-create-regexp-features-plugin" "^7.22.5"
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-new-target@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.22.5.tgz#1b248acea54ce44ea06dfd37247ba089fcf9758d"
-  integrity sha512-AsF7K0Fx/cNKVyk3a+DW0JLo+Ua598/NxMRvxDnkpCIGFh43+h/v2xyhRUYf6oD8gE4QtL83C7zZVghMjHd+iw==
+"@babel/plugin-transform-new-target@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.23.3.tgz#5491bb78ed6ac87e990957cea367eab781c4d980"
+  integrity sha512-YJ3xKqtJMAT5/TIZnpAR3I+K+WaDowYbN3xyxI8zxx/Gsypwf9B9h0VB+1Nh6ACAAPRS5NSRje0uVv5i79HYGQ==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-nullish-coalescing-operator@^7.22.11":
-  version "7.22.11"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.11.tgz#debef6c8ba795f5ac67cd861a81b744c5d38d9fc"
-  integrity sha512-YZWOw4HxXrotb5xsjMJUDlLgcDXSfO9eCmdl1bgW4+/lAGdkjaEvOnQ4p5WKKdUgSzO39dgPl0pTnfxm0OAXcg==
+"@babel/plugin-transform-nullish-coalescing-operator@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.4.tgz#45556aad123fc6e52189ea749e33ce090637346e"
+  integrity sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
 
-"@babel/plugin-transform-numeric-separator@^7.22.11":
-  version "7.22.11"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.11.tgz#498d77dc45a6c6db74bb829c02a01c1d719cbfbd"
-  integrity sha512-3dzU4QGPsILdJbASKhF/V2TVP+gJya1PsueQCxIPCEcerqF21oEcrob4mzjsp2Py/1nLfF5m+xYNMDpmA8vffg==
+"@babel/plugin-transform-numeric-separator@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.4.tgz#03d08e3691e405804ecdd19dd278a40cca531f29"
+  integrity sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/plugin-syntax-numeric-separator" "^7.10.4"
 
-"@babel/plugin-transform-object-rest-spread@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.15.tgz#21a95db166be59b91cde48775310c0df6e1da56f"
-  integrity sha512-fEB+I1+gAmfAyxZcX1+ZUwLeAuuf8VIg67CTznZE0MqVFumWkh8xWtn58I4dxdVf080wn7gzWoF8vndOViJe9Q==
+"@babel/plugin-transform-object-rest-spread@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.4.tgz#2b9c2d26bf62710460bdc0d1730d4f1048361b83"
+  integrity sha512-9x9K1YyeQVw0iOXJlIzwm8ltobIIv7j2iLyP2jIhEbqPRQ7ScNgwQufU2I0Gq11VjyG4gI4yMXt2VFags+1N3g==
   dependencies:
-    "@babel/compat-data" "^7.22.9"
+    "@babel/compat-data" "^7.23.3"
     "@babel/helper-compilation-targets" "^7.22.15"
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
-    "@babel/plugin-transform-parameters" "^7.22.15"
+    "@babel/plugin-transform-parameters" "^7.23.3"
 
-"@babel/plugin-transform-object-super@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.22.5.tgz#794a8d2fcb5d0835af722173c1a9d704f44e218c"
-  integrity sha512-klXqyaT9trSjIUrcsYIfETAzmOEZL3cBYqOYLJxBHfMFFggmXOv+NYSX/Jbs9mzMVESw/WycLFPRx8ba/b2Ipw==
+"@babel/plugin-transform-object-super@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.23.3.tgz#81fdb636dcb306dd2e4e8fd80db5b2362ed2ebcd"
+  integrity sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
-    "@babel/helper-replace-supers" "^7.22.5"
+    "@babel/helper-replace-supers" "^7.22.20"
 
-"@babel/plugin-transform-optional-catch-binding@^7.22.11":
-  version "7.22.11"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.11.tgz#461cc4f578a127bb055527b3e77404cad38c08e0"
-  integrity sha512-rli0WxesXUeCJnMYhzAglEjLWVDF6ahb45HuprcmQuLidBJFWjNnOzssk2kuc6e33FlLaiZhG/kUIzUMWdBKaQ==
+"@babel/plugin-transform-optional-catch-binding@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.4.tgz#318066de6dacce7d92fa244ae475aa8d91778017"
+  integrity sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/plugin-syntax-optional-catch-binding" "^7.8.3"
 
-"@babel/plugin-transform-optional-chaining@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.15.tgz#d7a5996c2f7ca4ad2ad16dbb74444e5c4385b1ba"
-  integrity sha512-ngQ2tBhq5vvSJw2Q2Z9i7ealNkpDMU0rGWnHPKqRZO0tzZ5tlaoz4hDvhXioOoaE0X2vfNss1djwg0DXlfu30A==
+"@babel/plugin-transform-optional-chaining@^7.23.3", "@babel/plugin-transform-optional-chaining@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.4.tgz#6acf61203bdfc4de9d4e52e64490aeb3e52bd017"
+  integrity sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5"
     "@babel/plugin-syntax-optional-chaining" "^7.8.3"
 
-"@babel/plugin-transform-parameters@^7.22.15":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.15.tgz#719ca82a01d177af358df64a514d64c2e3edb114"
-  integrity sha512-hjk7qKIqhyzhhUvRT683TYQOFa/4cQKwQy7ALvTpODswN40MljzNDa0YldevS6tGbxwaEKVn502JmY0dP7qEtQ==
+"@babel/plugin-transform-parameters@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.23.3.tgz#83ef5d1baf4b1072fa6e54b2b0999a7b2527e2af"
+  integrity sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-private-methods@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.5.tgz#21c8af791f76674420a147ae62e9935d790f8722"
-  integrity sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA==
+"@babel/plugin-transform-private-methods@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.23.3.tgz#b2d7a3c97e278bfe59137a978d53b2c2e038c0e4"
+  integrity sha512-UzqRcRtWsDMTLrRWFvUBDwmw06tCQH9Rl1uAjfh6ijMSmGYQ+fpdB+cnqRC8EMh5tuuxSv0/TejGL+7vyj+50g==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.22.5"
+    "@babel/helper-create-class-features-plugin" "^7.22.15"
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-private-property-in-object@^7.22.11":
-  version "7.22.11"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.11.tgz#ad45c4fc440e9cb84c718ed0906d96cf40f9a4e1"
-  integrity sha512-sSCbqZDBKHetvjSwpyWzhuHkmW5RummxJBVbYLkGkaiTOWGxml7SXt0iWa03bzxFIx7wOj3g/ILRd0RcJKBeSQ==
+"@babel/plugin-transform-private-property-in-object@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.4.tgz#3ec711d05d6608fd173d9b8de39872d8dbf68bf5"
+  integrity sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A==
   dependencies:
     "@babel/helper-annotate-as-pure" "^7.22.5"
-    "@babel/helper-create-class-features-plugin" "^7.22.11"
+    "@babel/helper-create-class-features-plugin" "^7.22.15"
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/plugin-syntax-private-property-in-object" "^7.14.5"
 
-"@babel/plugin-transform-property-literals@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.22.5.tgz#b5ddabd73a4f7f26cd0e20f5db48290b88732766"
-  integrity sha512-TiOArgddK3mK/x1Qwf5hay2pxI6wCZnvQqrFSqbtg1GLl2JcNMitVH/YnqjP+M31pLUeTfzY1HAXFDnUBV30rQ==
+"@babel/plugin-transform-property-literals@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.23.3.tgz#54518f14ac4755d22b92162e4a852d308a560875"
+  integrity sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-regenerator@^7.22.10":
-  version "7.22.10"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.10.tgz#8ceef3bd7375c4db7652878b0241b2be5d0c3cca"
-  integrity sha512-F28b1mDt8KcT5bUyJc/U9nwzw6cV+UmTeRlXYIl2TNqMMJif0Jeey9/RQ3C4NOd2zp0/TRsDns9ttj2L523rsw==
+"@babel/plugin-transform-regenerator@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.23.3.tgz#141afd4a2057298602069fce7f2dc5173e6c561c"
+  integrity sha512-KP+75h0KghBMcVpuKisx3XTu9Ncut8Q8TuvGO4IhY+9D5DFEckQefOuIsB/gQ2tG71lCke4NMrtIPS8pOj18BQ==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
     regenerator-transform "^0.15.2"
 
-"@babel/plugin-transform-reserved-words@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.22.5.tgz#832cd35b81c287c4bcd09ce03e22199641f964fb"
-  integrity sha512-DTtGKFRQUDm8svigJzZHzb/2xatPc6TzNvAIJ5GqOKDsGFYgAskjRulbR/vGsPKq3OPqtexnz327qYpP57RFyA==
+"@babel/plugin-transform-reserved-words@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.23.3.tgz#4130dcee12bd3dd5705c587947eb715da12efac8"
+  integrity sha512-QnNTazY54YqgGxwIexMZva9gqbPa15t/x9VS+0fsEFWplwVpXYZivtgl43Z1vMpc1bdPP2PP8siFeVcnFvA3Cg==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-shorthand-properties@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.22.5.tgz#6e277654be82b5559fc4b9f58088507c24f0c624"
-  integrity sha512-vM4fq9IXHscXVKzDv5itkO1X52SmdFBFcMIBZ2FRn2nqVYqw6dBexUgMvAjHW+KXpPPViD/Yo3GrDEBaRC0QYA==
+"@babel/plugin-transform-shorthand-properties@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.23.3.tgz#97d82a39b0e0c24f8a981568a8ed851745f59210"
+  integrity sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-spread@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.22.5.tgz#6487fd29f229c95e284ba6c98d65eafb893fea6b"
-  integrity sha512-5ZzDQIGyvN4w8+dMmpohL6MBo+l2G7tfC/O2Dg7/hjpgeWvUx8FzfeOKxGog9IimPa4YekaQ9PlDqTLOljkcxg==
+"@babel/plugin-transform-spread@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.23.3.tgz#41d17aacb12bde55168403c6f2d6bdca563d362c"
+  integrity sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
     "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5"
 
-"@babel/plugin-transform-sticky-regex@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.22.5.tgz#295aba1595bfc8197abd02eae5fc288c0deb26aa"
-  integrity sha512-zf7LuNpHG0iEeiyCNwX4j3gDg1jgt1k3ZdXBKbZSoA3BbGQGvMiSvfbZRR3Dr3aeJe3ooWFZxOOG3IRStYp2Bw==
+"@babel/plugin-transform-sticky-regex@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.23.3.tgz#dec45588ab4a723cb579c609b294a3d1bd22ff04"
+  integrity sha512-HZOyN9g+rtvnOU3Yh7kSxXrKbzgrm5X4GncPY1QOquu7epga5MxKHVpYu2hvQnry/H+JjckSYRb93iNfsioAGg==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-template-literals@^7.22.5", "@babel/plugin-transform-template-literals@^7.8.3":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.22.5.tgz#8f38cf291e5f7a8e60e9f733193f0bcc10909bff"
-  integrity sha512-5ciOehRNf+EyUeewo8NkbQiUs4d6ZxiHo6BcBcnFlgiJfu16q0bQUw9Jvo0b0gBKFG1SMhDSjeKXSYuJLeFSMA==
+"@babel/plugin-transform-template-literals@^7.23.3", "@babel/plugin-transform-template-literals@^7.8.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.23.3.tgz#5f0f028eb14e50b5d0f76be57f90045757539d07"
+  integrity sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-typeof-symbol@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.22.5.tgz#5e2ba478da4b603af8673ff7c54f75a97b716b34"
-  integrity sha512-bYkI5lMzL4kPii4HHEEChkD0rkc+nvnlR6+o/qdqR6zrm0Sv/nodmyLhlq2DO0YKLUNd2VePmPRjJXSBh9OIdA==
+"@babel/plugin-transform-typeof-symbol@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.23.3.tgz#9dfab97acc87495c0c449014eb9c547d8966bca4"
+  integrity sha512-4t15ViVnaFdrPC74be1gXBSMzXk3B4Us9lP7uLRQHTFpV5Dvt33pn+2MyyNxmN3VTTm3oTrZVMUmuw3oBnQ2oQ==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-unicode-escapes@^7.22.10":
-  version "7.22.10"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.10.tgz#c723f380f40a2b2f57a62df24c9005834c8616d9"
-  integrity sha512-lRfaRKGZCBqDlRU3UIFovdp9c9mEvlylmpod0/OatICsSfuQ9YFthRo1tpTkGsklEefZdqlEFdY4A2dwTb6ohg==
+"@babel/plugin-transform-unicode-escapes@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.23.3.tgz#1f66d16cab01fab98d784867d24f70c1ca65b925"
+  integrity sha512-OMCUx/bU6ChE3r4+ZdylEqAjaQgHAgipgW8nsCfu5pGqDcFytVd91AwRvUJSBZDz0exPGgnjoqhgRYLRjFZc9Q==
   dependencies:
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-unicode-property-regex@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.22.5.tgz#098898f74d5c1e86660dc112057b2d11227f1c81"
-  integrity sha512-HCCIb+CbJIAE6sXn5CjFQXMwkCClcOfPCzTlilJ8cUatfzwHlWQkbtV0zD338u9dZskwvuOYTuuaMaA8J5EI5A==
+"@babel/plugin-transform-unicode-property-regex@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.23.3.tgz#19e234129e5ffa7205010feec0d94c251083d7ad"
+  integrity sha512-KcLIm+pDZkWZQAFJ9pdfmh89EwVfmNovFBcXko8szpBeF8z68kWIPeKlmSOkT9BXJxs2C0uk+5LxoxIv62MROA==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.22.5"
+    "@babel/helper-create-regexp-features-plugin" "^7.22.15"
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-unicode-regex@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.22.5.tgz#ce7e7bb3ef208c4ff67e02a22816656256d7a183"
-  integrity sha512-028laaOKptN5vHJf9/Arr/HiJekMd41hOEZYvNsrsXqJ7YPYuX2bQxh31fkZzGmq3YqHRJzYFFAVYvKfMPKqyg==
+"@babel/plugin-transform-unicode-regex@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.23.3.tgz#26897708d8f42654ca4ce1b73e96140fbad879dc"
+  integrity sha512-wMHpNA4x2cIA32b/ci3AfwNgheiva2W0WUKWTK7vBHBhDKfPsc5cFGNWm69WBqpwd86u1qwZ9PWevKqm1A3yAw==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.22.5"
+    "@babel/helper-create-regexp-features-plugin" "^7.22.15"
     "@babel/helper-plugin-utils" "^7.22.5"
 
-"@babel/plugin-transform-unicode-sets-regex@^7.22.5":
-  version "7.22.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.22.5.tgz#77788060e511b708ffc7d42fdfbc5b37c3004e91"
-  integrity sha512-lhMfi4FC15j13eKrh3DnYHjpGj6UKQHtNKTbtc1igvAhRy4+kLhV07OpLcsN0VgDEw/MjAvJO4BdMJsHwMhzCg==
+"@babel/plugin-transform-unicode-sets-regex@^7.23.3":
+  version "7.23.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.23.3.tgz#4fb6f0a719c2c5859d11f6b55a050cc987f3799e"
+  integrity sha512-W7lliA/v9bNR83Qc3q1ip9CQMZ09CcHDbHfbLRDNuAhn1Mvkr1ZNF7hPmztMQvtTGVLJ9m8IZqWsTkXOml8dbw==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.22.5"
+    "@babel/helper-create-regexp-features-plugin" "^7.22.15"
     "@babel/helper-plugin-utils" "^7.22.5"
 
 "@babel/preset-env@^7.9.0":
-  version "7.22.20"
-  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.22.20.tgz#de9e9b57e1127ce0a2f580831717f7fb677ceedb"
-  integrity sha512-11MY04gGC4kSzlPHRfvVkNAZhUxOvm7DCJ37hPDnUENwe06npjIRAfInEMTGSb4LZK5ZgDFkv5hw0lGebHeTyg==
+  version "7.23.7"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.23.7.tgz#e5d69b9f14db8a13bae4d8e5ce7f360973626241"
+  integrity sha512-SY27X/GtTz/L4UryMNJ6p4fH4nsgWbz84y9FE0bQeWJP6O5BhgVCt53CotQKHCOeXJel8VyhlhujhlltKms/CA==
   dependencies:
-    "@babel/compat-data" "^7.22.20"
-    "@babel/helper-compilation-targets" "^7.22.15"
+    "@babel/compat-data" "^7.23.5"
+    "@babel/helper-compilation-targets" "^7.23.6"
     "@babel/helper-plugin-utils" "^7.22.5"
-    "@babel/helper-validator-option" "^7.22.15"
-    "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.22.15"
-    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.22.15"
+    "@babel/helper-validator-option" "^7.23.5"
+    "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.23.3"
+    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.23.3"
+    "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly" "^7.23.7"
     "@babel/plugin-proposal-private-property-in-object" "7.21.0-placeholder-for-preset-env.2"
     "@babel/plugin-syntax-async-generators" "^7.8.4"
     "@babel/plugin-syntax-class-properties" "^7.12.13"
     "@babel/plugin-syntax-class-static-block" "^7.14.5"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
     "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
-    "@babel/plugin-syntax-import-assertions" "^7.22.5"
-    "@babel/plugin-syntax-import-attributes" "^7.22.5"
+    "@babel/plugin-syntax-import-assertions" "^7.23.3"
+    "@babel/plugin-syntax-import-attributes" "^7.23.3"
     "@babel/plugin-syntax-import-meta" "^7.10.4"
     "@babel/plugin-syntax-json-strings" "^7.8.3"
     "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"
@@ -856,59 +866,58 @@
     "@babel/plugin-syntax-private-property-in-object" "^7.14.5"
     "@babel/plugin-syntax-top-level-await" "^7.14.5"
     "@babel/plugin-syntax-unicode-sets-regex" "^7.18.6"
-    "@babel/plugin-transform-arrow-functions" "^7.22.5"
-    "@babel/plugin-transform-async-generator-functions" "^7.22.15"
-    "@babel/plugin-transform-async-to-generator" "^7.22.5"
-    "@babel/plugin-transform-block-scoped-functions" "^7.22.5"
-    "@babel/plugin-transform-block-scoping" "^7.22.15"
-    "@babel/plugin-transform-class-properties" "^7.22.5"
-    "@babel/plugin-transform-class-static-block" "^7.22.11"
-    "@babel/plugin-transform-classes" "^7.22.15"
-    "@babel/plugin-transform-computed-properties" "^7.22.5"
-    "@babel/plugin-transform-destructuring" "^7.22.15"
-    "@babel/plugin-transform-dotall-regex" "^7.22.5"
-    "@babel/plugin-transform-duplicate-keys" "^7.22.5"
-    "@babel/plugin-transform-dynamic-import" "^7.22.11"
-    "@babel/plugin-transform-exponentiation-operator" "^7.22.5"
-    "@babel/plugin-transform-export-namespace-from" "^7.22.11"
-    "@babel/plugin-transform-for-of" "^7.22.15"
-    "@babel/plugin-transform-function-name" "^7.22.5"
-    "@babel/plugin-transform-json-strings" "^7.22.11"
-    "@babel/plugin-transform-literals" "^7.22.5"
-    "@babel/plugin-transform-logical-assignment-operators" "^7.22.11"
-    "@babel/plugin-transform-member-expression-literals" "^7.22.5"
-    "@babel/plugin-transform-modules-amd" "^7.22.5"
-    "@babel/plugin-transform-modules-commonjs" "^7.22.15"
-    "@babel/plugin-transform-modules-systemjs" "^7.22.11"
-    "@babel/plugin-transform-modules-umd" "^7.22.5"
+    "@babel/plugin-transform-arrow-functions" "^7.23.3"
+    "@babel/plugin-transform-async-generator-functions" "^7.23.7"
+    "@babel/plugin-transform-async-to-generator" "^7.23.3"
+    "@babel/plugin-transform-block-scoped-functions" "^7.23.3"
+    "@babel/plugin-transform-block-scoping" "^7.23.4"
+    "@babel/plugin-transform-class-properties" "^7.23.3"
+    "@babel/plugin-transform-class-static-block" "^7.23.4"
+    "@babel/plugin-transform-classes" "^7.23.5"
+    "@babel/plugin-transform-computed-properties" "^7.23.3"
+    "@babel/plugin-transform-destructuring" "^7.23.3"
+    "@babel/plugin-transform-dotall-regex" "^7.23.3"
+    "@babel/plugin-transform-duplicate-keys" "^7.23.3"
+    "@babel/plugin-transform-dynamic-import" "^7.23.4"
+    "@babel/plugin-transform-exponentiation-operator" "^7.23.3"
+    "@babel/plugin-transform-export-namespace-from" "^7.23.4"
+    "@babel/plugin-transform-for-of" "^7.23.6"
+    "@babel/plugin-transform-function-name" "^7.23.3"
+    "@babel/plugin-transform-json-strings" "^7.23.4"
+    "@babel/plugin-transform-literals" "^7.23.3"
+    "@babel/plugin-transform-logical-assignment-operators" "^7.23.4"
+    "@babel/plugin-transform-member-expression-literals" "^7.23.3"
+    "@babel/plugin-transform-modules-amd" "^7.23.3"
+    "@babel/plugin-transform-modules-commonjs" "^7.23.3"
+    "@babel/plugin-transform-modules-systemjs" "^7.23.3"
+    "@babel/plugin-transform-modules-umd" "^7.23.3"
     "@babel/plugin-transform-named-capturing-groups-regex" "^7.22.5"
-    "@babel/plugin-transform-new-target" "^7.22.5"
-    "@babel/plugin-transform-nullish-coalescing-operator" "^7.22.11"
-    "@babel/plugin-transform-numeric-separator" "^7.22.11"
-    "@babel/plugin-transform-object-rest-spread" "^7.22.15"
-    "@babel/plugin-transform-object-super" "^7.22.5"
-    "@babel/plugin-transform-optional-catch-binding" "^7.22.11"
-    "@babel/plugin-transform-optional-chaining" "^7.22.15"
-    "@babel/plugin-transform-parameters" "^7.22.15"
-    "@babel/plugin-transform-private-methods" "^7.22.5"
-    "@babel/plugin-transform-private-property-in-object" "^7.22.11"
-    "@babel/plugin-transform-property-literals" "^7.22.5"
-    "@babel/plugin-transform-regenerator" "^7.22.10"
-    "@babel/plugin-transform-reserved-words" "^7.22.5"
-    "@babel/plugin-transform-shorthand-properties" "^7.22.5"
-    "@babel/plugin-transform-spread" "^7.22.5"
-    "@babel/plugin-transform-sticky-regex" "^7.22.5"
-    "@babel/plugin-transform-template-literals" "^7.22.5"
-    "@babel/plugin-transform-typeof-symbol" "^7.22.5"
-    "@babel/plugin-transform-unicode-escapes" "^7.22.10"
-    "@babel/plugin-transform-unicode-property-regex" "^7.22.5"
-    "@babel/plugin-transform-unicode-regex" "^7.22.5"
-    "@babel/plugin-transform-unicode-sets-regex" "^7.22.5"
+    "@babel/plugin-transform-new-target" "^7.23.3"
+    "@babel/plugin-transform-nullish-coalescing-operator" "^7.23.4"
+    "@babel/plugin-transform-numeric-separator" "^7.23.4"
+    "@babel/plugin-transform-object-rest-spread" "^7.23.4"
+    "@babel/plugin-transform-object-super" "^7.23.3"
+    "@babel/plugin-transform-optional-catch-binding" "^7.23.4"
+    "@babel/plugin-transform-optional-chaining" "^7.23.4"
+    "@babel/plugin-transform-parameters" "^7.23.3"
+    "@babel/plugin-transform-private-methods" "^7.23.3"
+    "@babel/plugin-transform-private-property-in-object" "^7.23.4"
+    "@babel/plugin-transform-property-literals" "^7.23.3"
+    "@babel/plugin-transform-regenerator" "^7.23.3"
+    "@babel/plugin-transform-reserved-words" "^7.23.3"
+    "@babel/plugin-transform-shorthand-properties" "^7.23.3"
+    "@babel/plugin-transform-spread" "^7.23.3"
+    "@babel/plugin-transform-sticky-regex" "^7.23.3"
+    "@babel/plugin-transform-template-literals" "^7.23.3"
+    "@babel/plugin-transform-typeof-symbol" "^7.23.3"
+    "@babel/plugin-transform-unicode-escapes" "^7.23.3"
+    "@babel/plugin-transform-unicode-property-regex" "^7.23.3"
+    "@babel/plugin-transform-unicode-regex" "^7.23.3"
+    "@babel/plugin-transform-unicode-sets-regex" "^7.23.3"
     "@babel/preset-modules" "0.1.6-no-external-plugins"
-    "@babel/types" "^7.22.19"
-    babel-plugin-polyfill-corejs2 "^0.4.5"
-    babel-plugin-polyfill-corejs3 "^0.8.3"
-    babel-plugin-polyfill-regenerator "^0.5.2"
+    babel-plugin-polyfill-corejs2 "^0.4.7"
+    babel-plugin-polyfill-corejs3 "^0.8.7"
+    babel-plugin-polyfill-regenerator "^0.5.4"
     core-js-compat "^3.31.0"
     semver "^6.3.1"
 
@@ -927,13 +936,13 @@
   integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
 
 "@babel/runtime@^7.8.4":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8"
-  integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==
+  version "7.23.7"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.7.tgz#dd7c88deeb218a0f8bd34d5db1aa242e0f203193"
+  integrity sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA==
   dependencies:
     regenerator-runtime "^0.14.0"
 
-"@babel/template@^7.22.15", "@babel/template@^7.22.5", "@babel/template@^7.4.0":
+"@babel/template@^7.22.15", "@babel/template@^7.4.0":
   version "7.22.15"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
   integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==
@@ -942,29 +951,29 @@
     "@babel/parser" "^7.22.15"
     "@babel/types" "^7.22.15"
 
-"@babel/traverse@^7.22.15", "@babel/traverse@^7.22.20", "@babel/traverse@^7.4.3":
-  version "7.22.20"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.22.20.tgz#db572d9cb5c79e02d83e5618b82f6991c07584c9"
-  integrity sha512-eU260mPZbU7mZ0N+X10pxXhQFMGTeLb9eFS0mxehS8HZp9o1uSnFeWQuG1UPrlxgA7QoUzFhOnilHDp0AXCyHw==
+"@babel/traverse@^7.23.7", "@babel/traverse@^7.4.3":
+  version "7.23.7"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.7.tgz#9a7bf285c928cb99b5ead19c3b1ce5b310c9c305"
+  integrity sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==
   dependencies:
-    "@babel/code-frame" "^7.22.13"
-    "@babel/generator" "^7.22.15"
+    "@babel/code-frame" "^7.23.5"
+    "@babel/generator" "^7.23.6"
     "@babel/helper-environment-visitor" "^7.22.20"
-    "@babel/helper-function-name" "^7.22.5"
+    "@babel/helper-function-name" "^7.23.0"
     "@babel/helper-hoist-variables" "^7.22.5"
     "@babel/helper-split-export-declaration" "^7.22.6"
-    "@babel/parser" "^7.22.16"
-    "@babel/types" "^7.22.19"
-    debug "^4.1.0"
+    "@babel/parser" "^7.23.6"
+    "@babel/types" "^7.23.6"
+    debug "^4.3.1"
     globals "^11.1.0"
 
-"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
-  version "7.22.19"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.19.tgz#7425343253556916e440e662bb221a93ddb75684"
-  integrity sha512-P7LAw/LbojPzkgp5oznjE6tQEIWbp4PkkfrZDINTro9zgBRtI324/EYsiSI7lhPbpIQ+DCeR2NNmMWANGGfZsg==
+"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.23.6", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
+  version "7.23.6"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.6.tgz#be33fdb151e1f5a56877d704492c240fc71c7ccd"
+  integrity sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==
   dependencies:
-    "@babel/helper-string-parser" "^7.22.5"
-    "@babel/helper-validator-identifier" "^7.22.19"
+    "@babel/helper-string-parser" "^7.23.4"
+    "@babel/helper-validator-identifier" "^7.22.20"
     to-fast-properties "^2.0.0"
 
 "@colors/colors@1.5.0":
@@ -1114,9 +1123,9 @@
   integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
 
 "@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9":
-  version "0.3.19"
-  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz#f8a3249862f91be48d3127c3cfe992f79b4b8811"
-  integrity sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==
+  version "0.3.20"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz#72e45707cf240fa6b081d0366f8265b0cd10197f"
+  integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==
   dependencies:
     "@jridgewell/resolve-uri" "^3.1.0"
     "@jridgewell/sourcemap-codec" "^1.4.14"
@@ -1128,17 +1137,17 @@
   dependencies:
     vary "^1.1.2"
 
-"@lit-labs/ssr-dom-shim@^1.0.0", "@lit-labs/ssr-dom-shim@^1.1.0":
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.1.tgz#64df34e2f12e68e78ac57e571d25ec07fa460ca9"
-  integrity sha512-kXOeFbfCm4fFf2A3WwVEeQj55tMZa8c8/f9AKHMobQMkzNUfUj+antR3fRPaZJawsa1aZiP/Da3ndpZrwEe4rQ==
+"@lit-labs/ssr-dom-shim@^1.1.2":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz#d693d972974a354034454ec1317eb6afd0b00312"
+  integrity sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==
 
-"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.6.0":
-  version "1.6.3"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.3.tgz#25b4eece2592132845d303e091bad9b04cdcfe03"
-  integrity sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==
+"@lit/reactive-element@^1.0.0 || ^2.0.0", "@lit/reactive-element@^2.0.0":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.2.tgz#779ae9d265407daaf7737cb892df5ec2a86e22a0"
+  integrity sha512-SVOwLAWUQg3Ji1egtOt1UiFe4zdDpnWHyc5qctSceJ5XIu0Uc76YmGpIjZgx9YJ0XtdW0Jm507sDvjOu+HnB8w==
   dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.0.0"
+    "@lit-labs/ssr-dom-shim" "^1.1.2"
 
 "@mdn/browser-compat-data@^4.0.0":
   version "4.2.1"
@@ -1198,14 +1207,6 @@
     whatwg-fetch "^3.5.0"
     whatwg-url "^7.1.0"
 
-"@open-wc/chai-dom-equals@^0.12.36":
-  version "0.12.36"
-  resolved "https://registry.yarnpkg.com/@open-wc/chai-dom-equals/-/chai-dom-equals-0.12.36.tgz#ed0eb56b9e98c4d7f7280facce6215654aae9f4c"
-  integrity sha512-Gt1fa37h4rtWPQGETSU4n1L678NmMi9KwHM1sH+JCGcz45rs8DBPx7MUVeGZ+HxRlbEI5t9LU2RGGv6xT2OlyA==
-  dependencies:
-    "@open-wc/semantic-dom-diff" "^0.13.16"
-    "@types/chai" "^4.1.7"
-
 "@open-wc/dedupe-mixin@^1.4.0":
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz#b3c58f8699b197bb5e923d624c720e67c9f324d6"
@@ -1227,19 +1228,14 @@
     portfinder "^1.0.21"
     request "^2.88.0"
 
-"@open-wc/scoped-elements@^2.2.0":
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.2.0.tgz#4d65d7ba796c2bb76ef7934068532ca1795ea7b6"
-  integrity sha512-Qe+vWsuVHFzUkdChwlmJGuQf9cA3I+QOsSHULV/6qf6wsqLM2/32svNRH+rbBIMwiPEwzZprZlkvkqQRucYnVA==
+"@open-wc/scoped-elements@^2.2.4":
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.2.4.tgz#081559b62d885ac0ec043546f17f1f680294d500"
+  integrity sha512-12X4F4QGPWcvPbxAiJ4v8wQFCOu+laZHRGfTrkoj+3JzACCtuxHG49YbuqVzQ135QPKCuhP9wA0kpGGEfUegyg==
   dependencies:
-    "@lit/reactive-element" "^1.0.0"
+    "@lit/reactive-element" "^1.0.0 || ^2.0.0"
     "@open-wc/dedupe-mixin" "^1.4.0"
 
-"@open-wc/semantic-dom-diff@^0.13.16":
-  version "0.13.21"
-  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.13.21.tgz#718b9ec5f9a98935fc775e577ad094ae8d8b7dea"
-  integrity sha512-BONpjHcGX2zFa9mfnwBCLEmlDsOHzT+j6Qt1yfK3MzFXFtAykfzFjAgaxPetu0YbBlCfXuMlfxI4vlRGCGMvFg==
-
 "@open-wc/semantic-dom-diff@^0.19.9":
   version "0.19.9"
   resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.19.9.tgz#fd27659cbace40c6a59078233f4fa14a308a45b1"
@@ -1249,32 +1245,30 @@
     "@web/test-runner-commands" "^0.6.5"
 
 "@open-wc/semantic-dom-diff@^0.20.0":
-  version "0.20.0"
-  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.20.0.tgz#3766aa88f67df624db0494adf82c8035216a2493"
-  integrity sha512-qGHl3nkXluXsjpLY9bSZka/cnlrybPtJMs6RjmV/OP4ID7Gcz1uNWQks05pAhptDB1R47G6PQjdwxG8dXl1zGA==
+  version "0.20.1"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.20.1.tgz#b1bb78be455bd99fb034d9baadbb959d7d124030"
+  integrity sha512-mPF/RPT2TU7Dw41LEDdaeP6eyTOWBD4z0+AHP4/d0SbgcfJZVRymlIB6DQmtz0fd2CImIS9kszaMmwMt92HBPA==
   dependencies:
     "@types/chai" "^4.3.1"
-    "@web/test-runner-commands" "^0.7.0"
+    "@web/test-runner-commands" "^0.9.0"
 
-"@open-wc/testing-helpers@^2.3.0":
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.3.0.tgz#6ee88baaf316a6217c43e7ba536cb187d15cb6f4"
-  integrity sha512-wkDipkia/OMWq5Z1KkAgvqNLfIOCiPGrrtfoCKuQje8u7F0Bz9Un44EwBtWcCdYtLc40quWP7XFpFsW8poIfUA==
+"@open-wc/testing-helpers@^2.3.1":
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.3.2.tgz#c2bfa82cedd833608effa2d2367fe9524ddf4434"
+  integrity sha512-uZMGC/C1m5EiwQsff6KMmCW25TYMQlJt4ilAWIjnelWGFg9HPUiLnlFvAas3ESUP+4OXLO8Oft7p4mHvbYvAEQ==
   dependencies:
-    "@open-wc/scoped-elements" "^2.2.0"
-    lit "^2.0.0"
-    lit-html "^2.0.0"
+    "@open-wc/scoped-elements" "^2.2.4"
+    lit "^2.0.0 || ^3.0.0"
+    lit-html "^2.0.0 || ^3.0.0"
 
 "@open-wc/testing@^3.2.0":
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.2.0.tgz#884ca348861a116829ce5657fccff11a1a9a07bd"
-  integrity sha512-9geTbFq8InbcfniPtS8KCfb5sbQ9WE6QMo1Tli8XMnfllnkZok7Az4kTRAskGQeMeQN/I2I//jE5xY/60qhrHg==
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.2.2.tgz#c952f4b20af0d201cc8cc436c2c3cdd338bf8177"
+  integrity sha512-byN4dJTd6ZyI9mWmI4lVj30uiu+rYvQr93g64Pd7UFBdAUgb02DHLj6fkJ1gjxA6LC/MeFd7K7mOZ4+vKrMptw==
   dependencies:
     "@esm-bundle/chai" "^4.3.4-fix.0"
-    "@open-wc/chai-dom-equals" "^0.12.36"
     "@open-wc/semantic-dom-diff" "^0.20.0"
-    "@open-wc/testing-helpers" "^2.3.0"
-    "@types/chai" "^4.2.11"
+    "@open-wc/testing-helpers" "^2.3.1"
     "@types/chai-dom" "^1.11.0"
     "@types/sinon-chai" "^3.2.3"
     chai-a11y-axe "^1.5.0"
@@ -1366,21 +1360,21 @@
   integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
 
 "@types/accepts@*":
-  version "1.3.5"
-  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
-  integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==
+  version "1.3.7"
+  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.7.tgz#3b98b1889d2b2386604c2bbbe62e4fb51e95b265"
+  integrity sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==
   dependencies:
     "@types/node" "*"
 
 "@types/babel__code-frame@^7.0.2":
-  version "7.0.4"
-  resolved "https://registry.yarnpkg.com/@types/babel__code-frame/-/babel__code-frame-7.0.4.tgz#0d14543f70ca91f4d2b0513a60f1eb31432c42e1"
-  integrity sha512-WBxINLlATjvmpCgBbb9tOPrKtcPfu4A/Yz2iRzmdaodfvjAS/Z0WZJClV9/EXvoC9viI3lgUs7B9Uo7G/RmMGg==
+  version "7.0.6"
+  resolved "https://registry.yarnpkg.com/@types/babel__code-frame/-/babel__code-frame-7.0.6.tgz#20a899c0d29fba1ddf5c2156a10a2bda75ee6f29"
+  integrity sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==
 
 "@types/babel__core@^7.1.3":
-  version "7.20.2"
-  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.2.tgz#215db4f4a35d710256579784a548907237728756"
-  integrity sha512-pNpr1T1xLUc2l3xJKuPtsEky3ybxN3m4fJkknfIpTCTfIZCDW57oAg+EfCgIIp2rvCe0Wn++/FfodDS4YXxBwA==
+  version "7.20.5"
+  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017"
+  integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==
   dependencies:
     "@babel/parser" "^7.20.7"
     "@babel/types" "^7.20.7"
@@ -1389,39 +1383,39 @@
     "@types/babel__traverse" "*"
 
 "@types/babel__generator@*":
-  version "7.6.5"
-  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.5.tgz#281f4764bcbbbc51fdded0f25aa587b4ce14da95"
-  integrity sha512-h9yIuWbJKdOPLJTbmSpPzkF67e659PbQDba7ifWm5BJ8xTv+sDmS7rFmywkWOvXedGTivCdeGSIIX8WLcRTz8w==
+  version "7.6.8"
+  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.8.tgz#f836c61f48b1346e7d2b0d93c6dacc5b9535d3ab"
+  integrity sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==
   dependencies:
     "@babel/types" "^7.0.0"
 
 "@types/babel__template@*":
-  version "7.4.2"
-  resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.2.tgz#843e9f1f47c957553b0c374481dc4772921d6a6b"
-  integrity sha512-/AVzPICMhMOMYoSx9MoKpGDKdBRsIXMNByh1PXSZoa+v6ZoLa8xxtsT/uLQ/NJm0XVAWl/BvId4MlDeXJaeIZQ==
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f"
+  integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==
   dependencies:
     "@babel/parser" "^7.1.0"
     "@babel/types" "^7.0.0"
 
 "@types/babel__traverse@*":
-  version "7.20.2"
-  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.2.tgz#4ddf99d95cfdd946ff35d2b65c978d9c9bf2645d"
-  integrity sha512-ojlGK1Hsfce93J0+kn3H5R73elidKUaZonirN33GSmgTUMpzI/MIFfSpF3haANe3G1bEBS9/9/QEqwTzwqFsKw==
+  version "7.20.5"
+  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.5.tgz#7b7502be0aa80cc4ef22978846b983edaafcd4dd"
+  integrity sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==
   dependencies:
     "@babel/types" "^7.20.7"
 
 "@types/body-parser@*":
-  version "1.19.3"
-  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.3.tgz#fb558014374f7d9e56c8f34bab2042a3a07d25cd"
-  integrity sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==
+  version "1.19.5"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4"
+  integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==
   dependencies:
     "@types/connect" "*"
     "@types/node" "*"
 
 "@types/browserslist-useragent@^3.0.0":
-  version "3.0.5"
-  resolved "https://registry.yarnpkg.com/@types/browserslist-useragent/-/browserslist-useragent-3.0.5.tgz#406d83d09688c83b42dd7f2ccf984aa41b13f243"
-  integrity sha512-CLrJk4px6W5KY/7bmSkUMpWN1qOLFxZZ9+oWvSzarxJsOnMauvY6Tblf4ePpXv/3gEZx6j2iBpH0Ow3Wp8Z8+Q==
+  version "3.0.7"
+  resolved "https://registry.yarnpkg.com/@types/browserslist-useragent/-/browserslist-useragent-3.0.7.tgz#4c9783bf3c31aa71f8040dc74eef7d59bc3b0e05"
+  integrity sha512-rVvdB0HoQvHDkS8SPgUv2tKfnf0zKIzBh8oisvnq82R3asgpnF857UTAUJuh+3VXPqMYdZ13VWfdIDUN/1iFmQ==
 
 "@types/browserslist@^4.8.0":
   version "4.15.0"
@@ -1431,56 +1425,56 @@
     browserslist "*"
 
 "@types/caniuse-api@^3.0.0":
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.3.tgz#17899e2fa5d2443bd2576b05b7c1296b2b16cae0"
-  integrity sha512-nOcaDp0Qa1i5T0IUeW5y8jiGD2VaOj9RV5FzfV5fpMBJ0vkPIC+NV9ELKHwooxBVEN2+mI0J+v6NC7oiEXpnLQ==
+  version "3.0.6"
+  resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.6.tgz#68902fe2d809f65aa2b23a053f5c071e0044c443"
+  integrity sha512-yMGwHJaqwIEXc3x7EyY3CeS73QG9WeC00w2nZ0/inoRv9DiLIhfvrY6vmXMSKlpRLFxrLcAWJh3JTwYNPl3ihg==
 
 "@types/chai-dom@^1.11.0":
-  version "1.11.1"
-  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-1.11.1.tgz#5f91fb34a612ccef177c70100c7c1b98a684d696"
-  integrity sha512-q+fs4jdKZFDhXOWBehY0jDGCp8nxVe11Ia8MxqlIsJC3Y2JU149PSBYF2li2F3uxJFSAl2Rf8XeLWonHglpcGw==
+  version "1.11.3"
+  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-1.11.3.tgz#1659ace2698cdcd9ed8b2c007876f53e37d9cc89"
+  integrity sha512-EUEZI7uID4ewzxnU7DJXtyvykhQuwe+etJ1wwOiJyQRTH/ifMWKX+ghiXkxCUvNJ6IQDodf0JXhuP6zZcy2qXQ==
   dependencies:
     "@types/chai" "*"
 
-"@types/chai@*", "@types/chai@^4.1.7", "@types/chai@^4.2.11", "@types/chai@^4.2.12", "@types/chai@^4.3.1":
-  version "4.3.6"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.6.tgz#7b489e8baf393d5dd1266fb203ddd4ea941259e6"
-  integrity sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw==
+"@types/chai@*", "@types/chai@^4.2.12", "@types/chai@^4.3.1":
+  version "4.3.11"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.11.tgz#e95050bf79a932cb7305dd130254ccdf9bde671c"
+  integrity sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ==
 
 "@types/co-body@^6.1.0":
-  version "6.1.1"
-  resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.1.tgz#28d253c95cfbe30c8e8c5d69d4c0dbbcffc101c2"
-  integrity sha512-I9A1k7o4m8m6YPYJIGb1JyNTLqRWtSPg1JOZPWlE19w8Su2VRgRVp/SkKftQSwoxWHGUxGbON4jltONMumC8bQ==
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.3.tgz#201796c6389066b400cfcb4e1ec5c3db798265a2"
+  integrity sha512-UhuhrQ5hclX6UJctv5m4Rfp52AfG9o9+d9/HwjxhVB5NjXxr5t9oKgJxN8xRHgr35oo8meUEHUPFWiKg6y71aA==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
 
 "@types/command-line-args@^5.0.0":
-  version "5.2.1"
-  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.1.tgz#233bd1ba687e84ecbec0388e09f9ec9ebf63c55b"
-  integrity sha512-U2OcmS2tj36Yceu+mRuPyUV0ILfau/h5onStcSCzqTENsq0sBiAp2TmaXu1k8fY4McLcPKSYl9FRzn2hx5bI+w==
+  version "5.2.3"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.3.tgz#553ce2fd5acf160b448d307649b38ffc60d39639"
+  integrity sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==
 
 "@types/command-line-usage@^5.0.1":
-  version "5.0.2"
-  resolved "https://registry.yarnpkg.com/@types/command-line-usage/-/command-line-usage-5.0.2.tgz#ba5e3f6ae5a2009d466679cc431b50635bf1a064"
-  integrity sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==
+  version "5.0.4"
+  resolved "https://registry.yarnpkg.com/@types/command-line-usage/-/command-line-usage-5.0.4.tgz#374e4c62d78fbc5a670a0f36da10235af879a0d5"
+  integrity sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==
 
 "@types/connect@*":
-  version "3.4.36"
-  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.36.tgz#e511558c15a39cb29bd5357eebb57bd1459cd1ab"
-  integrity sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==
+  version "3.4.38"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858"
+  integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==
   dependencies:
     "@types/node" "*"
 
 "@types/content-disposition@*":
-  version "0.5.6"
-  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.6.tgz#0f5fa03609f308a7a1a57e0b0afe4b95f1d19740"
-  integrity sha512-GmShTb4qA9+HMPPaV2+Up8tJafgi38geFi7vL4qAM7k8BwjoelgHZqEUKJZLvughUw22h6vD/wvwN4IUCaWpDA==
+  version "0.5.8"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.8.tgz#6742a5971f490dc41e59d277eee71361fea0b537"
+  integrity sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==
 
 "@types/convert-source-map@^2.0.0":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-2.0.1.tgz#e72e8a3de9d6fe3d8e43d5c101c346de2ff6abdf"
-  integrity sha512-tm5Eb3AwhibN6ULRaad5TbNO83WoXVZLh2YRGAFH+qWkUz48l9Hu1jc+wJswB7T+ACWAG0cFnTeeQGpwedvlNw==
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-2.0.3.tgz#e586c22ca4af2d670d47d32d7fe365d5c5558695"
+  integrity sha512-ag0BfJLZf6CQz8VIuRIEYQ5Ggwk/82uvTQf27RcpyDNbY0Vw49LIPqAxk5tqYfrCs9xDaIMvl4aj7ZopnYL8bA==
 
 "@types/cookie@^0.4.1":
   version "0.4.1"
@@ -1488,9 +1482,9 @@
   integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==
 
 "@types/cookies@*":
-  version "0.7.8"
-  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.8.tgz#16fccd6d58513a9833c527701a90cc96d216bc18"
-  integrity sha512-y6KhF1GtsLERUpqOV+qZJrjUGzc0GE6UTa0b5Z/LZ7Nm2mKSdCXmS6Kdnl7fctPNnMSouHjxqEWI12/YqQfk5w==
+  version "0.7.10"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.10.tgz#c4881dca4dd913420c488508d192496c46eb4fd0"
+  integrity sha512-hmUCjAk2fwZVPPkkPBcI7jGLIR5mg4OVoNMBwU6aVsMm/iNPY7z9/R+x2fSwLt/ZXoGua6C5Zy2k5xOo9jUyhQ==
   dependencies:
     "@types/connect" "*"
     "@types/express" "*"
@@ -1498,16 +1492,16 @@
     "@types/node" "*"
 
 "@types/cors@^2.8.12":
-  version "2.8.14"
-  resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.14.tgz#94eeb1c95eda6a8ab54870a3bf88854512f43a92"
-  integrity sha512-RXHUvNWYICtbP6s18PnOCaqToK8y14DnLd75c6HfyKf228dxy7pHNOQkxPtvXKp/hINFMDjbYzsj63nnpPMSRQ==
+  version "2.8.17"
+  resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b"
+  integrity sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==
   dependencies:
     "@types/node" "*"
 
 "@types/debounce@^1.2.0":
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.1.tgz#79b65710bc8b6d44094d286aecf38e44f9627852"
-  integrity sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.4.tgz#cb7e85d9ad5ababfac2f27183e8ac8b576b2abb3"
+  integrity sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==
 
 "@types/estree@0.0.39":
   version "0.0.39"
@@ -1515,16 +1509,16 @@
   integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
 
 "@types/etag@*":
-  version "1.8.1"
-  resolved "https://registry.yarnpkg.com/@types/etag/-/etag-1.8.1.tgz#593ca8ddb43acb3db049bd0955fd64d281ab58b9"
-  integrity sha512-bsKkeSqN7HYyYntFRAmzcwx/dKW4Wa+KVMTInANlI72PWLQmOpZu96j0OqHZGArW4VQwCmJPteQlXaUDeOB0WQ==
+  version "1.8.3"
+  resolved "https://registry.yarnpkg.com/@types/etag/-/etag-1.8.3.tgz#0321c878a1ac1069131e4d90deab06db5ea2a0db"
+  integrity sha512-QYHv9Yeh1ZYSMPQOoxY4XC4F1r+xRUiAriB303F4G6uBsT3KKX60DjiogvVv+2VISVDuJhcIzMdbjT+Bm938QQ==
   dependencies:
     "@types/node" "*"
 
 "@types/express-serve-static-core@^4.17.33":
-  version "4.17.36"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.36.tgz#baa9022119bdc05a4adfe740ffc97b5f9360e545"
-  integrity sha512-zbivROJ0ZqLAtMzgzIUC4oNqDG9iF0lSsAqpOD9kbs5xcIM3dTiyuHvBc7R8MtWBp3AAWGaovJa+wzWPjLYW7Q==
+  version "4.17.41"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz#5077defa630c2e8d28aa9ffc2c01c157c305bef6"
+  integrity sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
@@ -1532,9 +1526,9 @@
     "@types/send" "*"
 
 "@types/express@*":
-  version "4.17.17"
-  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4"
-  integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d"
+  integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==
   dependencies:
     "@types/body-parser" "*"
     "@types/express-serve-static-core" "^4.17.33"
@@ -1542,43 +1536,43 @@
     "@types/serve-static" "*"
 
 "@types/http-assert@*":
-  version "1.5.3"
-  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661"
-  integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==
+  version "1.5.5"
+  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.5.tgz#dfb1063eb7c240ee3d3fe213dac5671cfb6a8dbf"
+  integrity sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g==
 
 "@types/http-errors@*":
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.2.tgz#a86e00bbde8950364f8e7846687259ffcd96e8c2"
-  integrity sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f"
+  integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==
 
 "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.3":
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
-  integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7"
+  integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==
 
 "@types/istanbul-lib-report@*":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686"
-  integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz#53047614ae72e19fc0401d872de3ae2b4ce350bf"
+  integrity sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==
   dependencies:
     "@types/istanbul-lib-coverage" "*"
 
 "@types/istanbul-reports@^3.0.0":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff"
-  integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54"
+  integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==
   dependencies:
     "@types/istanbul-lib-report" "*"
 
 "@types/keygrip@*":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.3.tgz#2286b16ef71d8dea74dab00902ef419a54341bfe"
-  integrity sha512-tfzBBb7OV2PbUfKbG6zRE5UbmtdLVCKT/XT364Z9ny6pXNbd9GnIB6aFYpq2A5lZ6mq9bhXgK6h5MFGNwhMmuQ==
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.6.tgz#1749535181a2a9b02ac04a797550a8787345b740"
+  integrity sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==
 
 "@types/koa-compose@*":
-  version "3.2.6"
-  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.6.tgz#17a077786d0ac5eee04c37a7d6c207b3252f6de9"
-  integrity sha512-PHiciWxH3NRyAaxUdEDE1NIZNfvhgtPlsdkjRPazHC6weqt90Jr0uLhIQs+SDwC8HQ/jnA7UQP6xOqGFB7ugWw==
+  version "3.2.8"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.8.tgz#dec48de1f6b3d87f87320097686a915f1e954b57"
+  integrity sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==
   dependencies:
     "@types/koa" "*"
 
@@ -1591,32 +1585,32 @@
     "@types/node" "*"
 
 "@types/koa-etag@^3.0.0":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@types/koa-etag/-/koa-etag-3.0.1.tgz#291025c16380dd20648c219bd2281d4721e40194"
-  integrity sha512-UkpP45FxOlwb33SPeCulTs2cIJg+tiQw/ea6vXp4JYJfMNNGUovEa/K1Id4+O2XNQe3rhCgagHMExjJfv9PhJQ==
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/koa-etag/-/koa-etag-3.0.3.tgz#67adf67b18057e4ecdc65efd705a979ba638d521"
+  integrity sha512-pOtRwgJOyP5XWoKUQpvvGbihep7ZoCQwJiFhZ2PdAjmXhgDFm5bbX9vYbXCwLIWG+WUb8CsOCf+78uae0Ho5sg==
   dependencies:
     "@types/etag" "*"
     "@types/koa" "*"
 
 "@types/koa-send@*":
-  version "4.1.4"
-  resolved "https://registry.yarnpkg.com/@types/koa-send/-/koa-send-4.1.4.tgz#c11fd792bbcf55d2c0117f975316c3f47ef2546e"
-  integrity sha512-+ttyO5T1T1cLRUtk9etg/4E7ZIplJJUANkuzYptCPysWX5LRfGHsv9YOCiB7+gkAuedjEgZrl4K02RWJ2gaJ6Q==
+  version "4.1.6"
+  resolved "https://registry.yarnpkg.com/@types/koa-send/-/koa-send-4.1.6.tgz#15d90e95e3ccce669a15b6a3c56c3a650a167cea"
+  integrity sha512-vgnNGoOJkx7FrF0Jl6rbK1f8bBecqAchKpXtKuXzqIEdXTDO6dsSTjr+eZ5m7ltSjH4K/E7auNJEQCAd0McUPA==
   dependencies:
     "@types/koa" "*"
 
 "@types/koa-static@^4.0.1":
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/@types/koa-static/-/koa-static-4.0.2.tgz#a199d2d64d2930755eb3ea370aeaf2cb6f501d67"
-  integrity sha512-ns/zHg+K6XVPMuohjpOlpkR1WLa4VJ9czgUP9bxkCDn0JZBtUWbD/wKDZzPGDclkQK1bpAEScufCHOy8cbfL0w==
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/@types/koa-static/-/koa-static-4.0.4.tgz#ce6f2a5d14cc7ef19f9bf6ee8e4f3eadfcc77323"
+  integrity sha512-j1AUzzl7eJYEk9g01hNTlhmipFh8RFbOQmaMNLvLcNNAkPw0bdTs3XTa3V045XFlrWN0QYnblbDJv2RzawTn6A==
   dependencies:
     "@types/koa" "*"
     "@types/koa-send" "*"
 
 "@types/koa@*", "@types/koa@^2.0.48", "@types/koa@^2.11.6":
-  version "2.13.9"
-  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.9.tgz#8d989ac17d7f033475fbe34c4f906c9287c2041a"
-  integrity sha512-tPX3cN1dGrMn+sjCDEiQqXH2AqlPoPd594S/8zxwUm/ZbPsQXKqHPUypr2gjCPhHUc+nDJLduhh5lXI/1olnGQ==
+  version "2.13.12"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.12.tgz#70d87a9061a81909e0ee11ca50168416e8d3e795"
+  integrity sha512-vAo1KuDSYWFDB4Cs80CHvfmzSQWeUb909aQib0C0aFx4sw0K9UZFz2m5jaEP+b3X1+yr904iQiruS0hXi31jbw==
   dependencies:
     "@types/accepts" "*"
     "@types/content-disposition" "*"
@@ -1640,19 +1634,19 @@
   integrity sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==
 
 "@types/mime-types@^2.1.0":
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.1.tgz#d9ba43490fa3a3df958759adf69396c3532cf2c1"
-  integrity sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==
+  version "2.1.4"
+  resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.4.tgz#93a1933e24fed4fb9e4adc5963a63efcbb3317a2"
+  integrity sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==
 
 "@types/mime@*":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
-  integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45"
+  integrity sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==
 
 "@types/mime@^1":
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
-  integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690"
+  integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==
 
 "@types/minimatch@^3.0.3":
   version "3.0.5"
@@ -1672,9 +1666,11 @@
   integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==
 
 "@types/node@*", "@types/node@>=10.0.0":
-  version "20.6.3"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.3.tgz#5b763b321cd3b80f6b8dde7a37e1a77ff9358dd9"
-  integrity sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA==
+  version "20.10.6"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.10.6.tgz#a3ec84c22965802bf763da55b2394424f22bfbb5"
+  integrity sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==
+  dependencies:
+    undici-types "~5.26.4"
 
 "@types/parse5@^6.0.1":
   version "6.0.3"
@@ -1682,33 +1678,33 @@
   integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
 
 "@types/path-is-inside@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
-  integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.3.tgz#bda44a673de87bae62943ed617d0a246f7a034d0"
+  integrity sha512-xZoKJ7TQYIBc/ry4CHIV3M4V96zLMdTIGPT7Du+yYWevnfoaiW5bEPpkCL1RuEySw7k+JnlL1VcLZfyOg6Sp5g==
 
 "@types/pixelmatch@^5.2.2":
-  version "5.2.4"
-  resolved "https://registry.yarnpkg.com/@types/pixelmatch/-/pixelmatch-5.2.4.tgz#ca145cc5ede1388c71c68edf2d1f5190e5ddd0f6"
-  integrity sha512-HDaSHIAv9kwpMN7zlmwfTv6gax0PiporJOipcrGsVNF3Ba+kryOZc0Pio5pn6NhisgWr7TaajlPEKTbTAypIBQ==
+  version "5.2.6"
+  resolved "https://registry.yarnpkg.com/@types/pixelmatch/-/pixelmatch-5.2.6.tgz#fba6de304ac958495f27d85989f5c6bb7499a686"
+  integrity sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==
   dependencies:
     "@types/node" "*"
 
 "@types/pngjs@^6.0.0":
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-6.0.1.tgz#c711ec3fbbf077fed274ecccaf85dd4673130072"
-  integrity sha512-J39njbdW1U/6YyVXvC9+1iflZghP8jgRf2ndYghdJb5xL49LYDB+1EuAxfbuJ2IBbWIL3AjHPQhgaTxT3YaYeg==
+  version "6.0.4"
+  resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-6.0.4.tgz#9a457aebabd944efde1a773a0fa1d74933e8021b"
+  integrity sha512-atAK9xLKOnxiuArxcHovmnOUUGBZOQ3f0vCf43FnoKs6XnqiambT1kkJWmdo71IR+BoXSh+CueeFR0GfH3dTlQ==
   dependencies:
     "@types/node" "*"
 
 "@types/qs@*":
-  version "6.9.8"
-  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.8.tgz#f2a7de3c107b89b441e071d5472e6b726b4adf45"
-  integrity sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==
+  version "6.9.11"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.11.tgz#208d8a30bc507bd82e03ada29e4732ea46a6bbda"
+  integrity sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==
 
 "@types/range-parser@*":
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
-  integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
+  integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
 
 "@types/resolve@0.0.8":
   version "0.0.8"
@@ -1725,34 +1721,34 @@
     "@types/node" "*"
 
 "@types/send@*":
-  version "0.17.1"
-  resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.1.tgz#ed4932b8a2a805f1fe362a70f4e62d0ac994e301"
-  integrity sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==
+  version "0.17.4"
+  resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a"
+  integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==
   dependencies:
     "@types/mime" "^1"
     "@types/node" "*"
 
 "@types/serve-static@*":
-  version "1.15.2"
-  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.2.tgz#3e5419ecd1e40e7405d34093f10befb43f63381a"
-  integrity sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==
+  version "1.15.5"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.5.tgz#15e67500ec40789a1e8c9defc2d32a896f05b033"
+  integrity sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==
   dependencies:
     "@types/http-errors" "*"
     "@types/mime" "*"
     "@types/node" "*"
 
 "@types/sinon-chai@^3.2.3":
-  version "3.2.9"
-  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.9.tgz#71feb938574bbadcb176c68e5ff1a6014c5e69d4"
-  integrity sha512-/19t63pFYU0ikrdbXKBWj9PCdnKyTd0Qkz0X91Ta081cYsq90OxYdcWwK/dwEoDa6dtXgj2HJfmzgq+QZTHdmQ==
+  version "3.2.12"
+  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.12.tgz#c7cb06bee44a534ec84f3a5534c3a3a46fd779b6"
+  integrity sha512-9y0Gflk3b0+NhQZ/oxGtaAJDvRywCa5sIyaVnounqLvmf93yBF4EgIRspePtkMs3Tr844nCclYMlcCNmLCvjuQ==
   dependencies:
     "@types/chai" "*"
     "@types/sinon" "*"
 
 "@types/sinon@*":
-  version "10.0.16"
-  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.16.tgz#4bf10313bd9aa8eef1e50ec9f4decd3dd455b4d3"
-  integrity sha512-j2Du5SYpXZjJVJtXBokASpPRj+e2z+VUhCPHmM6WMfe3dpHu6iVKJMU6AiBcMp/XTAYnEj6Wc1trJUWwZ0QaAQ==
+  version "17.0.2"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.2.tgz#9a769f67e62b45b7233f1fe01cb1f231d2393e1c"
+  integrity sha512-Zt6heIGsdqERkxctIpvN5Pv3edgBrhoeb3yHyxffd4InN0AX2SVNKSrhdDZKGQICVOxWP/q4DyhpfPNMSrpIiA==
   dependencies:
     "@types/sinonjs__fake-timers" "*"
 
@@ -1764,14 +1760,14 @@
     "@types/sinonjs__fake-timers" "*"
 
 "@types/sinonjs__fake-timers@*":
-  version "8.1.2"
-  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e"
-  integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==
+  version "8.1.5"
+  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2"
+  integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==
 
 "@types/trusted-types@^2.0.2":
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.4.tgz#2b38784cd16957d3782e8e2b31c03bc1d13b4d65"
-  integrity sha512-IDaobHimLQhjwsQ/NMwRVfa/yL7L/wriQPMhw1ZJall0KX6E1oxk29XMDeilW5qTIg5aoiqf5Udy8U/51aNoQQ==
+  version "2.0.7"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
+  integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
 
 "@types/whatwg-url@^6.4.0":
   version "6.4.0"
@@ -1788,9 +1784,9 @@
     "@types/node" "*"
 
 "@types/yauzl@^2.9.1":
-  version "2.10.0"
-  resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599"
-  integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==
+  version "2.10.3"
+  resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999"
+  integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==
   dependencies:
     "@types/node" "*"
 
@@ -1806,10 +1802,10 @@
   dependencies:
     errorstacks "^2.2.0"
 
-"@web/browser-logs@^0.3.2":
-  version "0.3.3"
-  resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.3.3.tgz#121e5b662db2707c4b8cd1628d86903f059f5031"
-  integrity sha512-wt8arj0x7ghXbnipgCvLR+xQ90cFg16ae23cFbInCrJvAxvyI22bAtT24W4XOXMPXwWLBVUJwBgBcXo3oKIvDw==
+"@web/browser-logs@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.4.0.tgz#8c4adddac46be02dff1a605312132053b3737d0a"
+  integrity sha512-/EBiDAUCJ2DzZhaFxTPRIznEPeafdLbXShIL6aTu7x73x7ZoxSDv7DGuTsh2rWNMUa4+AKli4UORrpyv6QBOiA==
   dependencies:
     errorstacks "^2.2.0"
 
@@ -1844,14 +1840,14 @@
     picomatch "^2.2.2"
     ws "^7.4.2"
 
-"@web/dev-server-core@^0.5.1":
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.5.2.tgz#27fe5448e587a87272b556b44ce84c6453655cdb"
-  integrity sha512-7YjWmwzM+K5fPvBCXldUIMTK4EnEufi1aWQWinQE81oW1CqzEwmyUNCtnWV9fcPA4kJC4qrpcjWNGF4YDWxuSg==
+"@web/dev-server-core@^0.7.0":
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.7.0.tgz#ffe71dd272ecb73a2b0c1ee23f3fad812780b998"
+  integrity sha512-1FJe6cJ3r0x0ZmxY/FnXVduQD4lKX7QgYhyS6N+VmIpV+tBU4sGRbcrmeoYeY+nlnPa6p2oNuonk3X5ln/W95g==
   dependencies:
     "@types/koa" "^2.11.6"
     "@types/ws" "^7.4.0"
-    "@web/parse5-utils" "^2.0.0"
+    "@web/parse5-utils" "^2.1.0"
     chokidar "^3.4.3"
     clone "^2.1.2"
     es-module-lexer "^1.0.0"
@@ -1919,10 +1915,10 @@
     "@types/parse5" "^6.0.1"
     parse5 "^6.0.1"
 
-"@web/parse5-utils@^2.0.0":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-2.0.1.tgz#11b91417165a838954dcf228383cfd8e1bdaf914"
-  integrity sha512-FQI72BU5CXhpp7gLRskOQGGCcwvagLZnMnDwAfjrxo3pm1KOQzr8Vl+438IGpHV62xvjNdF1pjXwXcf7eekWGw==
+"@web/parse5-utils@^2.1.0":
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-2.1.0.tgz#3d33aca62c66773492f2fba89d23a45f8b57ba4a"
+  integrity sha512-GzfK5disEJ6wEjoPwx8AVNwUe9gYIiwc+x//QYxYDAFKUp4Xb1OJAGLc2l2gVrSQmtPGLKrTRcW90Hv4pEq1qA==
   dependencies:
     "@types/parse5" "^6.0.1"
     parse5 "^6.0.1"
@@ -1945,12 +1941,12 @@
     "@web/test-runner-core" "^0.10.29"
     mkdirp "^1.0.4"
 
-"@web/test-runner-commands@^0.7.0":
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.7.0.tgz#c9693e4e8b05ef06a2102e03ac924bcbf7985312"
-  integrity sha512-3aXeGrkynOdJ5jgZu5ZslcWmWuPVY9/HNdWDUqPyNePG08PKmLV9Ij342ODDL6OVsxF5dvYn1312PhDqu5AQNw==
+"@web/test-runner-commands@^0.9.0":
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.9.0.tgz#ed15a021249948204bb27559eb437ff6ceeee067"
+  integrity sha512-zeLI6QdH0jzzJMDV5O42Pd8WLJtYqovgdt0JdytgHc0d1EpzXDsc7NTCJSImboc2NcayIsWAvvGGeRF69SMMYg==
   dependencies:
-    "@web/test-runner-core" "^0.11.0"
+    "@web/test-runner-core" "^0.13.0"
     mkdirp "^1.0.4"
 
 "@web/test-runner-core@^0.10.20", "@web/test-runner-core@^0.10.27", "@web/test-runner-core@^0.10.29":
@@ -1985,10 +1981,10 @@
     picomatch "^2.2.2"
     source-map "^0.7.3"
 
-"@web/test-runner-core@^0.11.0":
-  version "0.11.4"
-  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.11.4.tgz#590994c3fc69337e2c5411bf11c293dd061cc07a"
-  integrity sha512-E7BsKAP8FAAEsfj4viASjmuaYfOw4UlKP9IFqo4W20eVyd1nbUWU3Amq4Jksh7W/j811qh3VaFNjDfCwklQXMg==
+"@web/test-runner-core@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.13.0.tgz#a3799461002fcb969b0baa100d88be6c1ff504f4"
+  integrity sha512-mUrETPg9n4dHWEk+D46BU3xVhQf+ljT4cG7FSpmF7AIOsXWgWHoaXp6ReeVcEmM5fmznXec2O/apTb9hpGrP3w==
   dependencies:
     "@babel/code-frame" "^7.12.11"
     "@types/babel__code-frame" "^7.0.2"
@@ -1997,8 +1993,8 @@
     "@types/debounce" "^1.2.0"
     "@types/istanbul-lib-coverage" "^2.0.3"
     "@types/istanbul-reports" "^3.0.0"
-    "@web/browser-logs" "^0.3.2"
-    "@web/dev-server-core" "^0.5.1"
+    "@web/browser-logs" "^0.4.0"
+    "@web/dev-server-core" "^0.7.0"
     chokidar "^3.4.3"
     cli-cursor "^3.1.0"
     co-body "^6.1.0"
@@ -2259,9 +2255,9 @@
   integrity sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==
 
 axe-core@^4.3.3:
-  version "4.8.1"
-  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.8.1.tgz#6948854183ee7e7eae336b9877c5bafa027998ea"
-  integrity sha512-9l850jDDPnKq48nbad8SiEelCv4OrUWrKab/cPj0GScVg6cb6NbCCt/Ulk26QEq5jP9NnGr04Bit1BHyV6r5CQ==
+  version "4.8.3"
+  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.8.3.tgz#205df863dd9917d5979e9435dab4d47692759051"
+  integrity sha512-d5ZQHPSPkF9Tw+yfyDcRoUOc4g/8UloJJe5J8m4L5+c7AtDdjDLRxew/knnI4CxvtdxEUVgWz4x3OIQUIFiMfw==
 
 babel-plugin-istanbul@^5.1.4:
   version "5.2.0"
@@ -2273,29 +2269,29 @@
     istanbul-lib-instrument "^3.3.0"
     test-exclude "^5.2.3"
 
-babel-plugin-polyfill-corejs2@^0.4.5:
-  version "0.4.5"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.5.tgz#8097b4cb4af5b64a1d11332b6fb72ef5e64a054c"
-  integrity sha512-19hwUH5FKl49JEsvyTcoHakh6BE0wgXLLptIyKZ3PijHc/Ci521wygORCUCCred+E/twuqRyAkE02BAWPmsHOg==
+babel-plugin-polyfill-corejs2@^0.4.7:
+  version "0.4.7"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.7.tgz#679d1b94bf3360f7682e11f2cb2708828a24fe8c"
+  integrity sha512-LidDk/tEGDfuHW2DWh/Hgo4rmnw3cduK6ZkOI1NPFceSK3n/yAGeOsNT7FLnSGHkXj3RHGSEVkN3FsCTY6w2CQ==
   dependencies:
     "@babel/compat-data" "^7.22.6"
-    "@babel/helper-define-polyfill-provider" "^0.4.2"
+    "@babel/helper-define-polyfill-provider" "^0.4.4"
     semver "^6.3.1"
 
-babel-plugin-polyfill-corejs3@^0.8.3:
-  version "0.8.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.3.tgz#b4f719d0ad9bb8e0c23e3e630c0c8ec6dd7a1c52"
-  integrity sha512-z41XaniZL26WLrvjy7soabMXrfPWARN25PZoriDEiLMxAp50AUW3t35BGQUMg5xK3UrpVTtagIDklxYa+MhiNA==
+babel-plugin-polyfill-corejs3@^0.8.7:
+  version "0.8.7"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.7.tgz#941855aa7fdaac06ed24c730a93450d2b2b76d04"
+  integrity sha512-KyDvZYxAzkC0Aj2dAPyDzi2Ym15e5JKZSK+maI7NAwSqofvuFglbSsxE7wUOvTg9oFVnHMzVzBKcqEb4PJgtOA==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.4.2"
-    core-js-compat "^3.31.0"
+    "@babel/helper-define-polyfill-provider" "^0.4.4"
+    core-js-compat "^3.33.1"
 
-babel-plugin-polyfill-regenerator@^0.5.2:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.2.tgz#80d0f3e1098c080c8b5a65f41e9427af692dc326"
-  integrity sha512-tAlOptU0Xj34V1Y2PNTL4Y0FOJMDB6bZmoW39FeCQIhigGLkqu3Fj6uiXpxIf6Ij274ENdYx64y6Au+ZKlb1IA==
+babel-plugin-polyfill-regenerator@^0.5.4:
+  version "0.5.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.4.tgz#c6fc8eab610d3a11eb475391e52584bacfc020f4"
+  integrity sha512-S/x2iOCvDaCASLYsOOgWOq4bCfKYVqvO/uxjkaYyZ3rVsVE3CeAI/c84NpyuBBymEgNvHgjEot3a9/Z/kXvqsg==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.4.2"
+    "@babel/helper-define-polyfill-provider" "^0.4.4"
 
 balanced-match@^1.0.0:
   version "1.0.2"
@@ -2389,15 +2385,15 @@
     useragent "^2.3.0"
     yamlparser "^0.0.2"
 
-browserslist@*, browserslist@^4.0.0, browserslist@^4.16.5, browserslist@^4.19.1, browserslist@^4.21.10, browserslist@^4.21.9, browserslist@^4.9.1:
-  version "4.21.10"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.10.tgz#dbbac576628c13d3b2231332cb2ec5a46e015bb0"
-  integrity sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==
+browserslist@*, browserslist@^4.0.0, browserslist@^4.16.5, browserslist@^4.19.1, browserslist@^4.22.2, browserslist@^4.9.1:
+  version "4.22.2"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.22.2.tgz#704c4943072bd81ea18997f3bd2180e89c77874b"
+  integrity sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==
   dependencies:
-    caniuse-lite "^1.0.30001517"
-    electron-to-chromium "^1.4.477"
-    node-releases "^2.0.13"
-    update-browserslist-db "^1.0.11"
+    caniuse-lite "^1.0.30001565"
+    electron-to-chromium "^1.4.601"
+    node-releases "^2.0.14"
+    update-browserslist-db "^1.0.13"
 
 buffer-crc32@~0.2.3:
   version "0.2.13"
@@ -2436,12 +2432,13 @@
     ylru "^1.2.0"
 
 call-bind@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
-  integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.5.tgz#6fa2b7845ce0ea49bf4d8b9ef64727a2c2e2e513"
+  integrity sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==
   dependencies:
-    function-bind "^1.1.1"
-    get-intrinsic "^1.0.2"
+    function-bind "^1.1.2"
+    get-intrinsic "^1.2.1"
+    set-function-length "^1.1.1"
 
 camel-case@^4.1.1:
   version "4.1.2"
@@ -2471,10 +2468,10 @@
     lodash.memoize "^4.1.2"
     lodash.uniq "^4.5.0"
 
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001033, caniuse-lite@^1.0.30001517:
-  version "1.0.30001538"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001538.tgz#9dbc6b9af1ff06b5eb12350c2012b3af56744f3f"
-  integrity sha512-HWJnhnID+0YMtGlzcp3T9drmBJUVDchPJ08tpUGFLs9CYlwWPH2uLgpHn8fND5pCgXVtnGS3H4QR9XLMHVNkHw==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001033, caniuse-lite@^1.0.30001565:
+  version "1.0.30001572"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001572.tgz#1ccf7dc92d2ee2f92ed3a54e11b7b4a3041acfa0"
+  integrity sha512-1Pbh5FLmn5y4+QhNyJE9j3/7dK44dGB83/ZMjv/qJk86TvDbjk0LosiZo0i0WB0Vx607qMX9jYrn1VLHCkN4rw==
 
 caseless@~0.12.0:
   version "0.12.0"
@@ -2550,9 +2547,9 @@
     source-map "~0.6.0"
 
 clean-css@^5.3.1:
-  version "5.3.2"
-  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.2.tgz#70ecc7d4d4114921f5d298349ff86a31a9975224"
-  integrity sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==
+  version "5.3.3"
+  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.3.tgz#b330653cd3bd6b75009cc25c714cae7b93351ccd"
+  integrity sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==
   dependencies:
     source-map "~0.6.0"
 
@@ -2712,25 +2709,25 @@
   resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
   integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
 
-cookies@~0.8.0:
-  version "0.8.0"
-  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
-  integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==
+cookies@~0.9.0:
+  version "0.9.1"
+  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.9.1.tgz#3ffed6f60bb4fb5f146feeedba50acc418af67e3"
+  integrity sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==
   dependencies:
     depd "~2.0.0"
     keygrip "~1.1.0"
 
 core-js-bundle@^3.6.0, core-js-bundle@^3.8.1:
-  version "3.32.2"
-  resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.32.2.tgz#3a7736797ef483ff5ced565864f7b0a09cbeded2"
-  integrity sha512-USljqWm24S8dyZdUEh8pHBxUsHcsVQaWmkZsR8e5ZHdpnGEO1XDxCZHP6/ACtgjkFQ/I/1SnTuWEBFPThMHfMQ==
+  version "3.35.0"
+  resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.35.0.tgz#dd87c465625b52a35dd891a930789d11cae00fe0"
+  integrity sha512-gyqx4VKhV1tGhoxeYoxVchR1vMxbQJrC8BrCPnIU163Oyf9//GYQNU2eH7lmjav2K+5WGAWgLyZGxAfGggwJKA==
 
-core-js-compat@^3.31.0:
-  version "3.32.2"
-  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.32.2.tgz#8047d1a8b3ac4e639f0d4f66d4431aa3b16e004c"
-  integrity sha512-+GjlguTDINOijtVRUxrQOv3kfu9rl+qPNdX2LTbJ/ZyVTuxK+ksVSAGX1nHstu4hrv1En/uPTtWgq2gI5wt4AQ==
+core-js-compat@^3.31.0, core-js-compat@^3.33.1:
+  version "3.35.0"
+  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.35.0.tgz#c149a3d1ab51e743bc1da61e39cb51f461a41873"
+  integrity sha512-5blwFAddknKeNgsjBzilkdQ0+YK8L1PfqPYq40NOYMYFSS38qj+hpTcLLWwpIwA2A5bje/x5jmVn2tzUMg9IVw==
   dependencies:
-    browserslist "^4.21.10"
+    browserslist "^4.22.2"
 
 core-util-is@1.0.2:
   version "1.0.2"
@@ -2781,7 +2778,7 @@
   dependencies:
     ms "2.0.0"
 
-debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2:
+debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2:
   version "4.3.4"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
   integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
@@ -2822,6 +2819,15 @@
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
   integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
 
+define-data-property@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.1.tgz#c35f7cd0ab09883480d12ac5cb213715587800b3"
+  integrity sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==
+  dependencies:
+    get-intrinsic "^1.2.1"
+    gopd "^1.0.1"
+    has-property-descriptors "^1.0.0"
+
 define-lazy-prop@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
@@ -2920,10 +2926,10 @@
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
   integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
 
-electron-to-chromium@^1.4.477, electron-to-chromium@^1.4.67:
-  version "1.4.526"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.526.tgz#1bcda5f2b8238e497c20fcdb41af5da907a770e2"
-  integrity sha512-tjjTMjmZAx1g6COrintLTa2/jcafYKxKoiEkdQOrVdbLaHh2wCt2nsAF8ZHweezkrP+dl/VG9T5nabcYoo0U5Q==
+electron-to-chromium@^1.4.601, electron-to-chromium@^1.4.67:
+  version "1.4.617"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.617.tgz#3b0dde6c54e5f0f26db75ce6c6ae751e5df4bf75"
+  integrity sha512-sYNE3QxcDS4ANW1k4S/wWYMXjCVcFSOX3Bg8jpuMFaXt/x8JCmp0R1Xe1ZXDX4WXnSRBf+GJ/3eGWicUuQq5cg==
 
 emoji-regex@^8.0.0:
   version "8.0.0"
@@ -2948,9 +2954,9 @@
   integrity sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==
 
 engine.io@~6.5.2:
-  version "6.5.2"
-  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.2.tgz#769348ced9d56bd47bd83d308ec1c3375e85937c"
-  integrity sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==
+  version "6.5.4"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.4.tgz#6822debf324e781add2254e912f8568508850cdc"
+  integrity sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==
   dependencies:
     "@types/cookie" "^0.4.1"
     "@types/cors" "^2.8.12"
@@ -2981,9 +2987,9 @@
     is-arrayish "^0.2.1"
 
 errorstacks@^2.2.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.4.0.tgz#2155674dd9e741aacda3f3b8b967d9c40a4a0baf"
-  integrity sha512-5ecWhU5gt0a5G05nmQcgCxP5HperSMxLDzvWlT5U+ZSKkuDK0rJ3dbCQny6/vSCIXjwrhwSecXBbw1alr295hQ==
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.4.1.tgz#05adf6de1f5b04a66f2c12cc0593e1be2b18cd0f"
+  integrity sha512-jE4i0SMYevwu/xxAuzhly/KTwtj0xDhbzB6m1xPImxTkw8wcCbgarOQPfCVMi5JKVyW7in29pNJCCJrry3Ynnw==
 
 es-dev-server@^1.57.8:
   version "1.60.2"
@@ -3062,9 +3068,9 @@
   integrity sha512-Va0Q/xqtrss45hWzP8CZJwzGSZJjDM5/MJRE3IXXnUCcVLElR9BRaE9F62BopysASyc4nM3uwhSW7FFB9nlWAA==
 
 es-module-lexer@^1.0.0:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.3.1.tgz#c1b0dd5ada807a3b3155315911f364dc4e909db1"
-  integrity sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.4.1.tgz#41ea21b43908fe6a287ffcbe4300f790555331f5"
+  integrity sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==
 
 es-module-shims@^0.4.6:
   version "0.4.7"
@@ -3072,9 +3078,9 @@
   integrity sha512-0LTiSQoPWwdcaTVIQXhGlaDwTneD0g9/tnH1PNs3zHFFH+xoCeJclDM3rQeqF9nurXPfMKm3l9+kfPRa5VpbKg==
 
 es-module-shims@^1.4.1:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/es-module-shims/-/es-module-shims-1.8.0.tgz#c0c8e7f5a0d041998b9410879619ab745cb76a3a"
-  integrity sha512-5l/AqgnWvYFF38qkK8VNoQ8BL3LkJ8bAJuxhOKA/JqoLC4bcaeJeLwMkhEcrDsf5IUCDdwZ6eEG40+Xuh/APcQ==
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/es-module-shims/-/es-module-shims-1.8.2.tgz#54e09c96e1a8e897e55aa9ef3b12d9a818b19b25"
+  integrity sha512-7vIYVzpOhXtpc3Yn03itB+GSgVZFW7oL4kdydA+iL+IEi7HiSLBUxM05QFw4SxTl6e++pMpGqZPo2+vdNs3TbA==
 
 "esbuild@^0.16 || ^0.17":
   version "0.17.19"
@@ -3176,9 +3182,9 @@
   integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
 fast-glob@^3.2.9:
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4"
-  integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
+  integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
   dependencies:
     "@nodelib/fs.stat" "^2.0.2"
     "@nodelib/fs.walk" "^1.2.3"
@@ -3192,9 +3198,9 @@
   integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
 
 fastq@^1.6.0:
-  version "1.15.0"
-  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a"
-  integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==
+  version "1.16.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.16.0.tgz#83b9a9375692db77a822df081edb6a9cf6839320"
+  integrity sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==
   dependencies:
     reusify "^1.0.4"
 
@@ -3266,9 +3272,9 @@
   integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==
 
 follow-redirects@^1.0.0:
-  version "1.15.3"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a"
-  integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==
+  version "1.15.4"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf"
+  integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==
 
 forever-agent@~0.6.1:
   version "0.6.1"
@@ -3318,10 +3324,10 @@
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
   integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
 
-function-bind@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
-  integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
+function-bind@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
+  integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
 
 gensync@^1.0.0-beta.2:
   version "1.0.0-beta.2"
@@ -3333,15 +3339,15 @@
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
-get-intrinsic@^1.0.2:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82"
-  integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==
+get-intrinsic@^1.0.2, get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.2.tgz#281b7622971123e1ef4b3c90fd7539306da93f3b"
+  integrity sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==
   dependencies:
-    function-bind "^1.1.1"
-    has "^1.0.3"
+    function-bind "^1.1.2"
     has-proto "^1.0.1"
     has-symbols "^1.0.3"
+    hasown "^2.0.0"
 
 get-stream@^5.1.0:
   version "5.2.0"
@@ -3410,6 +3416,13 @@
     merge2 "^1.4.1"
     slash "^3.0.0"
 
+gopd@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c"
+  integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==
+  dependencies:
+    get-intrinsic "^1.1.3"
+
 graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.6:
   version "4.2.11"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
@@ -3443,6 +3456,13 @@
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
+has-property-descriptors@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz#52ba30b6c5ec87fd89fa574bc1c39125c6f65340"
+  integrity sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==
+  dependencies:
+    get-intrinsic "^1.2.2"
+
 has-proto@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0"
@@ -3460,12 +3480,12 @@
   dependencies:
     has-symbols "^1.0.2"
 
-has@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
-  integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
+hasown@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c"
+  integrity sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==
   dependencies:
-    function-bind "^1.1.1"
+    function-bind "^1.1.2"
 
 he@1.2.0, he@^1.2.0:
   version "1.2.0"
@@ -3574,14 +3594,14 @@
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
 ignore@^5.2.0:
-  version "5.2.4"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
-  integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78"
+  integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==
 
 inflation@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f"
-  integrity sha512-m3xv4hJYR2oXw4o4Y5l6P5P16WYmazYof+el6Al3f+YlggGj6qT9kImBAnzDelRALnP5d3h4jGBPKzYCizjZZw==
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.1.0.tgz#9214db11a47e6f756d111c4f9df96971c60f886c"
+  integrity sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==
 
 inflight@^1.0.4:
   version "1.0.6"
@@ -3631,11 +3651,11 @@
     builtin-modules "^3.3.0"
 
 is-core-module@^2.13.0:
-  version "2.13.0"
-  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db"
-  integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==
+  version "2.13.1"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384"
+  integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==
   dependencies:
-    has "^1.0.3"
+    hasown "^2.0.0"
 
 is-docker@^2.0.0, is-docker@^2.1.1:
   version "2.2.1"
@@ -3734,9 +3754,9 @@
   integrity sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==
 
 istanbul-lib-coverage@^3.0.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3"
-  integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756"
+  integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==
 
 istanbul-lib-instrument@^3.3.0:
   version "3.3.0"
@@ -3963,15 +3983,15 @@
     koa-send "^5.0.0"
 
 koa@^2.13.0, koa@^2.7.0:
-  version "2.14.2"
-  resolved "https://registry.yarnpkg.com/koa/-/koa-2.14.2.tgz#a57f925c03931c2b4d94b19d2ebf76d3244863fc"
-  integrity sha512-VFI2bpJaodz6P7x2uyLiX6RLYpZmOJqNmoCst/Yyd7hQlszyPwG/I9CQJ63nOtKSxpt5M7NH67V6nJL2BwCl7g==
+  version "2.15.0"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.15.0.tgz#d24ae1b0ff378bf12eb3df584ab4204e4c12ac2b"
+  integrity sha512-KEL/vU1knsoUvfP4MC4/GthpQrY/p6dzwaaGI6Rt4NQuFqkw3qrvsdYF5pz3wOfi7IGTvMPHC9aZIcUKYFNxsw==
   dependencies:
     accepts "^1.3.5"
     cache-content-type "^1.0.0"
     content-disposition "~0.5.2"
     content-type "^1.0.4"
-    cookies "~0.8.0"
+    cookies "~0.9.0"
     debug "^4.3.2"
     delegates "^1.0.0"
     depd "^2.0.0"
@@ -3999,30 +4019,30 @@
     debug "^2.6.9"
     marky "^1.2.2"
 
-lit-element@^3.3.0:
-  version "3.3.3"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.3.3.tgz#10bc19702b96ef5416cf7a70177255bfb17b3209"
-  integrity sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==
+lit-element@^4.0.0:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.0.2.tgz#1a519896d5ab7c7be7a8729f400499e38779c093"
+  integrity sha512-/W6WQZUa5VEXwC7H9tbtDMdSs9aWil3Ou8hU6z2cOKWbsm/tXPAcsoaHVEtrDo0zcOIE5GF6QgU55tlGL2Nihg==
   dependencies:
-    "@lit-labs/ssr-dom-shim" "^1.1.0"
-    "@lit/reactive-element" "^1.3.0"
-    lit-html "^2.8.0"
+    "@lit-labs/ssr-dom-shim" "^1.1.2"
+    "@lit/reactive-element" "^2.0.0"
+    lit-html "^3.1.0"
 
-lit-html@^2.0.0, lit-html@^2.8.0:
-  version "2.8.0"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.8.0.tgz#96456a4bb4ee717b9a7d2f94562a16509d39bffa"
-  integrity sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==
+"lit-html@^2.0.0 || ^3.0.0", lit-html@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.1.0.tgz#a7b93dd682073f2e2029656f4e9cd91e8034c196"
+  integrity sha512-FwAjq3iNsaO6SOZXEIpeROlJLUlrbyMkn4iuv4f4u1H40Jw8wkeR/OUXZUHUoiYabGk8Y4Y0F/rgq+R4MrOLmA==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
-lit@^2.0.0:
-  version "2.8.0"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.8.0.tgz#4d838ae03059bf9cafa06e5c61d8acc0081e974e"
-  integrity sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==
+"lit@^2.0.0 || ^3.0.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-3.1.0.tgz#76429b85dc1f5169fed499a0f7e89e2e619010c9"
+  integrity sha512-rzo/hmUqX8zmOdamDAeydfjsGXbbdtAFqMhmocnh2j9aDYqbu0fjXygjCa0T99Od9VQ/2itwaGrjZz/ZELVl7w==
   dependencies:
-    "@lit/reactive-element" "^1.6.0"
-    lit-element "^3.3.0"
-    lit-html "^2.8.0"
+    "@lit/reactive-element" "^2.0.0"
+    lit-element "^4.0.0"
+    lit-html "^3.1.0"
 
 load-json-file@^4.0.0:
   version "4.0.0"
@@ -4326,9 +4346,9 @@
   integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==
 
 nanoid@^3.1.25:
-  version "3.3.6"
-  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
-  integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
+  version "3.3.7"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
+  integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
 
 negotiator@0.6.3:
   version "0.6.3"
@@ -4336,9 +4356,9 @@
   integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
 
 nise@^5.1.1:
-  version "5.1.4"
-  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.4.tgz#491ce7e7307d4ec546f5a659b2efe94a18b4bbc0"
-  integrity sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==
+  version "5.1.5"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.5.tgz#f2aef9536280b6c18940e32ba1fbdc770b8964ee"
+  integrity sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==
   dependencies:
     "@sinonjs/commons" "^2.0.0"
     "@sinonjs/fake-timers" "^10.0.2"
@@ -4368,10 +4388,10 @@
   dependencies:
     whatwg-url "^5.0.0"
 
-node-releases@^2.0.13:
-  version "2.0.13"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d"
-  integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==
+node-releases@^2.0.14:
+  version "2.0.14"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b"
+  integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==
 
 normalize-package-data@^2.3.2:
   version "2.5.0"
@@ -4399,9 +4419,9 @@
   integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
 
 object-inspect@^1.9.0:
-  version "1.12.3"
-  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
-  integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
+  version "1.13.1"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2"
+  integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==
 
 on-finished@2.4.1, on-finished@^2.3.0:
   version "2.4.1"
@@ -4627,17 +4647,17 @@
   dependencies:
     find-up "^4.0.0"
 
-playwright-core@1.38.0:
-  version "1.38.0"
-  resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.38.0.tgz#cb8e135da1c0b1918b070642372040ed9aa7009a"
-  integrity sha512-f8z1y8J9zvmHoEhKgspmCvOExF2XdcxMW8jNRuX4vkQFrzV4MlZ55iwb5QeyiFQgOFCUolXiRHgpjSEnqvO48g==
+playwright-core@1.40.1:
+  version "1.40.1"
+  resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.40.1.tgz#442d15e86866a87d90d07af528e0afabe4c75c05"
+  integrity sha512-+hkOycxPiV534c4HhpfX6yrlawqVUzITRKwHAmYfmsVreltEl6fAZJ3DPfLMOODw0H3s1Itd6MDCWmP1fl/QvQ==
 
 playwright@^1.22.2:
-  version "1.38.0"
-  resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.38.0.tgz#0ee19d38512b7b1f961c0eb44008a6fed373d206"
-  integrity sha512-fJGw+HO0YY+fU/F1N57DMO+TmXHTrmr905J05zwAQE9xkuwP/QLDk63rVhmyxh03dYnEhnRbsdbH9B0UVVRB3A==
+  version "1.40.1"
+  resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.40.1.tgz#a11bf8dca15be5a194851dbbf3df235b9f53d7ae"
+  integrity sha512-2eHI7IioIpQ0bS1Ovg/HszsN/XKNwEG1kbzSDDmADpclKc7CyqkHw7Mg2JCz/bbCxg25QUPcjksoMW7JcIFQmw==
   dependencies:
-    playwright-core "1.38.0"
+    playwright-core "1.40.1"
   optionalDependencies:
     fsevents "2.3.2"
 
@@ -4705,9 +4725,9 @@
     once "^1.3.1"
 
 punycode@^2.1.0, punycode@^2.1.1:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
-  integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
+  integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
 
 puppeteer-core@^13.1.3:
   version "13.7.0"
@@ -4834,9 +4854,9 @@
   integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
 
 regenerator-runtime@^0.14.0:
-  version "0.14.0"
-  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45"
-  integrity sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==
+  version "0.14.1"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f"
+  integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==
 
 regenerator-transform@^0.15.2:
   version "0.15.2"
@@ -4924,9 +4944,9 @@
     path-is-absolute "1.0.1"
 
 resolve@^1.10.0, resolve@^1.14.2, resolve@^1.19.0:
-  version "1.22.6"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.6.tgz#dd209739eca3aef739c626fea1b4f3c506195362"
-  integrity sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==
+  version "1.22.8"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
+  integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
   dependencies:
     is-core-module "^2.13.0"
     path-parse "^1.0.7"
@@ -5005,6 +5025,16 @@
   dependencies:
     randombytes "^2.1.0"
 
+set-function-length@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.1.1.tgz#4bc39fafb0307224a33e106a7d35ca1218d659ed"
+  integrity sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==
+  dependencies:
+    define-data-property "^1.1.1"
+    get-intrinsic "^1.2.1"
+    gopd "^1.0.1"
+    has-property-descriptors "^1.0.0"
+
 setprototypeof@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
@@ -5128,14 +5158,14 @@
     spdx-license-ids "^3.0.0"
 
 spdx-license-ids@^3.0.0:
-  version "3.0.15"
-  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.15.tgz#142460aabaca062bc7cd4cc87b7d50725ed6a4ba"
-  integrity sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ==
+  version "3.0.16"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz#a14f64e0954f6e25cc6587bd4f392522db0d998f"
+  integrity sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==
 
 sshpk@^1.7.0:
-  version "1.17.0"
-  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5"
-  integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==
+  version "1.18.0"
+  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.18.0.tgz#1663e55cddf4d688b86a46b77f0d5fe363aba028"
+  integrity sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==
   dependencies:
     asn1 "~0.2.3"
     assert-plus "^1.0.0"
@@ -5245,9 +5275,9 @@
   integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
 
 systemjs@^6.3.1, systemjs@^6.8.3:
-  version "6.14.2"
-  resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.14.2.tgz#e289f959f8c8b407403bd39c6abaa16f2c13f316"
-  integrity sha512-1TlOwvKWdXxAY9vba+huLu99zrQURDWA8pUTYsRIYDZYQbGyK+pyEP4h4dlySsqo7ozyJBmYD20F+iUHhAltEg==
+  version "6.14.3"
+  resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.14.3.tgz#c1d6e4ff5f9ff7106e5bb3d451360b1a066bde8a"
+  integrity sha512-hQv45irdhXudAOr8r6SVSpJSGtogdGZUbJBRKCE5nsIS7tsxxvnIHqT4IOPWj+P+HcSzeWzHlGCGpmhPDIKe+w==
 
 table-layout@^1.0.2:
   version "1.0.2"
@@ -5450,14 +5480,14 @@
   integrity sha512-T+tKVNs6Wu7IWiAce5BgMd7OZfNYUndHwc5MknN+UHOudi7sGZzuHdCadllRuqJ3fPtgFtIH9+lt9qRv6lmpfA==
 
 ua-parser-js@^0.7.30:
-  version "0.7.36"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.36.tgz#382c5d6fc09141b6541be2cae446ecfcec284db2"
-  integrity sha512-CPPLoCts2p7D8VbybttE3P2ylv0OBZEAy7a12DsulIEcAiMtWJy+PBgMXgWDI80D5UwqE8oQPHYnk13tm38M2Q==
+  version "0.7.37"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832"
+  integrity sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==
 
 ua-parser-js@^1.0.33:
-  version "1.0.36"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.36.tgz#a9ab6b9bd3a8efb90bb0816674b412717b7c428c"
-  integrity sha512-znuyCIXzl8ciS3+y3fHJI/2OhQIXbXw9MWC/o3qwyR+RGppjZHrM27CGFSKCJXi2Kctiz537iOu2KnXs1lMQhw==
+  version "1.0.37"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f"
+  integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==
 
 unbzip2-stream@1.4.3:
   version "1.4.3"
@@ -5467,6 +5497,11 @@
     buffer "^5.2.1"
     through "^2.3.8"
 
+undici-types@~5.26.4:
+  version "5.26.5"
+  resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
+  integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
+
 unicode-canonical-property-names-ecmascript@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc"
@@ -5500,10 +5535,10 @@
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
   integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
 
-update-browserslist-db@^1.0.11:
-  version "1.0.12"
-  resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.12.tgz#868ce670ac09b4a4d4c86b608701c0dee2dc41cd"
-  integrity sha512-tE1smlR58jxbFMtrMpFNRmsrOXlpNXss965T1CrpwuZUzUAg/TBQc94SpyhDLSzrqrJS9xTRBthnZAGcE1oaxg==
+update-browserslist-db@^1.0.13:
+  version "1.0.13"
+  resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4"
+  integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==
   dependencies:
     escalade "^3.1.1"
     picocolors "^1.0.0"
@@ -5548,13 +5583,13 @@
     source-map "^0.7.3"
 
 v8-to-istanbul@^9.0.1:
-  version "9.1.0"
-  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz#1b83ed4e397f58c85c266a570fc2558b5feb9265"
-  integrity sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==
+  version "9.2.0"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad"
+  integrity sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==
   dependencies:
     "@jridgewell/trace-mapping" "^0.3.12"
     "@types/istanbul-lib-coverage" "^2.0.1"
-    convert-source-map "^1.6.0"
+    convert-source-map "^2.0.0"
 
 valid-url@^1.0.9:
   version "1.0.9"
@@ -5604,9 +5639,9 @@
   integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
 
 whatwg-fetch@^3.0.0, whatwg-fetch@^3.5.0:
-  version "3.6.19"
-  resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.19.tgz#caefd92ae630b91c07345537e67f8354db470973"
-  integrity sha512-d67JP4dHSbm2TrpFj8AbO8DnL1JXL5J9u0Kq2xW6d0TFDbCA3Muhdt8orXC22utleTVj7Prqt82baN6RBvnEgw==
+  version "3.6.20"
+  resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70"
+  integrity sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==
 
 whatwg-url@^11.0.0:
   version "11.0.0"
diff --git a/proto/cache.proto b/proto/cache.proto
index 87ae0e4..09c99df 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -172,12 +172,23 @@
 
   repeated int32 past_reviewer = 12;
 
-  // Next ID: 5
+  // Next ID: 8
   message ReviewerStatusUpdateProto {
     // Epoch millis.
     int64 timestamp_millis = 1;
     int32 updated_by = 2;
+
+    // Account ID of the reviewer.
+    // Not set if a reviewer for which no Gerrit account exists is added by email.
     int32 reviewer = 3;
+    bool has_reviewer = 5;
+
+    // Address of the reviewer by email (can be "Full Name <full.name@example.com>" or
+    // "full.name@example.com").
+    // Only set for reviewers that have no Gerrit account and that have been added by email.
+    string reviewer_by_email = 6;
+    bool has_reviewer_by_email = 7;
+
     string state = 4;
   }
   repeated ReviewerStatusUpdateProto reviewer_update = 13;
@@ -344,6 +355,7 @@
   bool inactive = 6;
   string status = 7;
   string meta_id = 8;
+  string unique_tag = 9;
 }
 
 // Serialized form of com.google.gerrit.server.account.CachedAccountDetails.Key.
diff --git a/proto/entities.proto b/proto/entities.proto
index 3412291..be04e97 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -31,7 +31,7 @@
 }
 
 // Serialized form of com.google.gerrit.entities.Change.
-// Next ID: 25
+// Next ID: 26
 message Change {
   required Change_Id change_id = 1;
   optional Change_Key change_key = 2;
@@ -50,6 +50,7 @@
   optional bool review_started = 22;
   optional Change_Id revert_of = 23;
   optional PatchSet_Id cherry_pick_of = 24;
+  optional string server_id = 25;
 
   // Deleted fields, should not be reused:
   reserved 3;    // row_version
@@ -62,6 +63,31 @@
   reserved 101;  // note_db_state
 }
 
+// Serialized form of com.google.gerrit.extensions.common.ChangeInput.
+// Next ID: 19
+message ChangeInput {
+  optional string project = 1;
+  optional string branch = 2;
+  optional string subject = 3;
+  optional string topic = 4;
+  optional ChangeStatus status = 5;
+  optional bool is_private = 6;
+  optional bool work_in_progress = 7;
+  optional string base_change = 8;
+  optional string base_commit = 9;
+  optional bool new_branch = 10;
+  map<string, string> validation_options = 11;
+  map<string, string> custom_keyed_values = 12;
+  optional MergeInput merge = 13;
+  optional ApplyPatchInput patch = 14;
+  optional AccountInput author = 15;
+  repeated ListChangesOption response_format_options = 16;
+  optional NotifyHandling notify = 17 [default = ALL];
+  // The key is the string representation of the RecipientType enum.
+  // We use a string here because proto does not allow enum keys in maps.
+  map<string, NotifyInfo> notify_details = 18;
+}
+
 // Serialized form of com.google.gerrit.enities.ChangeMessage.
 // Next ID: 3
 message ChangeMessage_Key {
@@ -81,6 +107,97 @@
   optional Account_Id real_author = 7;
 }
 
+// Serialized form of com.google.gerrit.extensions.client.ChangeStatus.
+// Next ID: 3
+enum ChangeStatus {
+ NEW = 0;
+ MERGED = 1;
+ ABANDONED = 2;
+}
+
+// Serialized form of com.google.gerrit.extensions.common.MergeInput.
+// Next ID: 5
+message MergeInput {
+ optional string source = 1;
+ optional string source_branch = 2;
+ optional string strategy = 3;
+ optional bool allow_conflicts = 4;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.changes.ApplyPatchInput.
+// Next ID: 2
+message ApplyPatchInput {
+ optional string patch = 1;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.accounts.AccountInput.
+// Next ID: 8
+message AccountInput {
+ optional string username = 1;
+ optional string name = 2;
+ optional string display_name = 3;
+ optional string email = 4;
+ optional string ssh_key = 5;
+ optional string http_password = 6;
+ repeated string groups = 7;
+}
+
+// Serialized form of com.google.gerrit.extensions.client.ListChangesOption.
+// Next ID: 28
+enum ListChangesOption {
+  LABELS = 0;
+  CURRENT_REVISION = 1;
+  ALL_REVISIONS = 2;
+  CURRENT_COMMIT = 3;
+  ALL_COMMITS = 4;
+  CURRENT_FILES = 5;
+  ALL_FILES = 6;
+  DETAILED_ACCOUNTS = 7;
+  DETAILED_LABELS = 8;
+  MESSAGES = 9;
+  CURRENT_ACTIONS = 10;
+  REVIEWED = 11;
+  DRAFT_COMMENTS = 12;
+  DOWNLOAD_COMMANDS = 13;
+  WEB_LINKS = 14;
+  CHECK = 15;
+  CHANGE_ACTIONS = 16;
+  COMMIT_FOOTERS = 17;
+  PUSH_CERTIFICATES = 18;
+  REVIEWER_UPDATES = 19;
+  SUBMITTABLE = 20;
+  TRACKING_IDS = 21;
+  SKIP_MERGEABLE = 22;
+  SKIP_DIFFSTAT = 23;
+  SUBMIT_REQUIREMENTS = 24;
+  CUSTOM_KEYED_VALUES = 25;
+  STAR = 26;
+  PARENTS = 27;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.changes.NotifyHandling.
+// Next ID: 4
+enum NotifyHandling {
+  NONE = 0;
+  OWNER = 1;
+  OWNER_REVIEWERS = 2;
+  ALL = 3;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.changes.RecipientType.
+// Next ID: 3
+enum RecipientType {
+  TO = 0;
+  CC = 1;
+  BCC = 2;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.changes.NotifyInfo.
+// Next ID: 2
+message NotifyInfo {
+  repeated string accounts = 1;
+}
+
 // Serialized form of com.google.gerrit.entities.PatchSet.Id.
 // Next ID: 3
 message PatchSet_Id {
@@ -311,3 +428,64 @@
   }
   optional EditPreferencesInfo edit_preferences_info = 3;
 }
+
+// Next Id: 13
+message HumanComment {
+  // Required. Note that the equivalent Java struct does not contain the change
+  // ID, so we keep the same format here.
+  optional int32 patchset_id = 1;
+  optional ObjectId dest_commit_id = 2;
+  // Required.
+  optional Account_Id account_id = 3;
+  optional Account_Id real_author = 4;
+
+  // Next Id: 5
+  message InFilePosition {
+    optional string file_path = 1;
+    enum Side {
+      // Should match the logic in
+      // http://google3/third_party/java_src/gerritcodereview/gerrit/java/com/google/gerrit/extensions/client/Side.java?rcl=579772037&l=24
+      PARENT = 0;
+      REVISION = 1;
+    }
+    // Default should match
+    // http://google3/third_party/java_src/gerritcodereview/gerrit/Documentation/rest-api-changes.txt?l=7423
+    optional Side side = 2 [default = REVISION];
+    message Range {
+      // 1-based
+      optional int32 start_line = 1;
+      // 0-based
+      optional int32 start_char = 2;
+      // 1-based
+      optional int32 end_line = 3;
+      // 0-based
+      optional int32 end_char = 4;
+    }
+    // If neither range nor line number set, the comment is on the file level. It is possible
+    // (though not required) for both values to be set. in this case, it is expected that the line
+    // number is identical to the range's end line.
+    optional Range position_range = 3;
+    // 1-based
+    optional int32 line_number = 4;
+  }
+
+  // If not set, the comment is on the patchset level.
+  optional InFilePosition in_file_position = 5;
+
+  // Required.
+  optional string comment_text = 6;
+  // Might be set by the user while creating the draft.
+  // See http://go/gerrit-rest-api-change#comment-info.
+  optional string tag = 7;
+  optional bool unresolved = 8 [default = false];
+
+  // Required.
+  optional string comment_uuid = 9;
+  // Required.
+  optional string parent_comment_uuid = 10;
+
+  // Required. Epoch millis.
+  optional fixed64 written_on_millis = 11;
+  // Required.
+  optional string server_id = 12;
+}
\ No newline at end of file
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index b748ba5..5ff1822 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -57,7 +57,7 @@
       {/if}
     {rb};
     window.PRELOADED_QUERIES = {lb}
-      {if $userIsAuthenticated and $defaultDashboardHex and $dashboardQuery}
+      {if $userIsAuthenticated && $defaultDashboardHex && $dashboardQuery}
         dashboardQuery: [{for $query in $dashboardQuery}{$query},{/for}],
       {/if}
     {rb};
@@ -103,7 +103,7 @@
       <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts?enable-context=true&context-padding=3" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     {/if}
   {/if}
-  {if $userIsAuthenticated and $defaultDashboardHex and $dashboardQuery}
+  {if $userIsAuthenticated && $defaultDashboardHex && $dashboardQuery}
     <link rel="preload" href="{$canonicalPath}/changes/?O={$defaultDashboardHex}&S=0{for $query in $dashboardQuery}&q={$query}{/for}&allow-incomplete-results=true" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
   {/if}
 
@@ -147,7 +147,7 @@
   // CC them on any changes that load content before gr-app.js.
   //
   // github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
-  {if $assetsPath and $assetsBundle}
+  {if $assetsPath && $assetsBundle}
     <link rel="import" href="{$assetsPath}/{$assetsBundle}">{\n}
   {/if}
 
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index c537a89..8434a8e 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -146,9 +146,9 @@
   fi
 }
 
-function test_suppress_squash {
+function suppress_squash_like {
   cat << EOF > input
-squash! bla bla
+$1! bla bla
 EOF
 
   ${hook} input || fail "failed hook execution"
@@ -158,6 +158,30 @@
   fi
 }
 
+function test_suppress_squash {
+  # test for standard git prefixes
+  suppress_squash_like squash
+  suppress_squash_like fixup
+  suppress_squash_like amend
+  # test for custom prefixes
+  suppress_squash_like temp
+  suppress_squash_like nopush
+}
+
+function test_always_create {
+  cat << EOF > input
+squash! bla bla
+EOF
+
+  git config gerrit.createChangeId always
+  ${hook} input || fail "failed hook execution"
+  git config --unset gerrit.createChangeId
+  found=$(grep -c '^Change-Id' input || true)
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Change-Ids, want 1"
+  fi
+}
+
 # gerrit.reviewUrl causes us to create Link instead of Change-Id.
 function test_link {
   cat << EOF > input
diff --git a/resources/com/google/gerrit/server/mail/ChangeFooter.soy b/resources/com/google/gerrit/server/mail/ChangeFooter.soy
index c5b9e4a..2910537 100644
--- a/resources/com/google/gerrit/server/mail/ChangeFooter.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeFooter.soy
@@ -34,7 +34,7 @@
     visit {$email.settingsUrl}{\n}
   {/if}
 
-  {if $email.changeUrl or $email.settingsUrl}
+  {if $email.changeUrl || $email.settingsUrl}
     {\n}
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy b/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
index 5a22acb..62358ee 100644
--- a/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
@@ -19,13 +19,13 @@
 {template ChangeFooterHtml}
   {@param change: ?}
   {@param email: ?}
-  {if $email.changeUrl or $email.settingsUrl}
+  {if $email.changeUrl || $email.settingsUrl}
     <p>
       {if $email.changeUrl}
         To view, visit{sp}
         <a href="{$email.changeUrl}">change {$change.changeNumber}</a>.
       {/if}
-      {if $email.changeUrl and $email.settingsUrl}{sp}{/if}
+      {if $email.changeUrl && $email.settingsUrl}{sp}{/if}
       {if $email.settingsUrl}
         To unsubscribe, or for help writing mail filters,{sp}
         visit <a href="{$email.settingsUrl}">settings</a>.
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeader.soy b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
index 12b68b6..cf1eb93 100644
--- a/resources/com/google/gerrit/server/mail/ChangeHeader.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
@@ -17,8 +17,8 @@
 {namespace com.google.gerrit.server.mail.template.ChangeHeader}
 
 {template ChangeHeader kind="text"}
-  {@param attentionSet: list<string>|null}
-  {if $attentionSet and length($attentionSet) > 0}
+  {@param? attentionSet: list<string>|null}
+  {if $attentionSet && length($attentionSet) > 0}
     Attention is currently required from:{sp}
     {for $attentionSetUser, $index in $attentionSet}
       {$attentionSetUser}
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
index e17e021..4b6a158 100644
--- a/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
@@ -18,8 +18,8 @@
 {namespace com.google.gerrit.server.mail.template.ChangeHeaderHtml}
 
 {template ChangeHeaderHtml}
-  {@param attentionSet: list<string>|null}
-  {if $attentionSet and length($attentionSet) > 0}
+  {@param? attentionSet: list<string>|null}
+  {if $attentionSet && length($attentionSet) > 0}
     <p> Attention is currently required from:{sp}
     {for $attentionSetUser, $index in $attentionSet}
       {$attentionSetUser}
diff --git a/resources/com/google/gerrit/server/mail/ChangeSubject.soy b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
index ba422a4..2603797 100644
--- a/resources/com/google/gerrit/server/mail/ChangeSubject.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
@@ -27,7 +27,7 @@
   {@param instanceAndProjectName: ?}
   {@param addInstanceNameInSubject: ?}    /** boolean */
 
-  {if not $addInstanceNameInSubject}
+  {if !$addInstanceNameInSubject}
     [{$change.sizeBucket}] Change in {$shortProjectName}[{$branch.shortName}]: {$change
     .shortSubject}
   {else}
diff --git a/resources/com/google/gerrit/server/mail/NewChange.soy b/resources/com/google/gerrit/server/mail/NewChange.soy
index c5f34b4..7e2af28 100644
--- a/resources/com/google/gerrit/server/mail/NewChange.soy
+++ b/resources/com/google/gerrit/server/mail/NewChange.soy
@@ -27,7 +27,7 @@
   {@param ownerName: ?}
   {@param patchSet: ?}
   {@param projectName: ?}
-  {if $email.reviewerNames or $email.removedReviewerNames}
+  {if $email.reviewerNames || $email.removedReviewerNames}
    {if $email.reviewerNames}
       Hello{sp}
       {for $reviewerName, $index in $email.reviewerNames}
diff --git a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index 008226f..fac3c3d 100644
--- a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -26,7 +26,7 @@
   {@param patchSet: ?}
   {@param projectName: ?}
   <p>
-    {if $email.reviewerNames or $email.removedReviewerNames}
+    {if $email.reviewerNames || $email.removedReviewerNames}
       {if $email.reviewerNames}
         {$fromName} would like{sp}
         {for $reviewerName, $index in $email.reviewerNames}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
index 6ae8625..9b9d1ad 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -30,7 +30,7 @@
   {@param unsatisfiedSubmitRequirements: ?}
   {@param oldSubmitRequirements: ?}
   {@param newSubmitRequirements: ?}
-  {if $email.reviewerNames and $fromEmail == $change.ownerEmail}
+  {if $email.reviewerNames && $fromEmail == $change.ownerEmail}
     Hello{sp}
     {for $reviewerName in $email.reviewerNames}
       {$reviewerName},{sp}
@@ -53,7 +53,7 @@
     {/if}.
     {if $email.changeUrl} ( {$email.changeUrl} ){/if}
   {/if}{\n}
-  {if $email.outdatedApprovals and length($email.outdatedApprovals) > 0}
+  {if $email.outdatedApprovals && length($email.outdatedApprovals) > 0}
     {\n}
     The following approvals got outdated and were removed:{\n}
     {for $outdatedApproval, $index in $email.outdatedApprovals}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
index 1d99591..30a5dbf 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -44,7 +44,7 @@
     </p>
   {/if}
 
-  {if $email.outdatedApprovals and length($email.outdatedApprovals) > 0}
+  {if $email.outdatedApprovals && length($email.outdatedApprovals) > 0}
     <p>
       The following approvals got outdated and were removed:{\n}
       {for $outdatedApproval, $index in $email.outdatedApprovals}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 2626059..642ef474 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -17,6 +17,7 @@
 bazel = text/x-python
 c = text/x-csrc
 cfg = text/x-ttcn-cfg
+cjs = text/javascript
 cl = text/x-common-lisp
 clj = text/x-clojure
 cljs = text/x-clojurescript
@@ -36,6 +37,7 @@
 csharp = text/x-csharp
 csproj = application/xml
 css = text/css
+cts = application/typescript
 cpp = text/x-c++src
 cql = text/x-cassandra
 cxx = text/x-c++src
@@ -122,6 +124,7 @@
 kts = text/x-kotlin
 less = text/x-less
 lhs = text/x-literate-haskell
+LICENSE = text/plain
 lisp = text/x-common-lisp
 list = text/plain
 log = text/plain
@@ -148,6 +151,7 @@
 mscgen = text/x-mscgen
 mscin = text/x-mscgen
 msgenny = text/x-msgenny
+mts = application/typescript
 nb = text/x-mathematica
 nginx.conf = text/x-nginx-conf
 nsh = text/x-nsis
@@ -189,6 +193,9 @@
 pxi = text/x-cython
 PKGBUILD = text/x-sh
 q = text/x-q
+qml = text/x-qml
+qmlproject = text/x-qml
+qrc = application/xml
 r = text/r-src
 rake = text/x-ruby
 rb = text/x-ruby
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index 5c7dffa..13aa86c 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -30,14 +30,20 @@
 fi
 
 # Do not create a change id if requested
-if test "false" = "$(git config --bool --get gerrit.createChangeId)" ; then
-  exit 0
-fi
+case "$(git config --get gerrit.createChangeId)" in
+  false)
+    exit 0
+    ;;
+  always)
+    ;;
+  *)
+    # Do not create a change id for squash/fixup commits.
+    if head -n1 "$1" | LC_ALL=C grep -q '^[a-z][a-z]*! '; then
+      exit 0
+    fi
+    ;;
+esac
 
-# Do not create a change id for squash commits.
-if head -n1 "$1" | grep -q '^squash! '; then
-  exit 0
-fi
 
 if git rev-parse --verify HEAD >/dev/null 2>&1; then
   refhash="$(git rev-parse HEAD)"
diff --git a/tools/BUILD b/tools/BUILD
index cb25c47..71ad096 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -18,17 +18,20 @@
     visibility = ["//visibility:public"],
 )
 
-default_java_toolchain(
-    name = "error_prone_warnings_toolchain_java17",
+[default_java_toolchain(
+    name = "error_prone_warnings_toolchain_java" + VERSION,
     configuration = dict(),
-    java_runtime = "@bazel_tools//tools/jdk:remotejdk_17",
+    java_runtime = "@rules_java//toolchains:remotejdk_" + VERSION,
     package_configuration = [
         ":error_prone",
     ],
-    source_version = "17",
-    target_version = "17",
+    source_version = VERSION,
+    target_version = VERSION,
     visibility = ["//visibility:public"],
-)
+) for VERSION in [
+    "17",
+    "21",
+]]
 
 # Error Prone errors enabled by default; see ../.bazelrc for how this is
 # enabled. This warnings list is originally based on:
@@ -76,7 +79,7 @@
         "-Xep:BadImport:ERROR",
         "-Xep:BadInstanceof:ERROR",
         "-Xep:BadShiftAmount:ERROR",
-        "-Xep:BanJNDI:OFF",
+        "-Xep:BanJNDI:WARN",
         "-Xep:BanSerializableRead:ERROR",
         "-Xep:BigDecimalEquals:ERROR",
         "-Xep:BigDecimalLiteralDouble:ERROR",
@@ -159,7 +162,7 @@
         "-Xep:FloatingPointLiteralPrecision:ERROR",
         "-Xep:FloggerArgumentToString:ERROR",
         "-Xep:FloggerFormatString:ERROR",
-        "-Xep:FloggerLogString:OFF",
+        "-Xep:FloggerLogString:WARN",
         "-Xep:FloggerLogVarargs:ERROR",
         "-Xep:FloggerSplitLogStatement:ERROR",
         "-Xep:FloggerStringConcatenation:ERROR",
@@ -202,7 +205,7 @@
         "-Xep:InjectOnConstructorOfAbstractClass:ERROR",
         "-Xep:InheritDoc:ERROR",
         "-Xep:InlineFormatString:ERROR",
-        "-Xep:InlineMeInliner:OFF",
+        "-Xep:InlineMeInliner:WARN",
         "-Xep:InlineMeSuggester:ERROR",
         "-Xep:InlineMeValidator:ERROR",
         "-Xep:InputStreamSlowMultibyteRead:ERROR",
@@ -290,7 +293,8 @@
         "-Xep:MultipleParallelOrSequentialCalls:ERROR",
         "-Xep:MultipleUnaryOperatorsInMethodCall:ERROR",
         "-Xep:MustBeClosedChecker:ERROR",
-        "-Xep:MutableConstantField:OFF",
+        "-Xep:MutableConstantField:WARN",
+        "-Xep:MutableGuiceModule:ERROR",
         "-Xep:MutablePublicArray:ERROR",
         "-Xep:NCopiesOfChar:ERROR",
         "-Xep:NarrowingCompoundAssignment:ERROR",
@@ -332,6 +336,7 @@
         "-Xep:PeriodTimeMath:ERROR",
         "-Xep:PreconditionsCheckNotNullRepeated:ERROR",
         "-Xep:PreconditionsInvalidPlaceholder:ERROR",
+        "-Xep:PreferredInterfaceType:ERROR",
         "-Xep:PrimitiveAtomicReference:ERROR",
         "-Xep:PrivateSecurityContractProtoAccess:ERROR",
         "-Xep:ProtectedMembersInFinalClass:ERROR",
@@ -343,7 +348,7 @@
         "-Xep:ProtoTimestampGetSecondsGetNano:ERROR",
         "-Xep:ProtoTruthMixedDescriptors:ERROR",
         "-Xep:ProtocolBufferOrdinal:ERROR",
-        "-Xep:ProvidesMethodOutsideOfModule:OFF",
+        "-Xep:ProvidesMethodOutsideOfModule:WARN",
         "-Xep:RandomCast:ERROR",
         "-Xep:RandomModInteger:ERROR",
         "-Xep:ReachabilityFenceUsage:ERROR",
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 9e515e5..5bacec8 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -1,6 +1,10 @@
+"""
+Build rules for plugins.
+"""
+
+load("//:version.bzl", "GERRIT_VERSION")
 load("@rules_java//java:defs.bzl", "java_binary", "java_library")
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//:version.bzl", "GERRIT_VERSION")
 
 IN_TREE_BUILD_MODE = True
 
diff --git a/tools/defs.bzl b/tools/defs.bzl
new file mode 100644
index 0000000..ff207b3
--- /dev/null
+++ b/tools/defs.bzl
@@ -0,0 +1,13 @@
+load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps")
+
+def gerrit_init():
+    """
+    Initialize the WORKSPACE for gerrit targets
+    """
+    protobuf_deps()
+
+    native.register_toolchains("//tools:error_prone_warnings_toolchain_java11_definition")
+
+    native.register_toolchains("//tools:error_prone_warnings_toolchain_java17_definition")
+
+    native.register_toolchains("//tools:error_prone_warnings_toolchain_java21_definition")
diff --git a/tools/deps.bzl b/tools/deps.bzl
index 43e8d2a..d056483 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -1,3 +1,7 @@
+"""
+This module lists the external dependencies of the Gerrit project.
+"""
+
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "maven_jar")
 
 CAFFEINE_VERS = "2.9.2"
@@ -9,21 +13,24 @@
 OW2_VERS = "9.2"
 AUTO_COMMON_VERSION = "1.2.1"
 AUTO_FACTORY_VERSION = "1.0.1"
-AUTO_VALUE_VERSION = "1.7.4"
+AUTO_VALUE_VERSION = "1.10.4"
 AUTO_VALUE_GSON_VERSION = "1.3.1"
 PROLOG_VERS = "1.4.4"
 PROLOG_REPO = GERRIT
-GITILES_VERS = "1.3.0"
+GITILES_VERS = "1.4.0"
 GITILES_REPO = GERRIT
 
 # When updating Bouncy Castle, also update it in bazlets.
 BC_VERS = "1.72"
-HTTPCOMP_VERS = "4.5.2"
+HTTPCOMP_VERS = "4.5.14"
 JETTY_VERS = "9.4.53.v20231009"
 BYTE_BUDDY_VERSION = "1.14.9"
 ROARING_BITMAP_VERSION = "0.9.44"
 
 def java_dependencies():
+    """
+    This method lists the maven jars used in the Gerrit project.
+    """
     maven_jar(
         name = "java-runtime",
         artifact = "org.antlr:antlr-runtime:" + ANTLR_VERS,
@@ -296,13 +303,13 @@
     maven_jar(
         name = "auto-value",
         artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
-        sha1 = "6b126cb218af768339e4d6e95a9b0ae41f74e73d",
+        sha1 = "90f9629eaa123f88551cc26a64bc386967ee24cc",
     )
 
     maven_jar(
         name = "auto-value-annotations",
         artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
-        sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
+        sha1 = "9679de8286eb0a151db6538ba297a8951c4a1224",
     )
 
     maven_jar(
@@ -391,14 +398,14 @@
         artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
         attach_source = False,
         repository = GITILES_REPO,
-        sha1 = "d0f5c98207648503b225501e84f529fa88651ebe",
+        sha1 = "005e9a8cfcfc15f232c796dbf1c8fb5499abff9c",
     )
 
     maven_jar(
         name = "gitiles-servlet",
         artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
         repository = GITILES_REPO,
-        sha1 = "b4ce5bc26e6a2674728d0d3c72c21e0b3443666d",
+        sha1 = "6fa0fe70154d09799ff7dc616727fec7342bb755",
     )
 
     maven_jar(
@@ -446,19 +453,19 @@
     maven_jar(
         name = "fluent-hc",
         artifact = "org.apache.httpcomponents:fluent-hc:" + HTTPCOMP_VERS,
-        sha1 = "7bfdfa49de6d720ad3c8cedb6a5238eec564dfed",
+        sha1 = "81a16abc0d5acb5016d5b46d4b197b53c3d6eb93",
     )
 
     maven_jar(
         name = "httpclient",
         artifact = "org.apache.httpcomponents:httpclient:" + HTTPCOMP_VERS,
-        sha1 = "733db77aa8d9b2d68015189df76ab06304406e50",
+        sha1 = "1194890e6f56ec29177673f2f12d0b8e627dec98",
     )
 
     maven_jar(
         name = "httpcore",
-        artifact = "org.apache.httpcomponents:httpcore:4.4.4",
-        sha1 = "b31526a230871fbe285fbcbe2813f9c0839ae9b0",
+        artifact = "org.apache.httpcomponents:httpcore:4.4.16",
+        sha1 = "51cf043c87253c9f58b539c9f7e44c8894223850",
     )
 
     # Test-only dependencies below.
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 9e54e7f..a3f4d8f 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -238,8 +238,7 @@
             if p.endswith('libquery_parser.jar') or \
                p.endswith('libgerrit-prolog-common.jar') or \
                p.endswith('external/com_google_protobuf/java/core/libcore.jar') or \
-               p.endswith('external/com_google_protobuf/java/core/liblite.jar') or \
-               p.endswith('lucene-core-and-backward-codecs-merged_deploy.jar'):
+               p.endswith('external/com_google_protobuf/java/core/liblite.jar'):
                 lib.add(p)
             if proto_library.match(p) :
                 proto.add(p)
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 7907c57..49b8edf 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.9.4-SNAPSHOT</version>
+  <version>3.10.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index d5f864d..ac3dbac 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.9.4-SNAPSHOT</version>
+  <version>3.10.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 73df557..b3408d1 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.9.4-SNAPSHOT</version>
+  <version>3.10.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index d222fc7..2f72352 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.9.4-SNAPSHOT</version>
+  <version>3.10.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index cb200dc..ac3f668 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -1,13 +1,56 @@
+"""
+Dependencies that are exempted from requiring a Library-Compliance approval
+from a Googler.
+"""
+
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
 load("//tools/bzl:maven_jar.bzl", "maven_jar")
 
-GUAVA_VERSION = "32.1.2-jre"
+GUAVA_VERSION = "33.0.0-jre"
 
-GUAVA_BIN_SHA1 = "5e64ec7e056456bef3a4bc4c6fdaef71e8ab6318"
+GUAVA_BIN_SHA1 = "161ba27964a62f241533807a46b8711b13c1d94b"
 
-GUAVA_TESTLIB_BIN_SHA1 = "c7a8a2c91b6809ff46373b1bc06185241801f6b5"
+GUAVA_TESTLIB_BIN_SHA1 = "cf21e00fcc92786094fb5b376500f50d06878b0b"
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
 
+def archive_dependencies():
+    return [
+        {
+            "name": "com_google_protobuf",
+            "sha256": "9bd87b8280ef720d3240514f884e56a712f2218f0d693b48050c836028940a42",
+            "strip_prefix": "protobuf-25.1",
+            "urls": [
+                "https://github.com/protocolbuffers/protobuf/archive/v25.1.tar.gz",
+            ],
+        },
+        {
+            "name": "platforms",
+            "urls": [
+                "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
+                "https://github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
+            ],
+            "sha256": "3a561c99e7bdbe9173aa653fd579fe849f1d8d67395780ab4770b1f381431d51",
+        },
+        {
+            "name": "rules_java",
+            "urls": [
+                "https://github.com/bazelbuild/rules_java/releases/download/7.3.1/rules_java-7.3.1.tar.gz",
+            ],
+            "sha256": "4018e97c93f97680f1650ffd2a7530245b864ac543fd24fae8c02ba447cb2864",
+        },
+        {
+            "name": "ubuntu2204_jdk17",
+            "strip_prefix": "rbe_autoconfig-5.1.0",
+            "urls": [
+                "https://gerrit-bazel.storage.googleapis.com/rbe_autoconfig/v5.1.0.tar.gz",
+                "https://github.com/davido/rbe_autoconfig/releases/download/v5.1.0/v5.1.0.tar.gz",
+            ],
+            "sha256": "8ea82b81c9707e535ff93ef5349d11e55b2a23c62bcc3b0faaec052144aed87d",
+        },
+    ]
+
 def declare_nongoogle_deps():
     """loads dependencies that are not used at Google.
 
@@ -16,10 +59,15 @@
     enforced by //lib:nongoogle_test.
     """
 
+    for dependency in archive_dependencies():
+        params = {}
+        params.update(**dependency)
+        maybe(http_archive, params.pop("name"), **params)
+
     maven_jar(
         name = "log4j",
-        artifact = "ch.qos.reload4j:reload4j:1.2.19",
-        sha1 = "4eae9978468c5e885a6fb44df7e2bbc07a20e6ce",
+        artifact = "ch.qos.reload4j:reload4j:1.2.25",
+        sha1 = "45921e383a1001c2a599fc4c6cf59af80cdd1cf1",
     )
 
     SLF4J_VERS = "1.7.36"
@@ -200,8 +248,8 @@
     # Keep this version of Soy synchronized with the version used in Gitiles.
     maven_jar(
         name = "soy",
-        artifact = "com.google.template:soy:2022-07-20",
-        sha1 = "f64eb90da6d91beddf11653865c90f26d26710cf",
+        artifact = "com.google.template:soy:2024-01-30",
+        sha1 = "6e9ccb00926325c7a9293ed05a2eaf56ea15d60e",
     )
 
     # Test-only dependencies below.
@@ -223,62 +271,62 @@
         sha1 = "48462eb319817c90c27d377341684b6b81372e08",
     )
 
-    TRUTH_VERS = "1.1"
+    TRUTH_VERS = "1.4.2"
 
     maven_jar(
         name = "truth",
         artifact = "com.google.truth:truth:" + TRUTH_VERS,
-        sha1 = "6a096a16646559c24397b03f797d0c9d75ee8720",
+        sha1 = "2322d861290bd84f84cbb178e43539725a4588fd",
     )
 
     maven_jar(
         name = "truth-java8-extension",
         artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-        sha1 = "258db6eb8df61832c5c059ed2bc2e1c88683e92f",
+        sha1 = "bfa44a01e1bb5a1df50bc9c678d6588b4d9eb73a",
     )
 
     maven_jar(
         name = "truth-liteproto-extension",
         artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
-        sha1 = "bf65afa13aa03330e739bcaa5d795fe0f10fbf20",
+        sha1 = "062a2716b3b0ba9d8e72c913dad43a8139b12202",
     )
 
     maven_jar(
         name = "truth-proto-extension",
         artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
-        sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
+        sha1 = "53cfc94dfa435c5dcd6f8b6844b82b423ea0a5af",
     )
 
-    LUCENE_VERS = "8.11.2"
+    LUCENE_VERS = "9.8.0"
 
     maven_jar(
         name = "lucene-core",
         artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
-        sha1 = "57438c3f31e0e440de149294890eee88e030ea6d",
+        sha1 = "5e8421c5f8573bcf22e9265fc7e19469545a775a",
     )
 
     maven_jar(
         name = "lucene-analyzers-common",
-        artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
-        sha1 = "07a74c5c2dd082b08c644a9016bc6ff66c8f27cc",
+        artifact = "org.apache.lucene:lucene-analysis-common:" + LUCENE_VERS,
+        sha1 = "36f0363325ca7bf62c180160d1ed5165c7c37795",
     )
 
     maven_jar(
-        name = "backward-codecs",
+        name = "lucene-backward-codecs",
         artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
-        sha1 = "a5d0f0db405d607cc13265819b8d2ef0c81c0819",
+        sha1 = "e98fb408028f40170e6d87c16422bfdc0bb2e392",
     )
 
     maven_jar(
         name = "lucene-misc",
         artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
-        sha1 = "9c7204f923465a96a20ac9e49cdca0cfcde64851",
+        sha1 = "9a57b049cf51a5e9c9c1909c420f645f1b6f9a54",
     )
 
     maven_jar(
         name = "lucene-queryparser",
         artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
-        sha1 = "1886e3a27a8d4a73eb8fad54ea93a160b099bc60",
+        sha1 = "982faf2bfa55542bf57fbadef54c19ac00f57cae",
     )
 
     # JGit's transitive dependencies
diff --git a/tools/platforms/Dockerfile b/tools/platforms/Dockerfile
deleted file mode 100644
index 157529c..0000000
--- a/tools/platforms/Dockerfile
+++ /dev/null
@@ -1,21 +0,0 @@
-# Copyright (C) 2021 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-FROM gcr.io/cloud-marketplace/google/rbe-ubuntu18-04:latest
-
-# Install Git >=2.18.0
-RUN add-apt-repository ppa:git-core/ppa && \
-    apt-get -y update && \
-    apt-get -y install git && \
-    apt-get clean
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc
index d8da574..d2a18bc 100644
--- a/tools/remote-bazelrc
+++ b/tools/remote-bazelrc
@@ -31,11 +31,11 @@
 
 # Set several flags related to specifying the platform, toolchain and java
 # properties.
-build:remote_shared --crosstool_top=@rbe_jdk11//cc:toolchain
-build:remote_shared --extra_toolchains=@rbe_jdk11//config:cc-toolchain
-build:remote_shared --extra_execution_platforms=@rbe_jdk11//config:platform
-build:remote_shared --host_platform=@rbe_jdk11//config:platform
-build:remote_shared --platforms=@rbe_jdk11//config:platform
+build:remote_shared --crosstool_top=@ubuntu2204_jdk17//cc:toolchain
+build:remote_shared --extra_toolchains=@ubuntu2204_jdk17//config:cc-toolchain
+build:remote_shared --extra_execution_platforms=@ubuntu2204_jdk17//config:platform
+build:remote_shared --host_platform=@ubuntu2204_jdk17//config:platform
+build:remote_shared --platforms=@ubuntu2204_jdk17//config:platform
 build:remote_shared --action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
 
 # Set various strategies so that all actions execute remotely. Mixing remote
diff --git a/tools/rules_nodejs-5.8.4-node_versions.bzl.patch b/tools/rules_nodejs-5.8.4-node_versions.bzl.patch
new file mode 100644
index 0000000..7df62d6
--- /dev/null
+++ b/tools/rules_nodejs-5.8.4-node_versions.bzl.patch
@@ -0,0 +1,17 @@
+diff --git a/nodejs/private/node_versions.bzl b/nodejs/private/node_versions.bzl
+index bbb45b26..8758b3cc 100644
+--- a/nodejs/private/node_versions.bzl
++++ b/nodejs/private/node_versions.bzl
+@@ -2311,4 +2311,12 @@ NODE_VERSIONS = {
+     "18.17.0-linux_s390x": ("node-v18.17.0-linux-s390x.tar.xz", "node-v18.17.0-linux-s390x", "876ca54c246d24e346d0c740fbb72c9fb7353369127f20492bc923ee6d0121db"),
+     "18.17.0-linux_amd64": ("node-v18.17.0-linux-x64.tar.xz", "node-v18.17.0-linux-x64", "f36facda28c4d5ce76b3a1b4344e688d29d9254943a47f2f1909b1a10acb1959"),
+     "18.17.0-windows_amd64": ("node-v18.17.0-win-x64.zip", "node-v18.17.0-win-x64", "06e30b4e70b18d794651ef132c39080e5eaaa1187f938721d57edae2824f4e96"),
++    # 20.9.0
++    "20.9.0-darwin_arm64": ("node-v20.9.0-darwin-arm64.tar.gz", "node-v20.9.0-darwin-arm64", "31d2d46ae8d8a3982f54e2ff1e60c2e4a8e80bf78a3e8b46dcaac95ac5d7ce6a"),
++    "20.9.0-darwin_amd64": ("node-v20.9.0-darwin-x64.tar.gz", "node-v20.9.0-darwin-x64", "fc5b73f2a78c17bbe926cdb1447d652f9f094c79582f1be6471b4b38a2e1ccc8"),
++    "20.9.0-linux_arm64": ("node-v20.9.0-linux-arm64.tar.xz", "node-v20.9.0-linux-arm64", "ced3ecece4b7c3a664bca3d9e34a0e3b9a31078525283a6fdb7ea2de8ca5683b"),
++    "20.9.0-linux_ppc64le": ("node-v20.9.0-linux-ppc64le.tar.xz", "node-v20.9.0-linux-ppc64le", "3c6cea5d614cfbb95d92de43fbc2f8ecd66e431502fe5efc4f3c02637897bd45"),
++    "20.9.0-linux_s390x": ("node-v20.9.0-linux-s390x.tar.xz", "node-v20.9.0-linux-s390x", "af1f4e63756ff685d452166c4d5ba93a308e816ee7c46015b5e086163d9f011b"),
++    "20.9.0-linux_amd64": ("node-v20.9.0-linux-x64.tar.xz", "node-v20.9.0-linux-x64", "9033989810bf86220ae46b1381bdcdc6c83a0294869ba2ad39e1061f1e69217a"),
++    "20.9.0-windows_amd64": ("node-v20.9.0-win-x64.zip", "node-v20.9.0-win-x64", "70d87dad2378c63216ff83d5a754c61d2886fc39d32ce0d2ea6de763a22d3780"),
+ }
diff --git a/tools/setup_gjf.sh b/tools/setup_gjf.sh
index 119f9af..8e2b57b 100755
--- a/tools/setup_gjf.sh
+++ b/tools/setup_gjf.sh
@@ -32,6 +32,9 @@
 1.7)
     SHA1="b6d34a51e579b08db7c624505bdf9af4397f1702"
     ;;
+1.22.0)
+    SHA1="693d8fd04656886a2287cfe1d7a118c4697c3a57"
+    ;;
 *)
     echo "unknown google-java-format version: $VERSION"
     exit 1
@@ -48,7 +51,7 @@
 mkdir -p "$dir"
 
 name="google-java-format-$VERSION-all-deps.jar"
-url="https://github.com/google/google-java-format/releases/download/google-java-format-$VERSION/$name"
+url="https://github.com/google/google-java-format/releases/download/v$VERSION/$name"
 "$root/tools/download_file.py" -o "$dir/$name" -u "$url" -v "$SHA1"
 
 launcher="$dir/google-java-format-$VERSION"
diff --git a/tools/util.py b/tools/util.py
index 947e2c0..aed9e91 100644
--- a/tools/util.py
+++ b/tools/util.py
@@ -16,8 +16,10 @@
 
 REPO_ROOTS = {
   'ECLIPSE': 'https://repo.eclipse.org/content/groups/releases',
+  'ECLIPSE_EGIT': 'https://repo.eclipse.org/content/repositories/egit-releases',
   'GERRIT': 'https://gerrit-maven.storage.googleapis.com',
   'GERRIT_API': 'https://gerrit-api.commondatastorage.googleapis.com/release',
+  'JENKINS': 'https://repo.jenkins-ci.org/artifactory/public',
   'MAVEN_CENTRAL': 'https://repo1.maven.org/maven2',
   'MAVEN_LOCAL': 'file://' + path.expanduser('~/.m2/repository'),
   'MAVEN_SNAPSHOT': 'https://oss.sonatype.org/content/repositories/snapshots',
diff --git a/version.bzl b/version.bzl
index e44c314..23ccefb 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.9.4-SNAPSHOT"
+GERRIT_VERSION = "3.10.0-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
index 5702d39..6d62a5e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -16,11 +16,11 @@
   integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==
 
 "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.11":
-  version "7.22.13"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
-  integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
+  version "7.23.5"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244"
+  integrity sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==
   dependencies:
-    "@babel/highlight" "^7.22.13"
+    "@babel/highlight" "^7.23.4"
     chalk "^2.4.2"
 
 "@babel/helper-validator-identifier@^7.22.20":
@@ -28,19 +28,19 @@
   resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
   integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
 
-"@babel/highlight@^7.22.13":
-  version "7.22.20"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
-  integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
+"@babel/highlight@^7.23.4":
+  version "7.23.4"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz#edaadf4d8232e1a961432db785091207ead0621b"
+  integrity sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==
   dependencies:
     "@babel/helper-validator-identifier" "^7.22.20"
     chalk "^2.4.2"
     js-tokens "^4.0.0"
 
 "@babel/runtime@^7.10.2":
-  version "7.22.15"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8"
-  integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==
+  version "7.23.7"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.7.tgz#dd7c88deeb218a0f8bd34d5db1aa242e0f203193"
+  integrity sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA==
   dependencies:
     regenerator-runtime "^0.14.0"
 
@@ -209,14 +209,14 @@
     eslint-visitor-keys "^3.3.0"
 
 "@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1":
-  version "4.8.1"
-  resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.8.1.tgz#8c4bb756cc2aa7eaf13cfa5e69c83afb3260c20c"
-  integrity sha512-PWiOzLIUAjN/w5K17PoF4n6sKBw0gqLHPhywmYHP4t1VFQQVYeb1yWsJwnMVEMl3tUHME7X/SJPZLmtG7XBDxQ==
+  version "4.10.0"
+  resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63"
+  integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==
 
-"@eslint/eslintrc@^2.1.2":
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.2.tgz#c6936b4b328c64496692f76944e755738be62396"
-  integrity sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==
+"@eslint/eslintrc@^2.1.4":
+  version "2.1.4"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad"
+  integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==
   dependencies:
     ajv "^6.12.4"
     debug "^4.3.2"
@@ -228,17 +228,17 @@
     minimatch "^3.1.2"
     strip-json-comments "^3.1.1"
 
-"@eslint/js@8.49.0":
-  version "8.49.0"
-  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.49.0.tgz#86f79756004a97fa4df866835093f1df3d03c333"
-  integrity sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w==
+"@eslint/js@8.56.0":
+  version "8.56.0"
+  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b"
+  integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==
 
-"@humanwhocodes/config-array@^0.11.11":
-  version "0.11.11"
-  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.11.tgz#88a04c570dbbc7dd943e4712429c3df09bc32844"
-  integrity sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==
+"@humanwhocodes/config-array@^0.11.13":
+  version "0.11.13"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297"
+  integrity sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==
   dependencies:
-    "@humanwhocodes/object-schema" "^1.2.1"
+    "@humanwhocodes/object-schema" "^2.0.1"
     debug "^4.1.1"
     minimatch "^3.0.5"
 
@@ -247,10 +247,10 @@
   resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
   integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
 
-"@humanwhocodes/object-schema@^1.2.1":
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
-  integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
+"@humanwhocodes/object-schema@^2.0.1":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz#e5211452df060fa8522b55c7b3c0c4d1981cb044"
+  integrity sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==
 
 "@koa/cors@^3.4.3":
   version "3.4.3"
@@ -373,41 +373,41 @@
     picomatch "^2.2.2"
 
 "@types/accepts@*":
-  version "1.3.5"
-  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
-  integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==
+  version "1.3.7"
+  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.7.tgz#3b98b1889d2b2386604c2bbbe62e4fb51e95b265"
+  integrity sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==
   dependencies:
     "@types/node" "*"
 
 "@types/body-parser@*":
-  version "1.19.3"
-  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.3.tgz#fb558014374f7d9e56c8f34bab2042a3a07d25cd"
-  integrity sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==
+  version "1.19.5"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4"
+  integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==
   dependencies:
     "@types/connect" "*"
     "@types/node" "*"
 
 "@types/command-line-args@^5.0.0":
-  version "5.2.1"
-  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.1.tgz#233bd1ba687e84ecbec0388e09f9ec9ebf63c55b"
-  integrity sha512-U2OcmS2tj36Yceu+mRuPyUV0ILfau/h5onStcSCzqTENsq0sBiAp2TmaXu1k8fY4McLcPKSYl9FRzn2hx5bI+w==
+  version "5.2.3"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.3.tgz#553ce2fd5acf160b448d307649b38ffc60d39639"
+  integrity sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==
 
 "@types/connect@*":
-  version "3.4.36"
-  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.36.tgz#e511558c15a39cb29bd5357eebb57bd1459cd1ab"
-  integrity sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==
+  version "3.4.38"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858"
+  integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==
   dependencies:
     "@types/node" "*"
 
 "@types/content-disposition@*":
-  version "0.5.6"
-  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.6.tgz#0f5fa03609f308a7a1a57e0b0afe4b95f1d19740"
-  integrity sha512-GmShTb4qA9+HMPPaV2+Up8tJafgi38geFi7vL4qAM7k8BwjoelgHZqEUKJZLvughUw22h6vD/wvwN4IUCaWpDA==
+  version "0.5.8"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.8.tgz#6742a5971f490dc41e59d277eee71361fea0b537"
+  integrity sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==
 
 "@types/cookies@*":
-  version "0.7.8"
-  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.8.tgz#16fccd6d58513a9833c527701a90cc96d216bc18"
-  integrity sha512-y6KhF1GtsLERUpqOV+qZJrjUGzc0GE6UTa0b5Z/LZ7Nm2mKSdCXmS6Kdnl7fctPNnMSouHjxqEWI12/YqQfk5w==
+  version "0.7.10"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.10.tgz#c4881dca4dd913420c488508d192496c46eb4fd0"
+  integrity sha512-hmUCjAk2fwZVPPkkPBcI7jGLIR5mg4OVoNMBwU6aVsMm/iNPY7z9/R+x2fSwLt/ZXoGua6C5Zy2k5xOo9jUyhQ==
   dependencies:
     "@types/connect" "*"
     "@types/express" "*"
@@ -420,9 +420,9 @@
   integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
 
 "@types/express-serve-static-core@^4.17.33":
-  version "4.17.36"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.36.tgz#baa9022119bdc05a4adfe740ffc97b5f9360e545"
-  integrity sha512-zbivROJ0ZqLAtMzgzIUC4oNqDG9iF0lSsAqpOD9kbs5xcIM3dTiyuHvBc7R8MtWBp3AAWGaovJa+wzWPjLYW7Q==
+  version "4.17.41"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz#5077defa630c2e8d28aa9ffc2c01c157c305bef6"
+  integrity sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
@@ -430,9 +430,9 @@
     "@types/send" "*"
 
 "@types/express@*":
-  version "4.17.17"
-  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4"
-  integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d"
+  integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==
   dependencies:
     "@types/body-parser" "*"
     "@types/express-serve-static-core" "^4.17.33"
@@ -440,19 +440,19 @@
     "@types/serve-static" "*"
 
 "@types/http-assert@*":
-  version "1.5.3"
-  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661"
-  integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==
+  version "1.5.5"
+  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.5.tgz#dfb1063eb7c240ee3d3fe213dac5671cfb6a8dbf"
+  integrity sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g==
 
 "@types/http-errors@*":
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.2.tgz#a86e00bbde8950364f8e7846687259ffcd96e8c2"
-  integrity sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f"
+  integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==
 
 "@types/json-schema@^7.0.9":
-  version "7.0.13"
-  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.13.tgz#02c24f4363176d2d18fc8b70b9f3c54aba178a85"
-  integrity sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==
+  version "7.0.15"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
+  integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
 
 "@types/json5@^0.0.29":
   version "0.0.29"
@@ -460,21 +460,21 @@
   integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
 
 "@types/keygrip@*":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.3.tgz#2286b16ef71d8dea74dab00902ef419a54341bfe"
-  integrity sha512-tfzBBb7OV2PbUfKbG6zRE5UbmtdLVCKT/XT364Z9ny6pXNbd9GnIB6aFYpq2A5lZ6mq9bhXgK6h5MFGNwhMmuQ==
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.6.tgz#1749535181a2a9b02ac04a797550a8787345b740"
+  integrity sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==
 
 "@types/koa-compose@*":
-  version "3.2.6"
-  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.6.tgz#17a077786d0ac5eee04c37a7d6c207b3252f6de9"
-  integrity sha512-PHiciWxH3NRyAaxUdEDE1NIZNfvhgtPlsdkjRPazHC6weqt90Jr0uLhIQs+SDwC8HQ/jnA7UQP6xOqGFB7ugWw==
+  version "3.2.8"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.8.tgz#dec48de1f6b3d87f87320097686a915f1e954b57"
+  integrity sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==
   dependencies:
     "@types/koa" "*"
 
 "@types/koa@*", "@types/koa@^2.11.6":
-  version "2.13.9"
-  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.9.tgz#8d989ac17d7f033475fbe34c4f906c9287c2041a"
-  integrity sha512-tPX3cN1dGrMn+sjCDEiQqXH2AqlPoPd594S/8zxwUm/ZbPsQXKqHPUypr2gjCPhHUc+nDJLduhh5lXI/1olnGQ==
+  version "2.13.12"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.12.tgz#70d87a9061a81909e0ee11ca50168416e8d3e795"
+  integrity sha512-vAo1KuDSYWFDB4Cs80CHvfmzSQWeUb909aQib0C0aFx4sw0K9UZFz2m5jaEP+b3X1+yr904iQiruS0hXi31jbw==
   dependencies:
     "@types/accepts" "*"
     "@types/content-disposition" "*"
@@ -491,24 +491,26 @@
   integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==
 
 "@types/mime@*":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
-  integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45"
+  integrity sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==
 
 "@types/mime@^1":
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
-  integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690"
+  integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==
 
 "@types/minimist@^1.2.0":
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c"
-  integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e"
+  integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==
 
 "@types/node@*":
-  version "20.6.3"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.3.tgz#5b763b321cd3b80f6b8dde7a37e1a77ff9358dd9"
-  integrity sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA==
+  version "20.10.6"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.10.6.tgz#a3ec84c22965802bf763da55b2394424f22bfbb5"
+  integrity sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==
+  dependencies:
+    undici-types "~5.26.4"
 
 "@types/node@^10.1.0":
   version "10.17.60"
@@ -516,14 +518,14 @@
   integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==
 
 "@types/normalize-package-data@^2.4.0":
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
-  integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
+  version "2.4.4"
+  resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901"
+  integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==
 
-"@types/page@^1.11.6":
-  version "1.11.6"
-  resolved "https://registry.yarnpkg.com/@types/page/-/page-1.11.6.tgz#d531dc26067ca6a52e785db54c65b9095b9d5b84"
-  integrity sha512-oOJysLQSd7rY3aqnEuPui0zJGQDQbwuwclwn9FQQVVome/U4oF2XK1SDp/1kWXhVlohey0zVr2LdV17y5fdLhg==
+"@types/page@^1.11.9":
+  version "1.11.9"
+  resolved "https://registry.yarnpkg.com/@types/page/-/page-1.11.9.tgz#a596cbcbb24bbe8a4292d56ac96c23895f7d44e5"
+  integrity sha512-Ki8IZMwg63i7+tF3UpfDIl4rwBN1B1kWQjZCUzaWoohfMB0m9CYap/dExbz7W21uS2WPoA/8lvlDuwX0X/YfIQ==
 
 "@types/parse5@^6.0.1":
   version "6.0.3"
@@ -531,14 +533,14 @@
   integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
 
 "@types/qs@*":
-  version "6.9.8"
-  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.8.tgz#f2a7de3c107b89b441e071d5472e6b726b4adf45"
-  integrity sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==
+  version "6.9.11"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.11.tgz#208d8a30bc507bd82e03ada29e4732ea46a6bbda"
+  integrity sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==
 
 "@types/range-parser@*":
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
-  integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
+  integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
 
 "@types/resolve@1.17.1":
   version "1.17.1"
@@ -548,22 +550,22 @@
     "@types/node" "*"
 
 "@types/semver@^7.3.12":
-  version "7.5.2"
-  resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.2.tgz#31f6eec1ed7ec23f4f05608d3a2d381df041f564"
-  integrity sha512-7aqorHYgdNO4DM36stTiGO3DvKoex9TQRwsJU6vMaFGyqpBA1MNZkz+PG3gaNUPpTAOYhT1WR7M1JyA3fbS9Cw==
+  version "7.5.6"
+  resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339"
+  integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==
 
 "@types/send@*":
-  version "0.17.1"
-  resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.1.tgz#ed4932b8a2a805f1fe362a70f4e62d0ac994e301"
-  integrity sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==
+  version "0.17.4"
+  resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a"
+  integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==
   dependencies:
     "@types/mime" "^1"
     "@types/node" "*"
 
 "@types/serve-static@*":
-  version "1.15.2"
-  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.2.tgz#3e5419ecd1e40e7405d34093f10befb43f63381a"
-  integrity sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==
+  version "1.15.5"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.5.tgz#15e67500ec40789a1e8c9defc2d32a896f05b033"
+  integrity sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==
   dependencies:
     "@types/http-errors" "*"
     "@types/mime" "*"
@@ -660,6 +662,11 @@
     "@typescript-eslint/types" "5.62.0"
     eslint-visitor-keys "^3.3.0"
 
+"@ungap/structured-clone@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
+  integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==
+
 "@web/config-loader@^0.1.3":
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/@web/config-loader/-/config-loader-0.1.3.tgz#8325ea54f75ef2ee7166783e64e66936db25bff7"
@@ -756,9 +763,9 @@
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
 
 acorn@^8.9.0:
-  version "8.10.0"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5"
-  integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==
+  version "8.11.3"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a"
+  integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==
 
 ajv@^6.12.4:
   version "6.12.6"
@@ -847,7 +854,7 @@
     call-bind "^1.0.2"
     is-array-buffer "^3.0.1"
 
-array-includes@^3.1.6:
+array-includes@^3.1.7:
   version "3.1.7"
   resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.7.tgz#8cd2e01b26f7a3086cbc87271593fe921c62abda"
   integrity sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==
@@ -868,7 +875,7 @@
   resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
   integrity sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==
 
-array.prototype.findlastindex@^1.2.2:
+array.prototype.findlastindex@^1.2.3:
   version "1.2.3"
   resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz#b37598438f97b579166940814e2c0493a4f50207"
   integrity sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==
@@ -879,7 +886,7 @@
     es-shim-unscopables "^1.0.0"
     get-intrinsic "^1.2.1"
 
-array.prototype.flat@^1.3.1:
+array.prototype.flat@^1.3.2:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz#1476217df8cff17d72ee8f3ba06738db5b387d18"
   integrity sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==
@@ -889,7 +896,7 @@
     es-abstract "^1.22.1"
     es-shim-unscopables "^1.0.0"
 
-array.prototype.flatmap@^1.3.1:
+array.prototype.flatmap@^1.3.2:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz#c9a7c6831db8e719d6ce639190146c24bbd3e527"
   integrity sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==
@@ -1026,13 +1033,14 @@
     mime-types "^2.1.18"
     ylru "^1.2.0"
 
-call-bind@^1.0.0, call-bind@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
-  integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==
+call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.4, call-bind@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.5.tgz#6fa2b7845ce0ea49bf4d8b9ef64727a2c2e2e513"
+  integrity sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==
   dependencies:
-    function-bind "^1.1.1"
-    get-intrinsic "^1.0.2"
+    function-bind "^1.1.2"
+    get-intrinsic "^1.2.1"
+    set-function-length "^1.1.1"
 
 call-me-maybe@^1.0.1:
   version "1.0.2"
@@ -1211,9 +1219,9 @@
   integrity sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==
 
 component-emitter@^1.2.1:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
-  integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17"
+  integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==
 
 concat-map@0.0.1:
   version "0.0.1"
@@ -1232,10 +1240,10 @@
   resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
   integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
 
-cookies@~0.8.0:
-  version "0.8.0"
-  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
-  integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==
+cookies@~0.9.0:
+  version "0.9.1"
+  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.9.1.tgz#3ffed6f60bb4fb5f146feeedba50acc418af67e3"
+  integrity sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==
   dependencies:
     depd "~2.0.0"
     keygrip "~1.1.0"
@@ -1324,10 +1332,10 @@
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
   integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
 
-define-data-property@^1.0.1:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.0.tgz#0db13540704e1d8d479a0656cf781267531b9451"
-  integrity sha512-UzGwzcjyv3OtAvolTj1GoyNYzfFR+iqbGjcnBEENZVCpM4/Ng1yhGNvS3lR/xDS74Tb2wGG9WzNSNIOS9UVb2g==
+define-data-property@^1.0.1, define-data-property@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.1.tgz#c35f7cd0ab09883480d12ac5cb213715587800b3"
+  integrity sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==
   dependencies:
     get-intrinsic "^1.2.1"
     gopd "^1.0.1"
@@ -1338,7 +1346,7 @@
   resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
   integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
 
-define-properties@^1.1.3, define-properties@^1.1.4, define-properties@^1.2.0:
+define-properties@^1.1.3, define-properties@^1.2.0, define-properties@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c"
   integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==
@@ -1477,25 +1485,25 @@
     is-arrayish "^0.2.1"
 
 es-abstract@^1.22.1:
-  version "1.22.2"
-  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.22.2.tgz#90f7282d91d0ad577f505e423e52d4c1d93c1b8a"
-  integrity sha512-YoxfFcDmhjOgWPWsV13+2RNjq1F6UQnfs+8TftwNqtzlmFzEXvlUwdrNrYeaizfjQzRMxkZ6ElWMOJIFKdVqwA==
+  version "1.22.3"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.22.3.tgz#48e79f5573198de6dee3589195727f4f74bc4f32"
+  integrity sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==
   dependencies:
     array-buffer-byte-length "^1.0.0"
     arraybuffer.prototype.slice "^1.0.2"
     available-typed-arrays "^1.0.5"
-    call-bind "^1.0.2"
+    call-bind "^1.0.5"
     es-set-tostringtag "^2.0.1"
     es-to-primitive "^1.2.1"
     function.prototype.name "^1.1.6"
-    get-intrinsic "^1.2.1"
+    get-intrinsic "^1.2.2"
     get-symbol-description "^1.0.0"
     globalthis "^1.0.3"
     gopd "^1.0.1"
-    has "^1.0.3"
     has-property-descriptors "^1.0.0"
     has-proto "^1.0.1"
     has-symbols "^1.0.3"
+    hasown "^2.0.0"
     internal-slot "^1.0.5"
     is-array-buffer "^3.0.2"
     is-callable "^1.2.7"
@@ -1505,7 +1513,7 @@
     is-string "^1.0.7"
     is-typed-array "^1.1.12"
     is-weakref "^1.0.2"
-    object-inspect "^1.12.3"
+    object-inspect "^1.13.1"
     object-keys "^1.1.1"
     object.assign "^4.1.4"
     regexp.prototype.flags "^1.5.1"
@@ -1519,28 +1527,28 @@
     typed-array-byte-offset "^1.0.0"
     typed-array-length "^1.0.4"
     unbox-primitive "^1.0.2"
-    which-typed-array "^1.1.11"
+    which-typed-array "^1.1.13"
 
 es-module-lexer@^1.0.0:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.3.1.tgz#c1b0dd5ada807a3b3155315911f364dc4e909db1"
-  integrity sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.4.1.tgz#41ea21b43908fe6a287ffcbe4300f790555331f5"
+  integrity sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==
 
 es-set-tostringtag@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz#338d502f6f674301d710b80c8592de8a15f09cd8"
-  integrity sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz#11f7cc9f63376930a5f20be4915834f4bc74f9c9"
+  integrity sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==
   dependencies:
-    get-intrinsic "^1.1.3"
-    has "^1.0.3"
+    get-intrinsic "^1.2.2"
     has-tostringtag "^1.0.0"
+    hasown "^2.0.0"
 
 es-shim-unscopables@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241"
-  integrity sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz#1f6942e71ecc7835ed1c8a83006d8771a63a3763"
+  integrity sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==
   dependencies:
-    has "^1.0.3"
+    hasown "^2.0.0"
 
 es-to-primitive@^1.2.1:
   version "1.2.1"
@@ -1604,7 +1612,7 @@
   resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-7.2.0.tgz#f4a4bd2832e810e8cc7c1411ec85b3e85c0c53f9"
   integrity sha512-rV4Qu0C3nfJKPOAhFujFxB7RMP+URFyQqqOZW9DMRD7ZDTFyjaIlETU3xzHELt++4ugC0+Jm084HQYkkJe+Ivg==
 
-eslint-import-resolver-node@^0.3.7:
+eslint-import-resolver-node@^0.3.9:
   version "0.3.9"
   resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz#d4eaac52b8a2e7c3cd1903eb00f7e053356118ac"
   integrity sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==
@@ -1635,28 +1643,28 @@
   dependencies:
     htmlparser2 "^8.0.1"
 
-eslint-plugin-import@^2.28.1:
-  version "2.28.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.28.1.tgz#63b8b5b3c409bfc75ebaf8fb206b07ab435482c4"
-  integrity sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==
+eslint-plugin-import@^2.29.1:
+  version "2.29.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz#d45b37b5ef5901d639c15270d74d46d161150643"
+  integrity sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==
   dependencies:
-    array-includes "^3.1.6"
-    array.prototype.findlastindex "^1.2.2"
-    array.prototype.flat "^1.3.1"
-    array.prototype.flatmap "^1.3.1"
+    array-includes "^3.1.7"
+    array.prototype.findlastindex "^1.2.3"
+    array.prototype.flat "^1.3.2"
+    array.prototype.flatmap "^1.3.2"
     debug "^3.2.7"
     doctrine "^2.1.0"
-    eslint-import-resolver-node "^0.3.7"
+    eslint-import-resolver-node "^0.3.9"
     eslint-module-utils "^2.8.0"
-    has "^1.0.3"
-    is-core-module "^2.13.0"
+    hasown "^2.0.0"
+    is-core-module "^2.13.1"
     is-glob "^4.0.3"
     minimatch "^3.1.2"
-    object.fromentries "^2.0.6"
-    object.groupby "^1.0.0"
-    object.values "^1.1.6"
+    object.fromentries "^2.0.7"
+    object.groupby "^1.0.1"
+    object.values "^1.1.7"
     semver "^6.3.1"
-    tsconfig-paths "^3.14.2"
+    tsconfig-paths "^3.15.0"
 
 eslint-plugin-jsdoc@^44.2.7:
   version "44.2.7"
@@ -1672,10 +1680,10 @@
     semver "^7.5.1"
     spdx-expression-parse "^3.0.1"
 
-eslint-plugin-lit@^1.9.1:
-  version "1.9.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-lit/-/eslint-plugin-lit-1.9.1.tgz#40cdd1f8d1b565eb5e913eab159c88f6f947bb19"
-  integrity sha512-XFFVufVxYJwqRB9sLkDXB7SvV1xi9hrC4HRFEdX1h9+iZ3dm4x9uS7EuT9uaXs6zR3DEgcojia1F7pmvWbc4Gg==
+eslint-plugin-lit@^1.11.0:
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-lit/-/eslint-plugin-lit-1.11.0.tgz#32fc1c58b476e5b9aa1c7b6ba9de295641bd4e9b"
+  integrity sha512-jVqy2juQTAtOzj1ILf+ZW5GpDobXlSw0kvpP2zu2r8ZbW7KISt7ikj1Gw9DhNeirEU1UlSJR0VIWpdr4lzjayw==
   dependencies:
     parse5 "^6.0.1"
     parse5-htmlparser2-tree-adapter "^6.0.1"
@@ -1745,18 +1753,19 @@
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
   integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
 
-eslint@^7.10.0, eslint@^8.49.0:
-  version "8.49.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.49.0.tgz#09d80a89bdb4edee2efcf6964623af1054bf6d42"
-  integrity sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==
+eslint@^7.10.0, eslint@^8.49.0, eslint@^8.56.0:
+  version "8.56.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.56.0.tgz#4957ce8da409dc0809f99ab07a1b94832ab74b15"
+  integrity sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==
   dependencies:
     "@eslint-community/eslint-utils" "^4.2.0"
     "@eslint-community/regexpp" "^4.6.1"
-    "@eslint/eslintrc" "^2.1.2"
-    "@eslint/js" "8.49.0"
-    "@humanwhocodes/config-array" "^0.11.11"
+    "@eslint/eslintrc" "^2.1.4"
+    "@eslint/js" "8.56.0"
+    "@humanwhocodes/config-array" "^0.11.13"
     "@humanwhocodes/module-importer" "^1.0.1"
     "@nodelib/fs.walk" "^1.2.8"
+    "@ungap/structured-clone" "^1.2.0"
     ajv "^6.12.4"
     chalk "^4.0.0"
     cross-spawn "^7.0.2"
@@ -1925,9 +1934,9 @@
     micromatch "^3.1.10"
 
 fast-glob@^3.2.2, fast-glob@^3.2.9:
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4"
-  integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
+  integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
   dependencies:
     "@nodelib/fs.stat" "^2.0.2"
     "@nodelib/fs.walk" "^1.2.3"
@@ -1946,9 +1955,9 @@
   integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
 
 fastq@^1.6.0:
-  version "1.15.0"
-  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a"
-  integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==
+  version "1.16.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.16.0.tgz#83b9a9375692db77a822df081edb6a9cf6839320"
+  integrity sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==
   dependencies:
     reusify "^1.0.4"
 
@@ -2007,15 +2016,15 @@
     path-exists "^4.0.0"
 
 flat-cache@^3.0.4:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.1.0.tgz#0e54ab4a1a60fe87e2946b6b00657f1c99e1af3f"
-  integrity sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee"
+  integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==
   dependencies:
-    flatted "^3.2.7"
+    flatted "^3.2.9"
     keyv "^4.5.3"
     rimraf "^3.0.2"
 
-flatted@^3.2.7:
+flatted@^3.2.9:
   version "3.2.9"
   resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf"
   integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==
@@ -2054,10 +2063,10 @@
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
   integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
 
-function-bind@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
-  integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
+function-bind@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
+  integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
 
 function.prototype.name@^1.1.6:
   version "1.1.6"
@@ -2079,15 +2088,15 @@
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
-get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0, get-intrinsic@^1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82"
-  integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==
+get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.2.tgz#281b7622971123e1ef4b3c90fd7539306da93f3b"
+  integrity sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==
   dependencies:
-    function-bind "^1.1.1"
-    has "^1.0.3"
+    function-bind "^1.1.2"
     has-proto "^1.0.1"
     has-symbols "^1.0.3"
+    hasown "^2.0.0"
 
 get-stream@^6.0.0:
   version "6.0.1"
@@ -2147,9 +2156,9 @@
     path-is-absolute "^1.0.0"
 
 globals@^13.19.0:
-  version "13.22.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-13.22.0.tgz#0c9fcb9c48a2494fbb5edbfee644285543eba9d8"
-  integrity sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw==
+  version "13.24.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171"
+  integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==
   dependencies:
     type-fest "^0.20.2"
 
@@ -2236,11 +2245,11 @@
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
 has-property-descriptors@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861"
-  integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz#52ba30b6c5ec87fd89fa574bc1c39125c6f65340"
+  integrity sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==
   dependencies:
-    get-intrinsic "^1.1.1"
+    get-intrinsic "^1.2.2"
 
 has-proto@^1.0.1:
   version "1.0.1"
@@ -2290,12 +2299,12 @@
     is-number "^3.0.0"
     kind-of "^4.0.0"
 
-has@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
-  integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
+hasown@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c"
+  integrity sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==
   dependencies:
-    function-bind "^1.1.1"
+    function-bind "^1.1.2"
 
 hosted-git-info@^2.1.4:
   version "2.8.9"
@@ -2361,9 +2370,9 @@
     safer-buffer ">= 2.1.2 < 3"
 
 ignore@^5.1.1, ignore@^5.2.0:
-  version "5.2.4"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
-  integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78"
+  integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==
 
 import-fresh@^3.2.1:
   version "3.3.0"
@@ -2421,12 +2430,12 @@
     through "^2.3.6"
 
 internal-slot@^1.0.5:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.5.tgz#f2a2ee21f668f8627a4667f309dc0f4fb6674986"
-  integrity sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.6.tgz#37e756098c4911c5e912b8edbf71ed3aa116f930"
+  integrity sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==
   dependencies:
-    get-intrinsic "^1.2.0"
-    has "^1.0.3"
+    get-intrinsic "^1.2.2"
+    hasown "^2.0.0"
     side-channel "^1.0.4"
 
 ip@^1.1.5:
@@ -2434,19 +2443,12 @@
   resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
   integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==
 
-is-accessor-descriptor@^0.1.6:
-  version "0.1.6"
-  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
-  integrity sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==
+is-accessor-descriptor@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz#3223b10628354644b86260db29b3e693f5ceedd4"
+  integrity sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==
   dependencies:
-    kind-of "^3.0.2"
-
-is-accessor-descriptor@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656"
-  integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==
-  dependencies:
-    kind-of "^6.0.0"
+    hasown "^2.0.0"
 
 is-array-buffer@^3.0.1, is-array-buffer@^3.0.2:
   version "3.0.2"
@@ -2501,26 +2503,19 @@
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
   integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==
 
-is-core-module@^2.13.0, is-core-module@^2.5.0:
-  version "2.13.0"
-  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db"
-  integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==
+is-core-module@^2.13.0, is-core-module@^2.13.1, is-core-module@^2.5.0:
+  version "2.13.1"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384"
+  integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==
   dependencies:
-    has "^1.0.3"
+    hasown "^2.0.0"
 
-is-data-descriptor@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
-  integrity sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==
+is-data-descriptor@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.1.tgz#2109164426166d32ea38c405c1e0945d9e6a4eeb"
+  integrity sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==
   dependencies:
-    kind-of "^3.0.2"
-
-is-data-descriptor@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7"
-  integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==
-  dependencies:
-    kind-of "^6.0.0"
+    hasown "^2.0.0"
 
 is-date-object@^1.0.1:
   version "1.0.5"
@@ -2530,22 +2525,20 @@
     has-tostringtag "^1.0.0"
 
 is-descriptor@^0.1.0:
-  version "0.1.6"
-  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca"
-  integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.7.tgz#2727eb61fd789dcd5bdf0ed4569f551d2fe3be33"
+  integrity sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==
   dependencies:
-    is-accessor-descriptor "^0.1.6"
-    is-data-descriptor "^0.1.4"
-    kind-of "^5.0.0"
+    is-accessor-descriptor "^1.0.1"
+    is-data-descriptor "^1.0.1"
 
 is-descriptor@^1.0.0, is-descriptor@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec"
-  integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.3.tgz#92d27cb3cd311c4977a4db47df457234a13cb306"
+  integrity sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==
   dependencies:
-    is-accessor-descriptor "^1.0.0"
-    is-data-descriptor "^1.0.0"
-    kind-of "^6.0.2"
+    is-accessor-descriptor "^1.0.1"
+    is-data-descriptor "^1.0.1"
 
 is-docker@^2.0.0, is-docker@^2.1.1:
   version "2.2.1"
@@ -2800,9 +2793,9 @@
     tsscmp "1.0.6"
 
 keyv@^4.5.3:
-  version "4.5.3"
-  resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.3.tgz#00873d2b046df737963157bd04f294ca818c9c25"
-  integrity sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==
+  version "4.5.4"
+  resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
+  integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
   dependencies:
     json-buffer "3.0.1"
 
@@ -2820,12 +2813,7 @@
   dependencies:
     is-buffer "^1.1.5"
 
-kind-of@^5.0.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
-  integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==
-
-kind-of@^6.0.0, kind-of@^6.0.2, kind-of@^6.0.3:
+kind-of@^6.0.2, kind-of@^6.0.3:
   version "6.0.3"
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
   integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
@@ -2868,15 +2856,15 @@
     koa-send "^5.0.0"
 
 koa@^2.13.0:
-  version "2.14.2"
-  resolved "https://registry.yarnpkg.com/koa/-/koa-2.14.2.tgz#a57f925c03931c2b4d94b19d2ebf76d3244863fc"
-  integrity sha512-VFI2bpJaodz6P7x2uyLiX6RLYpZmOJqNmoCst/Yyd7hQlszyPwG/I9CQJ63nOtKSxpt5M7NH67V6nJL2BwCl7g==
+  version "2.15.0"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.15.0.tgz#d24ae1b0ff378bf12eb3df584ab4204e4c12ac2b"
+  integrity sha512-KEL/vU1knsoUvfP4MC4/GthpQrY/p6dzwaaGI6Rt4NQuFqkw3qrvsdYF5pz3wOfi7IGTvMPHC9aZIcUKYFNxsw==
   dependencies:
     accepts "^1.3.5"
     cache-content-type "^1.0.0"
     content-disposition "~0.5.2"
     content-type "^1.0.4"
-    cookies "~0.8.0"
+    cookies "~0.9.0"
     debug "^4.3.2"
     delegates "^1.0.0"
     depd "^2.0.0"
@@ -3257,10 +3245,10 @@
     define-property "^0.2.5"
     kind-of "^3.0.3"
 
-object-inspect@^1.12.3, object-inspect@^1.9.0:
-  version "1.12.3"
-  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
-  integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
+object-inspect@^1.13.1, object-inspect@^1.9.0:
+  version "1.13.1"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2"
+  integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==
 
 object-keys@^1.1.1:
   version "1.1.1"
@@ -3275,16 +3263,16 @@
     isobject "^3.0.0"
 
 object.assign@^4.1.4:
-  version "4.1.4"
-  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f"
-  integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==
+  version "4.1.5"
+  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0"
+  integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==
   dependencies:
-    call-bind "^1.0.2"
-    define-properties "^1.1.4"
+    call-bind "^1.0.5"
+    define-properties "^1.2.1"
     has-symbols "^1.0.3"
     object-keys "^1.1.1"
 
-object.fromentries@^2.0.6:
+object.fromentries@^2.0.7:
   version "2.0.7"
   resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.7.tgz#71e95f441e9a0ea6baf682ecaaf37fa2a8d7e616"
   integrity sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==
@@ -3293,7 +3281,7 @@
     define-properties "^1.2.0"
     es-abstract "^1.22.1"
 
-object.groupby@^1.0.0:
+object.groupby@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.1.tgz#d41d9f3c8d6c778d9cbac86b4ee9f5af103152ee"
   integrity sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==
@@ -3310,7 +3298,7 @@
   dependencies:
     isobject "^3.0.1"
 
-object.values@^1.1.6:
+object.values@^1.1.7:
   version "1.1.7"
   resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.7.tgz#617ed13272e7e1071b43973aa1655d9291b8442a"
   integrity sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==
@@ -3564,9 +3552,9 @@
     long "^4.0.0"
 
 punycode@^2.1.0, punycode@^2.1.1:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
-  integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
+  integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
 
 queue-microtask@^1.2.2:
   version "1.2.3"
@@ -3622,9 +3610,9 @@
     strip-indent "^3.0.0"
 
 regenerator-runtime@^0.14.0:
-  version "0.14.0"
-  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45"
-  integrity sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==
+  version "0.14.1"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f"
+  integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==
 
 regex-not@^1.0.0, regex-not@^1.0.2:
   version "1.0.2"
@@ -3692,9 +3680,9 @@
   integrity sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==
 
 resolve@^1.10.0, resolve@^1.10.1, resolve@^1.19.0, resolve@^1.22.4:
-  version "1.22.6"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.6.tgz#dd209739eca3aef739c626fea1b4f3c506195362"
-  integrity sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==
+  version "1.22.8"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
+  integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
   dependencies:
     is-core-module "^2.13.0"
     path-parse "^1.0.7"
@@ -3814,6 +3802,16 @@
   resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
   integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
 
+set-function-length@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.1.1.tgz#4bc39fafb0307224a33e106a7d35ca1218d659ed"
+  integrity sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==
+  dependencies:
+    define-data-property "^1.1.1"
+    get-intrinsic "^1.2.1"
+    gopd "^1.0.1"
+    has-property-descriptors "^1.0.0"
+
 set-function-name@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a"
@@ -3990,9 +3988,9 @@
     spdx-license-ids "^3.0.0"
 
 spdx-license-ids@^3.0.0:
-  version "3.0.15"
-  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.15.tgz#142460aabaca062bc7cd4cc87b7d50725ed6a4ba"
-  integrity sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ==
+  version "3.0.16"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz#a14f64e0954f6e25cc6587bd4f392522db0d998f"
+  integrity sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==
 
 split-string@^3.0.1, split-string@^3.0.2:
   version "3.1.0"
@@ -4212,10 +4210,10 @@
   resolved "https://registry.yarnpkg.com/ts-simple-type/-/ts-simple-type-1.0.7.tgz#03930af557528dd40eaa121913c7035a0baaacf8"
   integrity sha512-zKmsCQs4dZaeSKjEA7pLFDv7FHHqAFLPd0Mr//OIJvu8M+4p4bgSFJwZSEBEg3ec9W7RzRz1vi8giiX0+mheBQ==
 
-tsconfig-paths@^3.14.2:
-  version "3.14.2"
-  resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz#6e32f1f79412decd261f92d633a9dc1cfa99f088"
-  integrity sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==
+tsconfig-paths@^3.15.0:
+  version "3.15.0"
+  resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4"
+  integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==
   dependencies:
     "@types/json5" "^0.0.29"
     json5 "^1.0.2"
@@ -4346,9 +4344,9 @@
   integrity sha512-T+tKVNs6Wu7IWiAce5BgMd7OZfNYUndHwc5MknN+UHOudi7sGZzuHdCadllRuqJ3fPtgFtIH9+lt9qRv6lmpfA==
 
 ua-parser-js@^1.0.33:
-  version "1.0.36"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.36.tgz#a9ab6b9bd3a8efb90bb0816674b412717b7c428c"
-  integrity sha512-znuyCIXzl8ciS3+y3fHJI/2OhQIXbXw9MWC/o3qwyR+RGppjZHrM27CGFSKCJXi2Kctiz537iOu2KnXs1lMQhw==
+  version "1.0.37"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f"
+  integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==
 
 unbox-primitive@^1.0.2:
   version "1.0.2"
@@ -4360,6 +4358,11 @@
     has-symbols "^1.0.3"
     which-boxed-primitive "^1.0.2"
 
+undici-types@~5.26.4:
+  version "5.26.5"
+  resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
+  integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
+
 union-value@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
@@ -4429,9 +4432,9 @@
     vscode-uri "^2.1.2"
 
 vscode-languageserver-textdocument@^1.0.1:
-  version "1.0.8"
-  resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.8.tgz#9eae94509cbd945ea44bca8dcfe4bb0c15bb3ac0"
-  integrity sha512-1bonkGqQs5/fxGT5UchTgjGVnfysL0O8v1AYMBjqTbWQTFn721zaPGDYFkOKtfDgFiSgXM3KwaG3FMGfW4Ed9Q==
+  version "1.0.11"
+  resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz#0822a000e7d4dc083312580d7575fe9e3ba2e2bf"
+  integrity sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA==
 
 vscode-languageserver-types@3.16.0-next.2:
   version "3.16.0-next.2"
@@ -4487,13 +4490,13 @@
   resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409"
   integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==
 
-which-typed-array@^1.1.11:
-  version "1.1.11"
-  resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.11.tgz#99d691f23c72aab6768680805a271b69761ed61a"
-  integrity sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==
+which-typed-array@^1.1.11, which-typed-array@^1.1.13:
+  version "1.1.13"
+  resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.13.tgz#870cd5be06ddb616f504e7b039c4c24898184d36"
+  integrity sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==
   dependencies:
     available-typed-arrays "^1.0.5"
-    call-bind "^1.0.2"
+    call-bind "^1.0.4"
     for-each "^0.3.3"
     gopd "^1.0.1"
     has-tostringtag "^1.0.0"