Merge branch 'stable-3.3' into stable-3.4

* stable-3.3:
  Detect DelegateRepository in GarbageCollection operation
  Allow reuse of DelegateRepository functionality

Release-Notes: skip
Change-Id: Ide5a2a4b7bf3f61bb875d61fd8436087e674c8f8
diff --git a/.bazelrc b/.bazelrc
index 72138d2..4117994 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -11,6 +11,8 @@
 # this flag here once flipped in Bazel again.
 build --incompatible_strict_action_env
 
+build --announce_rc
+
 test --build_tests_only
 test --test_output=errors
 test --java_toolchain=//tools:error_prone_warnings_toolchain
diff --git a/.zuul.yaml b/.zuul.yaml
index d6dbc34..da200b8 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -8,6 +8,9 @@
       (i.e., builds of Gerrit itself or plugins) on this branch.
     required-projects:
       - jgit
+    # Remove java_version variable when merging up to master
+    vars:
+      java_version: 8
 
 - job:
     name: gerrit-build
diff --git a/BUILD b/BUILD
index c48b3b9..084d383 100644
--- a/BUILD
+++ b/BUILD
@@ -56,19 +56,22 @@
 API_DEPS = [
     "//java/com/google/gerrit/acceptance:framework_deploy.jar",
     "//java/com/google/gerrit/acceptance:libframework-lib-src.jar",
-    "//java/com/google/gerrit/acceptance:framework-javadoc",
     "//java/com/google/gerrit/extensions:extension-api_deploy.jar",
     "//java/com/google/gerrit/extensions:libapi-src.jar",
-    "//java/com/google/gerrit/extensions:extension-api-javadoc",
     "//plugins:plugin-api_deploy.jar",
     "//plugins:plugin-api-sources_deploy.jar",
+]
+
+API_JAVADOC_DEPS = [
+    "//java/com/google/gerrit/acceptance:framework-javadoc",
+    "//java/com/google/gerrit/extensions:extension-api-javadoc",
     "//plugins:plugin-api-javadoc",
 ]
 
 genrule2(
     name = "api",
     testonly = True,
-    srcs = API_DEPS,
+    srcs = API_DEPS + API_JAVADOC_DEPS,
     outs = ["api.zip"],
     cmd = " && ".join([
         "cp $(SRCS) $$TMP",
@@ -76,3 +79,15 @@
         "zip -qr $$ROOT/$@ .",
     ]),
 )
+
+genrule2(
+    name = "api-skip-javadoc",
+    testonly = True,
+    srcs = API_DEPS,
+    outs = ["api-skip-javadoc.zip"],
+    cmd = " && ".join([
+        "cp $(SRCS) $$TMP",
+        "cd $$TMP",
+        "zip -qr $$ROOT/$@ .",
+    ]),
+)
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index e2d3c6a..2a019ca 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -93,8 +93,8 @@
 == Predefined Groups
 
 Predefined groups differs from system groups by the fact that they
-exist in the ACCOUNT_GROUPS table (like normal groups) but predefined groups
-are created on Gerrit site initialization and unique UUIDs are assigned
+exist in NoteDb under refs/meta/group-names (like normal groups) but predefined
+groups are created on Gerrit site initialization and unique UUIDs are assigned
 to those groups. These UUIDs are different on different Gerrit sites.
 
 Gerrit comes with two predefined groups:
diff --git a/Documentation/cmd-convert-ref-storage.txt b/Documentation/cmd-convert-ref-storage.txt
new file mode 100644
index 0000000..aae385f
--- /dev/null
+++ b/Documentation/cmd-convert-ref-storage.txt
@@ -0,0 +1,58 @@
+= gerrit convert-ref-storage
+
+== NAME
+gerrit convert-ref-storage - Convert ref storage to reftable (experimental).
+
+A reftable file is a portable binary file format customized for reference storage.
+References are sorted, enabling linear scans, binary search lookup, and range scans.
+
+See also link:https://www.git-scm.com/docs/reftable for more details[reftable,role=external,window=_blank]
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit convert-ref-storage_
+  [--format <format>]
+  [--backup | -b]
+  [--reflogs | -r]
+  [--project <PROJECT> | -p <PROJECT>]
+--
+
+== DESCRIPTION
+Convert ref storage to reftable.
+
+== ACCESS
+Administrators
+
+== OPTIONS
+--project::
+-p::
+	Required; Name of the project for which the ref format should be changed.
+
+--format::
+	Format to convert to: `reftable` or `refdir`.
+	Default: reftable.
+
+--backup::
+-b::
+	Create backup of old ref storage format.
+	Default: true.
+
+--reflogs::
+-r::
+	Write reflogs to reftable.
+	Default: true.
+
+== EXAMPLES
+
+Convert ref format for project "core" to reftable:
+----
+$ ssh -p 29418 review.example.com gerrit convert-ref-format -p core
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/cmd-create-project.txt b/Documentation/cmd-create-project.txt
index 9e3d70b..0575eb9 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -53,9 +53,10 @@
 -b::
 	Name of the initial branch(es) in the newly created project.
 	Several branches can be specified on the command line.
-	If several branches are specified then the first one becomes HEAD
-	of the project. If none branches are specified then default value
-	('master') is used.
+	If several branches are specified then the first one becomes
+	link:project-configuration.html#default-branch[HEAD] of the project.
+	If none branches are specified then link:config-gerrit.html#gerrit.defaultBranch[host-level default]
+	is used.
 
 --owner::
 -o::
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index 8a970c5..99ff0db 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -118,6 +118,9 @@
 link:cmd-close-connection.html[gerrit close-connection]::
 	Close the specified SSH connection.
 
+link:cmd-convert-ref-storage.html[gerrit convert-ref-storage]::
+	Convert ref storage to reftable (experimental).
+
 link:cmd-create-account.html[gerrit create-account]::
 	Create a new user account.
 
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 6de787c..0444fab 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -104,6 +104,9 @@
 Assigning a topic to a change can be done in the change screen or through a `git
 push` command.
 
+For more information about using topics, see the user guide:
+link:cross-repository-changes.html[Submitting Changes Across Repositories by using Topics].
+
 [[submit-strategies]]
 == Submit strategies
 
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index 67159f4..9b99960 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -293,12 +293,12 @@
 External IDs are stored as Git Notes in the `All-Users` repository. The
 name of the notes branch is `refs/meta/external-ids`.
 
-As note key the SHA1 of the external ID key is used, for example the key
+As note key the SHA-1 of the external ID key is used, for example the key
 for the external ID `username:jdoe` is `e0b751ae90ef039f320e097d7d212f490e933706`.
 This ensures that an external ID is used only once (e.g. an external ID can
 never be assigned to multiple accounts at a point in time).
 
-The following commands show how to find the SHA1 of an external ID:
+The following commands show how to find the SHA-1 of an external ID:
 
 ----
 $ echo -n 'gerrit:jdoe' | shasum
@@ -310,7 +310,7 @@
 
 [IMPORTANT]
 If the external ID key is changed manually you must adapt the note key
-to the new SHA1, otherwise the external ID becomes inconsistent and is
+to the new SHA-1, otherwise the external ID becomes inconsistent and is
 ignored by Gerrit.
 
 The note content is a Git config file:
@@ -322,7 +322,7 @@
   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
 ----
 
-Once SHA1 of an external ID is known the following command can be used to
+Once SHA-1 of an external ID is known the following command can be used to
 show the content of the note:
 
 ----
@@ -343,6 +343,12 @@
 The `accountId` field is mandatory. The `email` and `password` fields
 are optional.
 
+Note that git will automatically nest these notes at varying levels. If
+refs/meta/external-ids:7c/2a55657d911109dbc930836e7a770fb946e8ef is not
+found then check
+refs/meta/external-ids:7c/2a/55657d911109dbc930836e7a770fb946e8ef and
+so on.
+
 The external IDs are maintained by Gerrit. This means users are not
 allowed to manually edit their external IDs. Only users with the
 link:access-control.html#capability_accessDatabase[Access Database]
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 085a5d5..f088240 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -504,10 +504,10 @@
 +
 When `auth.type` does not normally enable this URL administrators may
 set this to `login/`, allowing users to begin a new web session. This value
-is used as an href in PolyGerrit, so absolute URLs like
+is used as an href in the Gerrit web app, so absolute URLs like
 `https://someotherhost/login` work as well.
 +
-If a ${path} parameter is included, then PolyGerrit will substitute the
+If a ${path} parameter is included, then the Gerrit web app will substitute the
 currently viewed path in the link. Be aware that this path will include
 a leading slash, so a value like this might be appropriate: `/login${path}`.
 
@@ -826,6 +826,7 @@
 * `"groups"`: default is unlimited
 * `"groups_byname"`: default is unlimited
 * `"groups_byuuid"`: default is unlimited
+* `"groups_byuuid_persisted"`: default is `1g` (1 GiB of disk space)
 * `"plugin_resources"`: default is 2m (2 MiB of memory)
 
 +
@@ -1005,6 +1006,11 @@
 be expensive to compute (60 or more seconds for a large history
 like the Linux kernel repository).
 
+cache `"comment_context"`::
++
+Caches the context lines of comments, which are the lines of the source file
+highlighted by the user when the comment was written.
+
 cache `"groups"`::
 +
 Caches the basic group information of internal groups by group ID,
@@ -1044,6 +1050,17 @@
 External group membership obtained from LDAP is cached under
 `"ldap_groups"`.
 
+cache `"groups_byuuid_persisted"`::
++
+Caches the basic group information of internal groups by group UUID,
+including the group owner, name, and description.
++
+This is the persisted version of `groups_byuuid` cache. The intention of this
+cache is to have an in-memory size of 0.
++
+External group membership obtained from LDAP is cached under
+`"ldap_groups"`.
+
 cache `"groups_bymember"`::
 +
 Caches the groups which contain a specific member (account). If direct
@@ -1325,17 +1342,6 @@
 +
 The default is false.
 
-[[change.largeChange]]change.largeChange::
-+
-Number of changed lines from which on a change is considered as a large
-change. The number of changed lines of a change is the sum of the lines
-that were inserted and deleted in the change.
-+
-The specified value is used to visualize the change sizes in the Web UI
-in change tables and user dashboards.
-+
-By default 500.
-
 [[change.maxComments]]change.maxComments::
 +
 Maximum number of comments (regular plus robot) allowed per change. Additional
@@ -1343,6 +1349,20 @@
 +
 By default 5,000.
 
+[[change.maxFiles]]change.maxFiles::
++
+Maximum number of files allowed per change. Larger changes are rejected and must
+be split up.
++
+By default 100,000.
+
+[[change.maxPatchSets]]change.maxPatchSets::
++
+Maximum number of patch sets allowed per change. If this is insufficient,
+recreate the change with a new Change-Id, then abandon the old change.
++
+By default 1,500.
+
 [[change.maxUpdates]]change.maxUpdates::
 +
 Maximum number of updates to a change. Counts only updates to the main NoteDb
@@ -1389,7 +1409,7 @@
   query operator. Gerrit does not serve `mergeable` in
   link:rest-api-changes.html#change-info[ChangeInfo].
 
-Default is `REF_UPDATED_AND_CHANGE_REINDEX`.
+Default is `NEVER`.
 
 [[change.move]]change.move::
 +
@@ -1732,6 +1752,12 @@
 ----
   javaOptions = -Dlog4j.configuration=file:///home/gerrit/site/etc/log4j.properties
 ----
++
+Gerrit built-in loggers are then ignored: error logger (`error_log` file),
+link:#httpd.requestLog[httpd.requestLog] and
+link:#sshd.requestLog[sshd.requestLog]. The
+link:#log.jsonLogging[log.jsonLogging] and
+link:#log.textLogging[log.textLogging] options are also ignored.
 
 [[container.daemonOpt]]container.daemonOpt::
 +
@@ -2119,6 +2145,13 @@
 +
 Defaults to `All-Projects` if not set.
 
+[[gerrit.defaultBranch]]gerrit.defaultBranch::
++
+Name of the link:project-configuration.html#default-branch[default branch]
+to use on the project creation, if no other branches were specified in the input.
++
+Defaults to `refs/heads/master` if not set.
+
 [[gerrit.allUsers]]gerrit.allUsers::
 +
 Name of the project in which meta data of all users is stored.
@@ -2274,16 +2307,11 @@
 By default unset, meaning no bug report URL will be displayed. Administrators
 should set this to the URL of their issue tracker, if necessary.
 
-[[gerrit.enableReverseDnsLookup]]gerrit.enableReverseDnsLookup::
-+
-Enable reverse DNS lookup during computing ref log entry for identified user,
-to record the actual hostname of the user's host in the ref log.
-+
-Enabling reverse DNS lookup can cause performance issues on git push when
-the reverse DNS lookup is slow.
-+
-Defaults to false, reverse DNS lookup is disabled. The user's IP address
-will be recorded in the ref log rather than their hostname.
+[[gerrit.enablePeerIPInReflogRecord]]gerrit.enablePeerIPInReflogRecord::
+
+Record actual peer IP address in ref log entry for identified user.
+
+Defaults to false.
 
 [[gerrit.secureStoreClass]]gerrit.secureStoreClass::
 +
@@ -2323,11 +2351,11 @@
 
 [[gerrit.cdnPath]]gerrit.cdnPath::
 +
-Path prefix for PolyGerrit's static resources if using a CDN.
+Path prefix for Gerrit's static resources if using a CDN.
 
 [[gerrit.faviconPath]]gerrit.faviconPath::
 +
-Path for PolyGerrit's favicon after link:#gerrit.canonicalWebUrl[default URL],
+Path for Gerrit's favicon after link:#gerrit.canonicalWebUrl[default URL],
 including icon name and extension (.ico should be used).
 
 [[gerrit.instanceId]]gerrit.instanceId::
@@ -2429,7 +2457,7 @@
 at a specific commit when `gitweb.type` is set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit
-and `${commit}` for the SHA1 hash for the commit.
+and `${commit}` for the SHA-1 hash for the commit.
 
 [[gitweb.project]]gitweb.project::
 +
@@ -2461,7 +2489,7 @@
 is set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit
-and `${commit}` for the SHA1 hash for the commit.
+and `${commit}` for the SHA-1 hash for the commit.
 
 [[gitweb.file]]gitweb.file::
 +
@@ -2470,8 +2498,9 @@
 set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit,
-`${file}` for the file name and `${commit}` for the SHA1 hash for
-the commit.
+`${file}` for the file name, `${hash}` for the SHA-1 hash for the commit,
+and `${commit}` for the change ref or SHA-1 of the commit if no base
+patch set.
 
 [[gitweb.filehistory]]gitweb.filehistory::
 +
@@ -2523,6 +2552,18 @@
 [[groups]]
 === Section groups
 
+[[groups.includeExternalUsersInRegisteredUsersGroup]]groups.includeExternalUsersInRegisteredUsersGroup::
++
+Controls whether external users (these are users we have sufficient
+knowledge about but who don't yet have a Gerrit account) are considered
+to be members of the `REGISTERED_USERS` group.
++
+This setting only makes sense if you run custom code (e.g. from a plugin
+or a custom authentication backend). By default, Gerrit core always requires
+users to register and doesn't use external users.
++
+By default, true.
+
 [[groups.newGroupsVisibleToAll]]groups.newGroupsVisibleToAll::
 +
 Controls whether newly created groups should be by default visible to
@@ -3339,6 +3380,19 @@
 +
 Defaults to 10000.
 
+[[elasticsearch.connectTimeout]]elasticsearch.connectTimeout::
++
+Sets the timeout for connecting to elasticsearch.
++
+Defaults to `1 second`.
+
+[[elasticsearch.socketTimeout]]elasticsearch.socketTimeout::
++
+Sets the timeout for the underlying connection. For more information, refer to
+link:#httpd.idleTimeout[`httpd.idleTimeout`].
++
+Defaults to `30 seconds`.
+
 ==== Elasticsearch Security
 
 When security is enabled in Elasticsearch, the username and password must be provided.
@@ -3400,9 +3454,9 @@
 [[experiments]]
 === Section experiments
 
-This section covers experimental new features. Gerrit's frontend uses experiments
-to research new behavior. Once the research is done, the experimental feature
-either stays and the experimentation flag gets removed, or the feature as a whole
+This section covers experimental new features. Gerrit uses experiments
+to research new behavior in frontend and core backend. Once the research is done, the experimental
+feature either stays and the experimentation flag gets removed, or the feature as a whole
 gets removed
 
 [[experiments.enabled]]experiments.enabled::
@@ -3850,8 +3904,13 @@
 
 [[log.jsonLogging]]log.jsonLogging::
 +
-If set to true, enables error, ssh and http logging in JSON format (file name:
-"logs/{error|sshd|httpd}_log.json").
+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
+configuration is specified via
+link:#container.javaOptions[container.javaOptions], for example
+`-Dlog4j.configuration=file://etc/log4j.properties`.
 +
 Defaults to false.
 
@@ -3860,6 +3919,11 @@
 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
+configuration is specified via
+link:#container.javaOptions[container.javaOptions], for example
+`-Dlog4j.configuration=file://etc/log4j.properties`.
++
 Defaults to true.
 
 [[log.compress]]log.compress::
@@ -4132,7 +4196,7 @@
 very CPU-heavy operation. For non public Gerrit-servers this check may
 be overkill.
 +
-Only disable this check if you trust the clients not to forge SHA1
+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.
@@ -4772,6 +4836,16 @@
   replicate = replication start
 ----
 
+[[ssh]]
+=== Section ssh
+
+[[ssh.clientImplementation]]ssh.clientImplementation::
++
+JCraft JSch client is supported in addition to Apache MINA SSH client.
+To use JSch client set the value to `JSCH`.
++
+By default, `APACHE`.
+
 [[sshd]]
 === Section sshd
 
@@ -5028,6 +5102,16 @@
 +
 By default, all supported MACs are available.
 
+[[sshd.enableDeprecatedKexAlgorithms]]sshd.enableDeprecatedKexAlgorithms::
++
+Enable deprecated kex algorithms:
++
+* `diffie-hellman-group1-sha1`
+* `diffie-hellman-group14-sha1`
+* `diffie-hellman-group-exchange-sha1`
+
+By default, the deprecated kex algorithms are disabled.
+
 [[sshd.kex]]sshd.kex::
 +
 --
@@ -5038,24 +5122,20 @@
 algorithms, key exchange algorithm names starting with `-` are
 removed from the default key exchange algorithms.
 
-In the following example configuration, support for the 1024-bit
-`diffie-hellman-group1-sha1` key exchange is disabled while leaving
-all of the other default algorithms enabled:
-
-----
-[sshd]
-  kex = -diffie-hellman-group1-sha1
-----
-
 Supported key exchange algorithms:
 
 * `ecdh-sha2-nistp521`
 * `ecdh-sha2-nistp384`
 * `ecdh-sha2-nistp256`
 * `diffie-hellman-group-exchange-sha256`
-* `diffie-hellman-group-exchange-sha1`
-* `diffie-hellman-group14-sha1`
-* `diffie-hellman-group1-sha1`
+* `diffie-hellman-group18-sha512`
+* `diffie-hellman-group17-sha512`
+* `diffie-hellman-group16-sha512`
+* `diffie-hellman-group15-sha512`
+* `diffie-hellman-group14-sha256`
+
+See link:#sshd.enableDeprecatedKexAlgorithms[sshd.enableDeprecatedKexAlgorithms]
+for deprecated key algorithms and how to enable them.
 
 By default, all supported key exchange algorithms are available.
 
@@ -5645,10 +5725,10 @@
 [[protocol.version]]protocol.version::
 +
 If set, the server will accept requests from a client attempting to communicate
-using the specified protocol version. Otherwise communication falls back to version 0.
-If set in file `etc/jgit.config` this option will be used for all repositories of
-the site. It can be overridden for a given repository by configuring a different
-value in the repository's `config` file.
+using the specified protocol version. Default is `2`. If set in file
+`etc/jgit.config` this option will be used for all repositories of the site.
+It can be overridden for a given repository by configuring a different value in
+the repository's `config` file.
 +
 Supported versions:
 0:: the original wire protocol.
diff --git a/Documentation/config-gitweb.txt b/Documentation/config-gitweb.txt
index 46c9ced..00e33a3 100644
--- a/Documentation/config-gitweb.txt
+++ b/Documentation/config-gitweb.txt
@@ -268,7 +268,22 @@
 
 cgit can be used by specifying `gitweb.type` to be 'cgit'.
 
-It is also possible to define custom patterns.
+It is also possible to define custom patterns. Gitea can be used
+with custom patterns for example:
+
+----
+  git config -f $site_path/etc/gerrit.config gitweb.type custom
+  git config -f $site_path/etc/gerrit.config gitweb.urlEncode false
+  git config -f $site_path/etc/gerrit.config gitweb.linkname gitea
+  git config -f $site_path/etc/gerrit.config gitweb.url https://gitea.example.org/
+  git config -f $site_path/etc/gerrit.config gitweb.branch ${project}/src/branch/${branch}
+  git config -f $site_path/etc/gerrit.config gitweb.file ${project}/src/commit/${hash}/${file}
+  git config -f $site_path/etc/gerrit.config gitweb.filehistory ${project}/commits/branch/${branch}/${file}
+  git config -f $site_path/etc/gerrit.config gitweb.project ${project}
+  git config -f $site_path/etc/gerrit.config gitweb.revision ${project}/commit/${commit}
+  git config -f $site_path/etc/gerrit.config gitweb.roottree ${project}/src/commit/${commit}
+  git config -f $site_path/etc/gerrit.config gitweb.tag ${project}/src/tag/${tag}
+----
 
 === SEE ALSO
 
diff --git a/Documentation/config-groups.txt b/Documentation/config-groups.txt
index afabbfc..0917515 100644
--- a/Documentation/config-groups.txt
+++ b/Documentation/config-groups.txt
@@ -64,7 +64,7 @@
 
 The format of this map is as follows:
 
-* keys are the normal SHA1 of the group name
+* keys are the normal SHA-1 of the group name
 * values are blobs that look like
 +
 ----
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index a3b9d0b..9d3446e 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -283,6 +283,22 @@
 sticky approvals, reducing turn-around for trivial cleanups prior to
 submitting a change. Defaults to false.
 
+[[label_copyAllScoresIfListOfFilesDidNotChange]]
+=== `label.Label-Name.copyAllScoresIfListOfFilesDidNotChange`
+
+This policy is useful if you don't want to trigger CI or human
+verification again if the list of files didn't change.
+
+If true, all scores for the label are copied forward when a new
+patch-set is uploaded that has the same list of files as the previous
+patch-set.
+
+Renames are considered different files when computing whether new files
+were added or old files were deleted. Hence, if there are renames, scores will
+*NOT* be copied over.
+
+Defaults to false.
+
 [[label_copyAllScoresOnMergeFirstParentUpdate]]
 === `label.Label-Name.copyAllScoresOnMergeFirstParentUpdate`
 
@@ -337,7 +353,7 @@
 If true, all scores for the label are copied forward when a new patch
 set is uploaded that has the same parent tree, code delta, and commit
 message as the previous patch set. This means that only the patch
-set SHA1 is different. This can be used to enable sticky
+set SHA-1 is different. This can be used to enable sticky
 approvals, reducing turn-around for this special case.
 It is recommended to leave this enabled for both Verified and
 Code-Review labels.
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index 272c4eb..3433c15 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -49,7 +49,7 @@
 [[codemirror-editor]]
 === codemirror-editor
 
-CodeMirror plugin for polygerrit.
+CodeMirror JavaScript plugin for Gerrit.
 
 link:https://gerrit-review.googlesource.com/admin/repos/plugins/codemirror-editor[
 Project,role=external,window=_blank] |
diff --git a/Documentation/config-validation.txt b/Documentation/config-validation.txt
index cb953c1..56c9ecd 100644
--- a/Documentation/config-validation.txt
+++ b/Documentation/config-validation.txt
@@ -21,6 +21,14 @@
 Out of the box, Gerrit includes a plugin that checks the length of the
 subject and body lines of commit messages on uploaded commits.
 
+[plugin-push-options]]
+=== Plugin push options
+
+Plugins can register push options by implementing the `PluginPushOption`
+interface. If a plugin push option was specified it is available from
+the `CommitReceivedEvent` that is passed into `CommitValidationListener`.
+This way the plugin commit validation can be controlled by push options.
+
 [[user-ref-operations-validation]]
 == User ref operations validation
 
diff --git a/Documentation/cross-repository-changes.txt b/Documentation/cross-repository-changes.txt
new file mode 100644
index 0000000..53fd5cd
--- /dev/null
+++ b/Documentation/cross-repository-changes.txt
@@ -0,0 +1,254 @@
+:linkattrs:
+= Gerrit Code Review - Submitting Changes Across Repositories by using Topics
+
+== Goal
+
+This document describes how to propose and submit code changes across multiple
+Git repositories together in Gerrit.
+
+== When to Use
+
+Oftentimes, especially for larger code bases, code is split across multiple
+repositories. The Android operating system’s code base, for example, consists of
+https://android.googlesource.com/[hundreds] of separate repositories. When
+making a change, you might make code changes that span multiple repositories.
+For example, one repository could define an API which is used in another
+repository. Submitting these changes across these repositories separately could
+cause the build to break for other developers.
+
+Gerrit provides a mechanism called link:intro-user.html#topics[Topics] to submit
+changes together to prevent this problem.
+
+|===
+|NOTE: Usage of topics to submit multiple changes together requires your
+Gerrit host having
+link:config-gerrit.html#change.submitWholeTopic[config.submitWholeTopic] set to
+true. Ask your Gerrit administrator if you're not sure if this is enabled for
+your Gerrit instance.
+|===
+
+== What is a Topic?
+
+* A topic is a string that can be associated with a change.
+* Multiple changes can use that topic to be submitted at the same time (assuming
+  approvals, etc.).
+* Submitting a change with a topic causes all of the changes in the topic *to be
+  submitted together*
+  ** Topics that span only a single repository are guaranteed to be submitted
+  together
+  ** Topics that span multiple repositories simply triggers submission of all
+  changes. No other guarantees are given. Submission of all changes could
+  fail, so you could get a partial topic submission. This is very rare but
+  can happen in some of the following situations:
+  *** Storage layer failures. This is unlikely in single-master installation and
+  more likely with multi-master setups.
+  *** Race conditions. Concurrent submits to the same repository or concurrent
+  updates of the pending changes.
+
+Here are a few intricacies you should be aware of:
+
+1. Topics can only be used for changes within a single Gerrit instance. There is
+no builtin support for synchronizing with other Gerrit or Git hosting sites.
+
+2. A topic can be any string, and they are not namespaced in a Gerrit instance;
+there is a chance for collisions and inadvertently grouping changes together
+that weren’t meant to be grouped. This could even happen with changes you can’t
+see, leading to more confusion e.g. (change not submittable, but you can't see
+why it's not submittable.). We suggest prefixing topic strings with the author’s
+username e.g. “username-” to help avoid this.
+
+You can view the assigned topic from the change screen in Gerrit:
+
+image::images/cross-repository-changes-topic.png[width=600]
+
+=== Topic submission behavior
+* Submitting a topic will submit any dependent changes as well. For example,
+  an unsubmitted parent change will also be submitted, even if it isn’t in the
+  original topic.
+* A change with a topic is submittable when *all changes* in the topic are
+  submittable and *all of the changes’ dependent changes* (and their topics!)
+  are also submittable.
+* Gerrit calls the totality of these changes "Submitted Together", and they can
+be found with the
+  link:rest-api-changes.html#submitted-together[Submitted Together endpoint] or
+  on the change screen.
+
+image::images/cross-repository-changes-submitted-together.png[width=600]
+
+* A submission creates a unique submission ID
+    (link:rest-api-changes.html#change-info[`submission_id`]), which can be
+    used in Gerrit's search bar to find all the submitted changes for the
+    submission. This ID is relevant when <<reverting,reverting a submission>>.
+
+To better underestand this behavior, consider this following example.
+
+[[example_submission]]
+=== Example Submission
+
+image::images/cross-repository-changes-example.png[width=600]
+
+* Two repositories: A and B
+* Two changes in A: A1 and A2, where A2 is the child change.
+* Two changes in B: B1 and B2, where B2 is the child change.
+* Topic X contains change A1 and B1
+* Topic Y contains change A2 and B2
+
+Submission of A2 will submit all four of these changes because submission of A2
+submits all of topic Y as well as all dependent changes and their topics i.e. A1
+and topic X.
+
+Because of this, any submission is blocked until all four of these changes are
+submittable.
+
+|===
+| Important point: B1 can unexpectedly block the submission of A2!
+This kind of situation is hard to immediately grok: B1 isn't in the topic you're
+trying to submit, and it isn't a depnedent change of A2. If your topic isn’t
+submittable and you can’t figure out why, this might be a reason.
+|===
+
+== Submitting Changes Using Topics
+
+=== 1. *Associate the changes to a topic*
+
+The first step is to associate all the changes you want to be submitted together
+with the same topic. There are multiple ways to associate changes with a topic.
+
+==== From the command line
+You can set the topic name when uploading to Gerrit
+
+----
+$ git push origin HEAD:refs/heads/master -o topic=[YOUR_TOPIC_NAME]
+----
+
+*OR*
+
+----
+$ git push origin HEAD:refs/for/master%topic=[YOUR_TOPIC_NAME]
+----
+
+If you’re using https://source.android.com/setup/develop[repo] to upload a
+change to Android Gerrit, you can associate a topic via:
+
+----
+$ repo upload -o topic=[YOUR_TOPIC_NAME]
+----
+
+If you’re using
+https://commondatastorage.googleapis.com/chrome-infra-docs/flat/depot_tools/docs/html/depot_tools.html[depot_tools]
+to upload a change to Chromium Gerrit, you can associate a topic via:
+
+----
+$ git cl upload --topic=[YOUR_TOPIC_NAME]
+----
+
+==== From the UI
+
+If the change has already been created, you can add a topic from the change page
+by clicking ADD TOPIC, found on the left side of the top of the Change screen.
+
+image::images/cross-repository-changes-add-topic.png[width=600]
+
+=== 2. *Go through the normal code review process*
+
+Each change still goes through the normal code review process where reviewers
+vote on each change individually. The changes won’t be able to be submitted
+until *all* changes in the topic are submittable.
+
+The requirements for submittability vary based on rules set by your repository
+administrators; often this includes being approved by all requisite parties,
+passing presubmit testing, and being able to merge cleanly (without conflicts)
+into the target branch.
+
+=== 3. *Submit the change*
+
+When all changes in the topic are submittable, you’ll see *SUBMIT WHOLE TOPIC*
+at the top of the _Change screen_. Clicking it will submit all the changes in
+"Submitted Together."
+
+image::images/cross-repository-changes-submit-topic.png[width=600]
+
+[[reverting]]
+== Reverting a Submission
+
+After a topic is submitted, you can revert all or one of the changes by clicking
+the *REVERT* button on any change.
+
+image::images/cross-repository-changes-revert-topic.png[width=600]
+
+This will give you the option to either revert just the change in question or
+the entire topic:
+
+image::images/cross-repository-changes-revert-topic-options.png[width=600]
+
+Reverting the entire submission creates revert commits for each change and
+automatically associates them together under the same topic. To submit
+these changes, go through the normal review process.
+
+When submitting a topic, dependent changes and their topics are submitted as
+well. The RevertSubmission creates reverts for all the changes that were
+submitted at that time. When reverting the submission described in
+<<example_submission,Example Submission>>, all 4 of those changes will get
+reverted.
+
+|===
+| NOTE: We say “reverting a submission” instead of “reverting a submitted
+  topic” because submissions are defined by submission id, not by the topic
+  string. So even though topics names could be reused, this doesn't effect
+  reverting. For example:
+
+  1. Submission #1 uses topic A
+
+  2. Later, Submission #2 uses topic A again
+
+  Reverting submission #2 only reverts the changes in that submission, not all
+  changes included in topic A.
+|===
+
+== Cherry-Picking a Topic
+
+You may want to cherry-pick the changes (i.e. copy the changes) of a topic to
+another branch, perhaps because you have multiple branches that all need to be
+updated with the same change (e.g. you're porting a security fix across
+branches). Gerrit provides a mechanism to create these changes.
+
+From the overflow menu (3 dot icon) in the top right of the Change Screen,
+select “Cherry pick.” In the screenshot below, we’re showing this on a
+submitted change, but this option is available if the change is pending as
+well.
+
+image::images/cross-repository-changes-cp-menu.png[width=600]
+
+Afterwards, you’ll be presented with a modal where you can “Cherry Pick entire
+topic.”
+
+image::images/cross-repository-changes-cp-modal.png[width=600]
+
+Enter the branch name that you want to target for these repositories. The
+branch must already exist on all of the repositories. After clicking
+“CHERRY PICK,” Gerrit will create new changes all targeting the entered
+branch in their respective repositories, and these new changes will all be
+associated with a new, uniquely-generated topic name.
+
+To submit the cherry-picked changes, go through the normal submission
+process.
+
+|===
+| NOTE: You cannot cherry pick two or more changes that all target the same
+ repository from the Gerrit UI at this time; you’ll get an error message saying
+ “changes cannot be of the same repository.” To accomplish this, you’d
+ need to do the cherry-pick locally.
+|===
+
+== Searching for Topics
+
+In the Gerrit search bar, you can search for changes attached to a specific
+topic using the `topic` operator e.g. `topic:MY_TOPIC_NAME`. The `intopic`
+operator works similary but supports free-text and regular expression search.
+
+You can also search for a submission using the `submissionid` operator. Topic
+submission IDs are "<id>-<topic>" where id is the change number of the change
+that triggered the submission (though this could change in the future). As a
+full example, if the topic name is my-topic and change 12345 was the one that
+triggered submission, you could find it with `submissionid:12345-my-topic`.
+
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 5d71b41..c05d3f4 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -137,7 +137,7 @@
 [[release]]
 === Gerrit Release WAR File
 
-To build the Gerrit web application that includes the PolyGerrit UI,
+To build the Gerrit web application that includes the Gerrit UI,
 core plugins and documentation:
 
 ----
@@ -152,8 +152,7 @@
 
 === Headless Mode
 
-To build Gerrit in headless mode, i.e. without the PolyGerrit UI:
-Web UI:
+To build Gerrit in headless mode, i.e. without the Gerrit UI:
 
 ----
   bazel build headless
@@ -288,6 +287,18 @@
   bazel-bin/withdocs.war
 ----
 
+Alternatively, one can generate the documentation as flat files:
+
+----
+  bazel build Documentation:Documentation
+----
+
+The html, css, js files are placed in:
+
+----
+ `bazel-bin/Documentation/`
+----
+
 [[tests]]
 == Running Unit Tests
 
@@ -313,6 +324,18 @@
   bazel test //javatests/com/google/gerrit/acceptance/rest/account:rest_account
 ----
 
+To run SSH tests using JSch ssh client:
+
+----
+  bazel test --test_env=SSH_CLIENT_IMPLEMENTATION=JSCH //...
+----
+
+To run SSH tests using Apache MINA ssh client:
+
+----
+  bazel test --test_env=SSH_CLIENT_IMPLEMENTATION=APACHE //...
+----
+
 To run only tests that do not use SSH:
 
 ----
@@ -365,6 +388,29 @@
 * server
 * ssh
 
+Bazel itself supports a multitude of ways to
+link:https://docs.bazel.build/versions/master/guide.html#specifying-targets-to-build[specify targets,role=external,window=_blank]
+for fine-grained test selection that can be combined with many of the examples
+above.
+
+[[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
+observe a different test result in Eclipse and bazel. Using the `--java_debug` option will start the
+JVM in debug mode and await for a remote debugger to attach.
+
+Example:
+[source,bash]
+----
+  bazel test --java_debug --test_tag_filters=delete-project //...
+  ...
+  Listening for transport dt_socket at address: 5005
+  ...
+----
+
+Now attach with a debugger to the port `5005`. For example use "Remote Java Application" launch
+configuration in Eclipe and specify the port `5005`.
+
 [[elasticsearch]]
 === Elasticsearch
 
@@ -523,10 +569,10 @@
 [[npm-binary]]
 == NPM Binaries
 
-Parts of the PolyGerrit build require running NPM-based JavaScript programs as
-"binaries". We don't attempt to resolve and download NPM dependencies at build
-time, but instead use pre-built bundles of the NPM binary along with all its
-dependencies. Some packages on
+Parts of the Gerrit web app build require running NPM-based JavaScript programs
+as "binaries". We don't attempt to resolve and download NPM dependencies at
+build time, but instead use pre-built bundles of the NPM binary along with all
+its dependencies. Some packages on
 link:https://docs.npmjs.com/misc/registry[registry.npmjs.org,role=external,window=_blank] come with their
 dependencies bundled, but this is the exception rather than the rule. More
 commonly, to add a new binary to this list, you will need to bundle the binary
@@ -589,8 +635,8 @@
 If a npm package has transitive dependencies (or just several files) with a not allowed
 license and you can't avoid use it in release, then you can add this package.
 For example some packages contain demo-code with a different license. Another example - optional
-dependencies, which are not needed to build polygerrit, but they are installed together with
-the package anyway.
+dependencies, which are not needed to build the Gerrit web app, but they are installed together
+with the package anyway.
 
 In this case you should exclude all files and/or transitive dependencies with a not allowed license.
 Adding such package requires additional updates:
@@ -635,6 +681,40 @@
     --disk-size=200
 ```
 
+Due to outdated Git version in official RBE docker images, a custom RBE docker
+image must be used. To build custom docker imager, change to the directory
+`tools/platforms` and build and publish custom RBE docker image.
+
+To build the custom RBE docker image, run:
+
+```
+docker build -t gcr.io/api-project-164060093628/rbe-ubuntu18-04 .
+```
+
+To publish the custom RBE docker image, run:
+
+```
+docker push gcr.io/api-project-164060093628/rbe-ubuntu18-04
+[...]
+latest: digest: sha256:de5186d4313630a6111f9a2449b72563d0bc59ec9fb60956f063b69a38a76834 size: 1584
+```
+
+Re-build rbe_autoconfig project conduct a new release and switch to using it
+in `WORKSPACE` file.
+
+Note, to authenticate to the gcr.io registry, the following command must be
+used:
+
+```
+gcloud auth configure-docker
+```
+
+To see the documentation, developer must be added to this group:
+https://groups.google.com/forum/#!forum/rbe-alpha-customers.
+
+Documentation can be found at:
+https://cloud.google.com/remote-build-execution/docs.
+
 To use RBE, execute
 
 ```
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt
index 53829c9..7488f74 100644
--- a/Documentation/dev-community.txt
+++ b/Documentation/dev-community.txt
@@ -43,7 +43,7 @@
 ** link:dev-contributing.html#mentorship[Mentorship]
 * link:dev-design-docs.html[Design Docs]
 * link:dev-readme.html[Developer Setup]
-* link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui[Polymer Frontend 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]
 
@@ -52,7 +52,7 @@
 * link:dev-plugins-lifecycle.html[Plugin Lifecycle]
 * link:dev-plugins.html[Developing Plugins]
 * link:dev-build-plugins.html[Building Gerrit plugins]
-* link:js-api.html[JavaScript Plugin API]
+* link:pg-plugin-dev.html[JavaScript Plugin Development and API]
 * link:config-validation.html[Validation Interfaces]
 * link:dev-stars.html[Starring Changes]
 * link:quota.html[Quota Enforcement]
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 477641b..01857da 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -113,6 +113,11 @@
 nags and pester you if you haven't replied or made a fix, so it helps
 them know if you missed it or decided against it.
 
+Features or API extensions, even if they are small, will incur
+long-time maintenance and support burden, so they should be left
+pending for at least 24 hours to give maintainers in all timezones a
+chance to evaluate.
+
 [[design-driven-contribution-process]]
 === Design-driven Contribution Process
 
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index 7935f30..15bf785 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -306,6 +306,30 @@
     and it also makes `git revert` more useful.
   * Use topics to link your separate changes together.
 
+[[opportunistic-refactoring]]
+== Opportunistic Refactoring
+
+Opportunistic Refactoring is a terminology
+link:https://martinfowler.com/bliki/OpportunisticRefactoring.html[used by Martin Fowler,role=external,window=_blank]
+also known as the "boy scout rule" of the software developer:
+"always leave the code behind in a better state than you found it."
+
+In practice, this rule means you should not add technical debt in the code while
+implementing a new feature or fixing a bug. If you or a reviewer find an
+opportunity to clean up the code during implementation or review of your change,
+take the time to do a little cleanup to improve the overall code base.
+
+When approaching refactoring, keep in mind that changes should do one thing
+(<<change-size,see change size section above>>). If a change you're making
+requires cleanup/refactoring, it is best to do that cleanup in a preparatory and
+separate change. Likewise, if during review for a functional change, an
+opportunity for cleanup/refactoring is discovered, then it is preferable to do
+the cleanup first in a separate change so as to improve the reviewability of the
+functional change.
+
+Reviewers should keep in mind the scope of the change under review and ensure
+suggested refactoring is aligned with that scope.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-design.txt b/Documentation/dev-design.txt
index 1935586..5636dfd 100644
--- a/Documentation/dev-design.txt
+++ b/Documentation/dev-design.txt
@@ -18,83 +18,45 @@
 
 == Background
 
-Google developed Mondrian, a Perforce based code review tool to
-facilitate peer-review of changes prior to submission to the central
-code repository.  Mondrian is not open source, as it is tied to the
-use of Perforce and to many Google-only services, such as Bigtable.
-Google employees have often described how useful Mondrian and its
-peer-review process is to their day-to-day work.
-
-Guido van Rossum open sourced portions of Mondrian within Rietveld,
-a similar code review tool running on Google App Engine, but for
-use with Subversion rather than Perforce.  Rietveld is in common
-use by many open source projects, facilitating their peer reviews
-much as Mondrian does for Google employees.  Unlike Mondrian and
-the Google Perforce triggers, Rietveld is strictly advisory and
-does not enforce peer-review prior to submission.
-
 Git is a distributed version control system, wherein each repository
 is assumed to be owned/maintained by a single user.  There are no
 inherent security controls built into Git, so the ability to read
 from or write to a repository is controlled entirely by the host's
-filesystem access controls.  When multiple maintainers collaborate
-on a single shared repository a high degree of trust is required,
-as any collaborator with write access can alter the repository.
+filesystem or network access controls.
 
-Gitosis provides tools to secure centralized Git repositories,
-permitting multiple maintainers to manage the same project at once,
-by restricting the access to only over a secure network protocol,
-much like Perforce secures a repository by only permitting access
-over its network port.
+The objective of Gerrit is to facilitate Git development by larger
+teams: it provides a means to enforce organizational policies around
+code submissions, eg. "all code must be reviewed by another
+developer", "all code shall pass tests". It achieves this by
 
-The Android Open Source Project (AOSP) was founded by Google by the
-open source releasing of the Android operating system.  AOSP has
-selected Git as its primary version control tool.  As many of the
-engineers have a background of working with Mondrian at Google,
-there is a strong desire to have the same (or better) feature set
-available for Git and AOSP.
+* providing fine-grained (per-branch, per-repository, inheriting)
+  access controls, which allow a Gerrit admin to delegate permissions
+  to different team(-lead)s.
 
-Gerrit Code Review started as a simple set of patches to Rietveld,
-and was originally built to service AOSP. This quickly turned
-into a fork as we added access control features that Guido van
-Rossum did not want to see complicating the Rietveld code base. As
-the functionality and code were starting to become drastically
-different, a different name was needed. Gerrit calls back to the
-original namesake of Rietveld, Gerrit Rietveld, a Dutch architect.
-
-Gerrit 2.x is a complete rewrite of the Gerrit fork, completely
-changing the implementation from Python on Google App Engine, to Java
-on a J2EE servlet container and an SQL database.
-
-Since Gerrit 3.x link:note-db.html[NoteDb] replaced the SQL database
-and all metadata is now stored in Git.
-
-* link:http://video.google.com/videoplay?docid=-8502904076440714866[Mondrian Code Review On The Web,role=external,window=_blank]
-* link:https://github.com/rietveld-codereview/rietveld[Rietveld - Code Review for Subversion,role=external,window=_blank]
-* link:http://eagain.net/gitweb/?p=gitosis.git;a=blob;f=README.rst;hb=HEAD[Gitosis README,role=external,window=_blank]
-* link:http://source.android.com/[Android Open Source Project,role=external,window=_blank]
-
+* facilitate code review: Gerrit offers a web view of pending code
+  changes, that allows for easy reading and commenting by humans. The
+  web view can offer data coming out of automated QA processes (eg.
+  CI). The permission system also includes fine grained control of who
+  can approve pending changes for submission to further facilitate
+  delegation of code ownership.
 
 == Overview
 
 Developers create one or more changes on their local desktop system,
 then upload them for review to Gerrit using the standard `git push`
-command line program, or any GUI which can invoke `git push` on
-behalf of the user.  Authentication and data transfer are handled
-through SSH.  Users are authenticated by username and public/private
-key pair, and all data transfer is protected by the SSH connection
-and Git's own data integrity checks.
+command line program, or any GUI which can invoke `git push` on behalf
+of the user. Authentication and data transfer are handled through SSH
+and HTTPS. Uploads are protected by the authentication,
+confidentiality and integrity offered by the transport (SSH, HTTPS).
 
-Each Git commit created on the client desktop system is converted
-into a unique change record which can be reviewed independently.
-Change records are stored in NoteDb.
+Each Git commit created on the client desktop system is converted into
+a unique change record which can be reviewed independently.
 
 A summary of each newly uploaded change is automatically emailed
 to reviewers, so they receive a direct hyperlink to review the
 change on the web.  Reviewer email addresses can be specified on the
-`git push` command line, but typically reviewers are automatically
-selected by Gerrit by identifying users who have change approval
-permissions in the project.
+`git push` command line, but typically reviewers are added in the web
+interface.
 
 Reviewers use the web interface to read the side-by-side or unified
 diff of a change, and insert draft inline/file comments where
@@ -103,40 +65,48 @@
 emailed to the change author by Gerrit, and are CC'd to all other
 reviewers who have already commented on the change.
 
-When publishing comments reviewers are also given the opportunity
-to score the change, indicating whether they feel the change is
-ready for inclusion in the project, needs more work, or should be
-rejected outright.  These scores provide direct feedback to Gerrit's
-change submit function.
+Reviewers can score the change ("vote"), indicating whether they feel the
+change is ready for inclusion in the project, needs more work, or
+should be rejected outright. These scores provide direct feedback to
+Gerrit's change submit function.
 
-After a change has been scored positively by reviewers, Gerrit
-enables a submit button on the web interface.  Authorized users
-can push the submit button to have the change enter the project
-repository.  The equivalent in Subversion or Perforce would be
-that Gerrit is invoking `svn commit` or `p4 submit` on behalf of
-the web user pressing the button.  Due to the way Git audit trails
-are maintained, the user pressing the submit button does not need
-to be the author of the change.
+After a change has been scored positively by reviewers, Gerrit enables
+a submit button on the web interface. Authorized users can push the
+submit button to have the change enter the project repository. The
+user pressing the submit button does not need to be the author of the
+change.
 
 
 == Infrastructure
 
 End-user web browsers make HTTP requests directly to Gerrit's
-HTTP server.  As nearly all of the user interface is implemented
-through PolyGerrit, the majority of these requests are transmitting
-compressed JSON payloads, with all HTML being generated within the
-browser.  Most responses are under 1 KB.
+HTTP server. As nearly all of the Gerrit user interface is implemented
+in a JavaScript based web app, the majority of these requests are
+transmitting compressed JSON payloads, with all HTML being generated
+within the browser.
 
-Gerrit's HTTP server side component is implemented as a standard
-Java servlet, and thus runs within any J2EE servlet container.
-Popular choices for deployments would be Tomcat or Jetty, as these
-are high-quality open-source servlet containers that are readily
-available for download.
+Gerrit's HTTP server side component is implemented as a standard Java
+servlet, and thus runs within any link:install-j2ee.html[J2EE servlet
+container]. The standard install will run inside Jetty, which is
+included in the binary.
 
-End-user uploads are performed over SSH, so Gerrit's servlets also
-start up a background thread to receive SSH connections through
-an independent SSH port.  SSH clients communicate directly with
-this port, bypassing the HTTP server used by browsers.
+End-user uploads are performed over SSH or HTTP, so Gerrit's servlets
+also start up a background thread to receive SSH connections through
+an independent SSH port. SSH clients communicate directly with this
+port, bypassing the HTTP server used by browsers.
+
+User authentication is handled by identity realms. Gerrit supports the
+following types of authentication:
+
+* OpenId (see link:http://openid.net/developers/specs/[OpenID Specifications,role=external,window=_blank])
+* OAuth2
+* LDAP
+* Google accounts (on googlesource.com)
+* SAML
+* Kerberos
+* 3rd party SSO
+
+=== NoteDb
 
 Server side data storage for Gerrit is broken down into two different
 categories:
@@ -156,28 +126,119 @@
 local ones, due to Git disk IO behavior not being optimized for
 remote access.
 
-The Gerrit metadata contains a summary of the available changes,
-all comments (published and drafts), and individual user account
-information.  The metadata is mostly housed in the database (*1),
-which can be located either on the same server as Gerrit, or on
-a different (but nearby) server.  Most installations would opt to
-install both Gerrit and the metadata database on the same server,
-to reduce administration overheads.
+The Gerrit metadata contains a summary of the available changes, all
+comments (published and drafts), and individual user account
+information.
 
-User authentication is handled by OpenID, and therefore Gerrit
-requires that the OpenID provider selected by a user must be
-online and operating in order to authenticate that user.
+Gerrit metadata is also stored in Git, with the commits marking the
+historical state of metadata. Data is stored in the trees associated
+with the commits, typically using Git config file or JSON as the base
+format. For metadata, there are 3 types of data: changes, accounts and
+groups.
 
-* link:http://www.kernel.org/pub/software/scm/git/docs/gitrepository-layout.html[Git Repository Format,role=external,window=_blank]
-* link:http://openid.net/developers/specs/[OpenID Specifications,role=external,window=_blank]
+Accounts are stored in a special Git repository `All-Users`.
 
-*1  Although an effort is underway to eliminate the use of the
-database altogether, and to store all the metadata directly in
-the git repositories themselves.  So far, as of Gerrit 2.2.1, of
-all Gerrit's metadata, only the project configuration metadata
-has been migrated out of the database and into the git
-repositories for each project.
+Accounts can be grouped in groups. Gerrit has a built-in group system,
+but can also interface to external group system (eg. Google groups,
+LDAP). The built-in groups are stored in `All-Users`.
 
+Draft comments are stored in `All-Users` too.
+
+Permissions are stored in Git, in a branch `refs/meta/config` for the
+repository. Repository configuration (including permissions) supports
+single inheritance, with the `All-Projects` repository containing
+site-wide defaults.
+
+Code review metadata is stored in Git, alongside the code under
+review. Metadata includes change status, votes, comments. This review
+metadata is stored in NoteDb along with the submitted code and code
+under review. Hence, the review history can be exported with `git
+clone --mirror` by anyone with sufficient permissions.
+
+== Permissions
+
+Permissions are specified on branch names, and given to groups. For
+example,
+
+```
+[access "refs/heads/stable/*"]
+        push = group Release-Engineers
+```
+
+this provides a rule, granting Release-Engineers push permission for
+stable branches.
+
+There are fundamentally two types of permissions:
+
+* Write permissions (who can vote, push, submit etc.)
+
+* Read permissions (who can see data)
+
+Read permissions need special treatment across Gerrit, because Gerrit
+should only surface data (including repository existence) if a user
+has read permission. This means that
+
+* The git wire protocol support must omit references from
+  advertisement if the user lacks read permissions
+
+* Uploads through the git wire protocol must refuse commits that are
+  based on SHA-1s for data that the user can't see.
+
+* Tags are only visible if their commits are visible to user through a
+  non-tag reference.
+
+Metadata (eg. OAuth credentials) is also stored in Git. Existing
+endpoints must refuse creating branches or changes that expose these
+metadata or allow changes to them.
+
+
+=== Indexing
+
+Almost all data is stored as Git, but Git only supports fast lookup by
+SHA-1 or by ref (branch) name. Therefore Gerrit also has an indexing
+system (powered by Lucene by default) for other types of queries.
+There are 4 indices:
+
+* Project index - find repositories by name, parent project, etc.
+* Account index - find accounts by name, email, etc.
+* Group index - find groups by name, owner, description etc.
+* Change index - find changes by file, status, modification date etc.
+
+The base entities are characterized by SHA-1s. Storing the
+characterizing SHA-1s allows detection of stale index entries.
+
+== Plug-in architecture
+
+Gerrit has a plug-in architecture. Plugins can be installed by
+dropping them into $site_directory/plugins, or at runtime through
+plugin SSH commands, or the plugin REST API.
+
+=== Backend plugins
+
+At runtime, code can be loaded from a `.jar` file. This code can hook
+into predefined extension points. A common use of plugins is to have
+Gerrit interoperate with site-specific tools, such as CI-systems or
+issue trackers.
+
+// list some notable extension points, and notable plugins
+// link to plugin development
+
+Some backend plugins expose the JVM for scripting use (eg. Groovy,
+Scala), so plugins can be written without having to setup a Java
+development environment.
+
+// Luca to expand: how do script plugins load their scripts?
+
+=== Frontend plugins
+
+The UI can be extended using Frontend plugins. This is useful for
+changing the look & feel of Gerrit, but it can also be used to surface
+data from systems that aren't integrated with the Gerrit backend, eg.
+CI systems or code coverage providers.
+
+// FE team to write a bit more:
+// * how to load ?
+// * XSRF, CORS ?
 
 == Internationalization and Localization
 
@@ -189,14 +250,11 @@
 and comments in English, and therefore an English user interface
 is usable by the target user base.
 
-Right-to-left (RTL) support is only barely considered within the
-Gerrit code base.  Some portions of the code have tried to take
-RTL into consideration, while others probably need to be modified
-before translating the UI to an RTL language.
-
 
 == Accessibility Considerations
 
+// UI team to rewrite this.
+
 Whenever possible Gerrit displays raw text rather than image icons,
 so screen readers should still be able to provide useful information
 to blind persons accessing Gerrit sites.
@@ -215,7 +273,9 @@
 
 == Browser Compatibility
 
-Supporting non-JavaScript enabled browsers is a non-goal for Gerrit.
+Gerrit requires a JavaScript enabled browser.
+
+// UI team to add section on minimum browser requirements.
 
 As Gerrit is a pure JavaScript application on the client side, with
 no server side rendering fallbacks, the browser must support modern
@@ -223,50 +283,19 @@
 Dumb clients such as `lynx`, `wget`, `curl`, or even many search engine
 spiders are not able to access Gerrit content.
 
-There are number of web browsers available with full JavaScript
-support, and nearly every operating system (including any PDA-like
-mobile phone) comes with one standard.  Users who are committed
-to developing changes for a Gerrit managed project can be expected
-to be able to run a JavaScript enabled browser, as they also would
-need to be running Git in order to contribute.
-
-There are a number of open source browsers available, including
-Firefox and Chromium.  Users have some degree of choice in their
-browser selection, including being able to build and audit their
-browser from source.
-
-The majority of the content stored within Gerrit is also available
-through other means, such as gitweb or the `git://` protocol.
-Any existing search engine spider can crawl the server-side HTML
-produced by gitweb, and thus can index the majority of the changes
-which might appear in Gerrit.  Some engines may even choose to
-crawl the native version control database, such as ohloh.net does.
-Therefore the lack of support for most search engine spiders is a
-non-issue for most Gerrit deployments.
+All of the content stored within Gerrit is also available through
+other means, such as gitweb or the `git://` protocol. Any existing
+search engine crawlers can index the server-side HTML served by a code
+browser, and thus can index the majority of the changes which might
+appear in Gerrit. Therefore the lack of support for most search engine
+crawlers is a non-issue for most Gerrit deployments.
 
 
 == Product Integration
 
-Gerrit integrates with an existing gitweb installation by optionally
-creating hyperlinks to reference changes on the gitweb server.
-
-Gerrit integrates with an existing git-daemon installation by
-optionally displaying `git://` URLs for users to download a
-change through the native Git protocol.
-
-Gerrit integrates with any OpenID provider for user authentication,
-making it easier for users to join a Gerrit site and manage their
-authentication credentials to it.
-
-Site administrators may limit the range of OpenID providers to
-a subset of "reliable providers".  Users may continue to use
-any OpenID provider to publish comments, but granted privileges
-are only available to a user if the only entry point to their
-account is through the defined set of "reliable OpenID providers".
-This permits site administrators to require HTTPS for OpenID,
-and to use only large main-stream providers that are trustworthy,
-or to require users to only use a custom OpenID provider installed
-alongside Gerrit Code Review.
+Gerrit optionally surfaces links to HTML pages in a code browser. The
+links are configurable, and Gerrit comes with a built-in code browser,
+called Gitiles.
 
 Gerrit integrates with some types of corporate single-sign-on (SSO)
 solutions, typically by having the SSO authentication be performed
@@ -286,16 +315,17 @@
 Gerrit does not integrate with any Google service, or any other
 services other than those listed above.
 
+Plugins (see above) can be used to drive product integrations from the
+Gerrit side. Products that support Gerrit explicitly can use the REST
+API or the SSH API to contact Gerrit.
+
+
 == Privacy Considerations
 
 Gerrit stores the following information per user account:
 
 * Full Name
 * Preferred Email Address
-* Mailing Address '(Optional, Encrypted)'
-* Country '(Optional, Encrypted)'
-* Phone Number '(Optional, Encrypted)'
-* Fax Number '(Optional, Encrypted)'
 
 The full name and preferred email address fields are shown to any
 site visitor viewing a page containing a change uploaded by the
@@ -321,271 +351,145 @@
 The user's name and email address is stored unencrypted in the
 link:config-accounts.html#all-users[All-Users] repository.
 
-The snail-mail mailing address, country, and phone and fax numbers
-are gathered to help project leads contact the user should there
-be a legal question regarding any change they have uploaded.
-
-These sensitive fields are immediately encrypted upon receipt with
-a GnuPG public key, and stored "off site" in another data store,
-isolated from the main Gerrit change data.  Gerrit does not have
-access to the matching private key, and as such cannot decrypt the
-information.  Therefore these fields are write-once in Gerrit, as not
-even the account owner can recover the values they previously stored.
-
-It is expected that the address information would only need to be
-decrypted and revealed with a valid court subpoena, but this is
-really left to the discretion of the Gerrit site administrator as
-to when it is reasonable to reveal this information to a 3rd party.
-
-
 == Spam and Abuse Considerations
 
-Gerrit makes no attempt to detect spam changes or comments.  The
-somewhat high barrier to entry makes it unlikely that a spammer
-will target Gerrit.
+There is no spam protection for the Git protocol upload path.
+Uploading a change successfully requires a pre-existing account, and a
+lot of up-front effort.
 
-To upload a change, the client must speak the native Git protocol
-embedded in SSH, with some custom Gerrit semantics added on top.
-The client must have their public key already stored in the Gerrit
-database, which can only be done through the XSRF protected
-JSON-RPC interface.  The level of effort required to construct
-the necessary tools to upload a well-formatted change that isn't
-rejected outright by the Git and Gerrit checksum validations is
-too high to for a spammer to get any meaningful return.
+Gerrit makes no attempt to detect spam changes or comments in the web
+UI. To post and publish a comment a client must sign in and then use
+the XSRF protected JSON-RPC interface to publish the draft on an
+existing change record.
 
-To post and publish a comment a client must sign in with an OpenID
-provider and then use the XSRF protected JSON-RPC interface to
-publish the draft on an existing change record.  Again, the level of
-effort required to implement the Gerrit specific XSRF protections
-and the JSON-RPC payload format necessary to post a draft and then
-publish that draft is simply too high for a spammer to bother with.
-
-Both of these assumptions are also based upon the idea that Gerrit
-will be a lot less popular than blog software, and thus will be
-running on a lot fewer websites.  Spammers therefore have very little
-returned benefit for getting over the protocol hurdles.
-
-These assumptions may need to be revisited in the future if any
-public Gerrit site actually notices spam.
-
-
-== Latency
-
-Gerrit targets for sub-250 ms per page request, mostly by using
-very compact JSON payloads between client and server.  However, as
-most of the serving stack (network, hardware, metadata
-database) is out of control of the Gerrit developers, no real
-guarantees can be made about latency.
+Absence of SPAM handling is based upon the idea that Gerrit caters to
+a niche audience, and will therefore be unattractive to spammers. In
+addition, it is not a factor for corporate, on-premise deployments.
 
 
 == Scalability
 
-Gerrit is designed for a very large scale open source project, or
-large commercial development project.  Roughly this amounts to
-parameters such as the following:
+Gerrit supports the Git wire protocol, and an API (one API for HTTP,
+and one for SSH).
 
-.Design Parameters
-[options="header"]
-|======================================================
-|Parameter        | Default Maximum | Estimated Maximum
-|Projects         |         1,000   | 10,000
-|Contributors     |         1,000   | 50,000
-|Changes/Day      |           100   |  2,000
-|Revisions/Change |            20   |     20
-|Files/Change     |            50   | 16,000
-|Comments/File    |           100   |    100
-|Reviewers/Change |             8   |      8
-|======================================================
+The git wire protocol does a client/server negotiation to avoid
+sending too much data. This negotation occupies a CPU, so the number
+of concurrent push/fetch operations should be capped by the number of
+CPUs.
 
-Out of the box, Gerrit will handle the "Default Maximum". Site
-administrators may reconfigure their servers by editing gerrit.config
-to run closer to the estimated maximum if sufficient memory is made
-available to the JVM and the relevant cache.*.memoryLimit variables
-are increased from their defaults.
-
-=== Discussion
-
-Very few, if any open source projects have more than a handful of
-Git repositories associated with them.  Since Gerrit treats each
-Git repository as a project, an upper limit of 10,000 projects
-is reasonable.  If a site has more than 1,000 projects, administrators
-should increase
-link:config-gerrit.html#cache.name.memoryLimit[`cache.projects.memoryLimit`]
-to match.
-
-Almost no open source project has 1,000 contributors over all time,
-let alone on a daily basis.  This default figure of 1,000 was WAG'd by
-looking at PR statements published by cell phone companies picking
-up the Android operating system.  If all of the stated employees in
-those PR statements were working on *only* the open source Android
-repositories, we might reach the 1,000 estimate listed here.  Knowing
-these companies as being very closed-source minded in the past, it
-is very unlikely all of their Android engineers will be working on
-the open source repository, and thus 1,000 is a very high estimate.
-
-The upper maximum of 50,000 contributors is based on existing
-installations that are already handling quite a bit more than the
-default maximum of 1,000 contributors. Given how the user data is
-stored and indexed, supporting 50,000 contributor accounts (or more)
-is easily possible for a server. If a server has more than 1,000
-*active* contributors,
-link:config-gerrit.html#cache.name.memoryLimit[`cache.accounts.memoryLimit`]
-should be increased by the site administrator, if sufficient RAM
-is available to the host JVM.
-
-The estimate of 100 changes per day was WAG'd off some estimates
-originally obtained from Android's development history.  Writing a
-good change that will be accepted through a peer-review process
-takes time.  The average engineer may need 4-6 hours per change just
-to write the code and unit tests.  Proper design consideration and
-additional but equally important tasks such as meetings, interviews,
-training, and eating lunch will often pad the engineer's day out
-such that suitable changes are only posted once a day, or once
-every other day.  For reference, the entire Linux kernel has an
-average of only 79 changes/day. If more than 100 changes are active
-per day, site administrators should consider increasing the
-link:config-gerrit.html#cache.name.memoryLimit[`cache.diff.memoryLimit`]
-and `cache.diff_intraline.memoryLimit`.
-
-On average any given change will need to be modified once to address
-peer review comments before the final revision can be accepted by the
-project.  Executing these revisions also eats into the contributor's
-time, and is another factor limiting the number of changes/day
-accepted by the Gerrit instance.  However, even though this implies
-only 2 revisions/change, many existing Gerrit installations have seen
-20 or more revisions/change, when new contributors are learning the
-project's style and conventions.
-
-On average, each change will have 2 reviewers, a human and an
-automated test bed system.  Usually this would be the project lead, or
-someone who is familiar with the code being modified.  The time
-required to comment further reduces the time available for writing
-one's own changes.  However, existing Gerrit installations have seen 8
-or more reviewers frequently show up on changes that impact many
-functional areas, and therefore it is reasonable to expect 8 or more
-reviewers to be able to work together on a single change.
-
-Existing installations have successfully processed change reviews with
-more than 16,000 files per change. However, since 16,000 modified/new
-files is a massive amount of code to review, it is more typical to see
-less than 10 files modified in any single change. Changes larger than
-10 files are typically merges, for example integrating the latest
-version of an upstream library, where the reviewer has little to do
-beyond verifying the project compiles and passes a test suite.
-
-=== CPU Usage - Web UI
-
-Gerrit's web UI would require on average `4+F+F*C` HTTP requests to
-review a change and post comments.  Here `F` is the number of files
-modified by the change, and `C` is the number of inline/file comments
-left by the reviewer per file.  The constant 4 accounts for the request
-to load the reviewer's dashboard, to load the change detail page,
-to publish the review comments, and to reload the change detail
-page after comments are published.
-
-This WAG'd estimate boils down to 216,000 HTTP requests per day
-(QPD). Assuming these are evenly distributed over an 8 hour work day
-in a single time zone, we are looking at approximately 7.5 queries
-per second (QPS).
-
-----
-  QPD = Changes_Day * Revisions_Change * Reviewers_Change * (4 +  F +  F * C)
-      = 2,000       * 2                * 1                * (4 + 10 + 10 * 4)
-      = 216,000
-  QPS = QPD / 8_Hours / 60_Minutes / 60_Seconds
-      = 7.5
-----
-
-Gerrit serves most requests in under 60 ms when using the loopback
-interface and a single processor.  On a single CPU system there is
-sufficient capacity for 16 QPS.  A dual processor system should be
-more than sufficient for a site with the estimated load described above.
-
-Given a more realistic estimate of 79 changes per day (from the
-Linux kernel) suggests only 8,532 queries per day, and a much lower
-0.29 QPS when spread out over an 8 hour work day.
-
-=== CPU Usage - Git over SSH/HTTP
-
-A 24 core server is able to handle ~25 concurrent `git fetch`
-operations per second. The issue here is each concurrent operation
-demands one full core, as the computation is almost entirely server
-side CPU bound. 25 concurrent operations is known to be sufficient to
-support hundreds of active developers and 50 automated build servers
-polling for updates and building every change.  (This data was derived
-from an actual installation's performance.)
-
-Because of the distributed nature of Git, end-users don't need to
-contact the central Gerrit Code Review server very often. For `git
-fetch` traffic, link:pgm-daemon.html[replica mode] is known to be an
-effective way to offload traffic from the main server, permitting it
-to scale to a large user base without needing an excessive number of
-cores in a single system.
-
-Clients on very slow network connections (for example home office
-users on VPN over home DSL) may be network bound rather than server
-side CPU bound, in which case a core may be effectively shared with
-another user. Possible core sharing due to network bottlenecks
+Clients on slow network connections may be network bound rather than
+server side CPU bound, in which case a core may be effectively shared
+with another user. Possible core sharing due to network bottlenecks
 generally holds true for network connections running below 10 MiB/sec.
 
-If the server's own network interface is 1 Gib/sec (Gigabit Ethernet),
-the system can really only serve about 10 concurrent clients at the
-10 MiB/sec speed, no matter how many cores it has.
+Deployments for large, distributed companies can replicate Git data to
+read-only replicas to offload fetch traffic. The read-only replicas
+should also serve this data using Gerrit to ensure that permissions
+are obeyed.
 
-=== Disk Usage
+The API serves requests of varying costs. Requests that originate in
+the UI can block productivity, so care has been taken to optimize
+these for latency, using the following techniques:
 
-The average size of a revision in the Linux kernel once compressed by
-Git is 2,327 bytes, or roughly 2 KiB.  Over the course of a year a
-Gerrit server running with the estimated maximum parameters above might
-see an introduction of 1.4 GiB over the total set of 10,000 projects
-hosted in that server.  This figure assumes the majority of the content
-is human written source code, and not large binary blobs such as disk
-images or media files.
+* Async calls: the UI becomes responsive before some UI elements
+  finished loading
 
-Production Gerrit installations have been tested, and are known to
-handle Git repositories in the multigigabyte range, storing binary
-files, ranging in size from a few kilobytes (for example compressed
-icons) to 800+ megabytes (firmware images, large uncompressed original
-artwork files).  Best practices encourage breaking very large binary
-files into their Git repositories based on access, to prevent desktop
-clients from needing to clone unnecessary materials (for example a C
-developer does not need every 800+ megabyte firmware image created by
-the product's quality assurance team).
+* Caching: metadata is stored in Git, which is relatively expensive to
+  access. This is sped up by multiple caches. Metadata entities are
+  stored in Git, and can therefore be seen as immutable values keyed
+  by SHA-1, which is very amenable to caching. All SHA-1 keyed caches
+  can be persisted on local disk.
+
+  The size (memory, disk) of these caches should be adapted to the
+  instance size (number of users, size and quantity of repositories)
+  for optimal performance.
+
+Git does not impose fundamental limits (eg. number of files per
+change) on data. To ensure stability, Gerrit configures a number of
+default limits for these.
+
+// add a link to the default settings.
+
+=== Scaling team size
+
+A team of size N has N^2 possible interactions. As a result, features
+that expose interactions with activities of other team members has a
+quadratic cost in aggregate. The following features scale poorly with
+large team sizes:
+
+* the change screen shows conflicting changes by default. This data is
+  cached, but updates to pending changes cause cache misses. For a
+  single change, the amount of work is proportional to the number of
+  pending changes, so in aggregate, the cost of this feature is
+  quadratic in the team size.
+
+* the change screen shows if a change is mergeable to the target
+  branch. If the target branch moves quickly (large developer team),
+  this causes cache misses. In aggregate, the cost of this feature is
+  also quadratic.
+
+Both features should be turned off for repositories that involve 1000s
+of developers.
+
+=== Browser performance
+
+// say something about browser performance tuning.
+
+=== Real life numbers
+
+
+Gerrit is designed for very large projects, both open source and
+proprietary commercial projects. For a single Gerrit process, the
+following limits are known to work:
+
+.Observed maximums
+[options="header"]
+|======================================================
+|Parameter        |         Maximum | Deployment
+|Projects         |         50,000  | gerrithub.io
+|Contributors     |        150,000  | eclipse.org
+|Bytes/repo       |        100G     | Qualcomm internal
+|Changes/repo     |        300k     | Qualcomm internal
+|Revisions/Change |        300      | Qualcomm internal
+|Reviewers/Change |        87       | Qualcomm internal
+|======================================================
+
+
+// find some numbers for these stats:
+// |Files/repo       |        ? |
+// |Files/Change     |        ? |
+// |Comments/Change  |        ? |
+// |max QPS/CPU      |        ? |
+
+
+Google runs a horizontally scaled deployment. We have seen the
+following per-JVM maximums:
+
+.Observed maximums (googlesource.com)
+[options="header"]
+|======================================================
+|Parameter        |         Maximum | Deployment
+|Files/repo       |        500,000  | chromium-review
+|Bytes/repo       |         12G     | chromium-review
+|Changes/repo     |          500k   | chromium-review
+|Revisions/Change |          1900   | chromium-review
+|Files/Change     |           10,000| android-review
+|Comments/Change  |           1,200 | chromium-review
+|======================================================
+
 
 == Redundancy & Reliability
 
-Gerrit largely assumes that the local filesystem where Git repository
-data is stored is always available.  Important data written to disk
-is also forced to the platter with an `fsync()` once it has been
-fully written.  If the local filesystem fails to respond to reads
-or becomes corrupt, Gerrit has no provisions to fallback or retry
-and errors will be returned to clients.
+Gerrit is structured as a single JVM process, reading and writing to a
+single file system. If there are hardware failures in the machine
+running the JVM, or the storage holding the repositories, there is no
+recourse; on failure, errors will be returned to the client.
 
-Gerrit largely assumes that the metadata database is online and
-answering both read and write queries.  Query failures immediately
-result in the operation aborting and errors being returned to the
-client, with no retry or fallback provisions.
+Deployments needing more stringent uptime guarantees can use
+replication/multi-master setup, which ensures availability and
+geographical distribution, at the cost of slower write actions.
 
-Due to the relatively small scale described above, it is very likely
-that the Git filesystem and metadata database are all housed on the
-same server that is running Gerrit.  If any failure arises in one of
-these components, it is likely to manifest in the others too.  It is
-also likely that the administrator cannot be bothered to deploy a
-cluster of load-balanced server hardware, as the scale and expected
-load does not justify the hardware or management costs.
-
-Most deployments caring about reliability will setup a warm-spare
-standby system and use a manual fail-over process to switch from the
-failed system to the warm-spare.
-
-As Git is a distributed version control system, and open source
-projects tend to have contributors from all over the world, most
-contributors will be able to tolerate a Gerrit down time of several
-hours while the administrator is notified, signs on, and brings the
-warm-spare up.  Pending changes are likely to need at least 24 hours
-of time on the Gerrit site anyway in order to ensure any interested
-parties around the world have had a chance to comment.  This expected
-lag largely allows for some downtime in a disaster scenario.
+// TODO: link.
 
 === Backups
 
@@ -599,7 +503,8 @@
 
 == Logging Plan
 
-Gerrit does not maintain logs on its own.
+Gerrit stores Apache style HTTPD logs, as well as ERROR/INFO messages
+from the Java logger, under `$site_dir/logs/`.
 
 Published comments contain a publication date, so users can judge
 when the comment was posted and decide if it was "recent" or not.
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index bbe227a..e18d7b0 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -118,7 +118,7 @@
 
 == Testing
 
-=== PolyGerrit UI is served by `server.go` process. To launch it,
+=== The Gerrit web app UI is served by `server.go` process. To launch it,
 run this command:
 
 ----
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 03e8ce6..3d0d6f9 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -5,8 +5,8 @@
 This page describes how plugins for Gerrit can be developed and hosted
 on gerrit-review.googlesource.com.
 
-For PolyGerrit-specific plugin development, consult with
-link:pg-plugin-dev.html[PolyGerrit Plugin Development] guide.
+For JavaScript plugin development, consult with
+link:pg-plugin-dev.html[JavaScript Plugin Development] guide.
 
 Depending on how tightly the extension code is coupled with the Gerrit
 server code, there is a distinction between `plugins` and `extensions`.
@@ -809,6 +809,35 @@
 }
 ----
 
+To provide additional Guice bindings for options to a command in another classloader, bind a
+ModulesClassNamesProvider which provides the name of your Modules needed for your DynamicBean
+in the other classLoader.
+
+Do this by binding to the name of the command you are going to bind to and providing an
+Iterable of Module names to instantiate and add to the Injector used to instantiate the
+DynamicBean in the other classLoader. This interface supports running LifecycleListeners
+which are defined by the Modules being provided. The duration of the lifecycle starts when
+a ssh or http request starts and ends when the request completes.
+
+[source, java]
+----
+bind(DynamicOptions.DynamicBean.class)
+    .annotatedWith(Exports.named(
+        "com.google.gerrit.plugins.otherplugin.command"))
+    .to(MyOptionsModulesClassNamesProvider.class);
+
+static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ModulesClassNamesProvider {
+  @Override
+  public String getClassName() {
+    return "com.googlesource.gerrit.plugins.myplugin.CommandOptions";
+  }
+  @Override
+  public Iterable<String> getModulesClassNames()() {
+    return "com.googlesource.gerrit.plugins.myplugin.MyOptionsModule";
+  }
+}
+----
+
 Plugins can receive a bean object for each of the gerrit ssh and the REST API
 commands by implementing BeanParseListener interface and registering it to a
 command class name in the plugin module's `configure()` method. The below
@@ -1034,17 +1063,9 @@
 Runtime exceptions generated by the implementors of ChangePluginDefinedInfoFactory
 are encapsulated in PluginDefinedInfo objects which are part of SSH/REST query output.
 
-=== ChangeAttributeFactory
-
-Alternatively, there is also `ChangeAttributeFactory` which takes in one single
-`ChangeData` at a time. `ChangePluginDefinedInfoFactory` should be preferred
-over this as it handles many changes at once which also decreases the round-trip
-time for queries resulting in performance increase for bulk queries.
-
-Implementors of the `ChangePluginDefinedInfoFactory` and `ChangeAttributeFactory`
-interfaces should check whether they need to contribute to the
-link:#change-etag-computation[change ETag computation] to prevent callers using
-ETags from potentially seeing outdated plugin attributes.
+Implementors of the `ChangePluginDefinedInfoFactory` interface should check whether
+they need to contribute to the link:#change-etag-computation[change ETag computation]
+to prevent callers using ETags from potentially seeing outdated plugin attributes.
 
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
@@ -1480,141 +1501,22 @@
   [...]
 ----
 
+[post_review_extensions]
+== Post Review Extensions
+
+By implementing the `com.google.gerrit.server.restapi.change.OnPostReview`
+interface plugins can extend the change message that is being posted when the
+link:rest-api-changes.html#set-review[post review] REST endpoint is invoked.
+
+This is useful if certain approvals have a special meaning (e.g. custom logic
+that is implemented in Prolog submit rules, signal for triggering an action
+like running CI etc.), as it allows the plugin to tell users about this meaning
+in the change message. This makes the effect of a given approval more
+transparent to the user.
+
 [[ui_extension]]
 == UI Extension
 
-[[panels]]
-=== Panels
-
-UI plugins can contribute panels to Gerrit screens.
-
-Gerrit screens define extension points where plugins can add GWT
-panels with custom controls:
-
-* Change Screen:
-** `GerritUiExtensionPoint.CHANGE_SCREEN_HEADER`:
-+
-Panel will be shown in the header bar to the right of the change
-status.
-
-** `GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_BUTTONS`:
-+
-Panel will be shown in the header bar on the right side of the buttons.
-
-** `GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_POP_DOWNS`:
-+
-Panel will be shown in the header bar on the right side of the pop down
-buttons.
-
-** `GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK`:
-+
-Panel will be shown below the commit info block.
-
-** `GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK`:
-+
-Panel will be shown below the change info block.
-
-** `GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK`:
-+
-Panel will be shown below the related info block.
-
-** `GerritUiExtensionPoint.CHANGE_SCREEN_HISTORY_RIGHT_OF_BUTTONS`:
-+
-Panel will be shown in the history bar on the right side of the buttons.
-
-** The following parameters are provided:
-*** `GerritUiExtensionPoint.Key.CHANGE_INFO`:
-+
-The link:rest-api-changes.html#change-info[ChangeInfo] entity for the
-current change.
-+
-The link:rest-api-changes.html#revision-info[RevisionInfo] entity for
-the current patch set.
-
-* Project Info Screen:
-** `GerritUiExtensionPoint.PROJECT_INFO_SCREEN_TOP`:
-+
-Panel will be shown at the top of the screen.
-
-** `GerritUiExtensionPoint.PROJECT_INFO_SCREEN_BOTTOM`:
-+
-Panel will be shown at the bottom of the screen.
-
-** The following parameters are provided:
-*** `GerritUiExtensionPoint.Key.PROJECT_NAME`:
-+
-The name of the project.
-
-* User Password Screen:
-** `GerritUiExtensionPoint.PASSWORD_SCREEN_BOTTOM`:
-+
-Panel will be shown at the bottom of the screen.
-
-** The following parameters are provided:
-*** `GerritUiExtensionPoint.Key.ACCOUNT_INFO`:
-+
-The link:rest-api-accounts.html#account-info[AccountInfo] entity for
-the current user.
-
-* User Preferences Screen:
-** `GerritUiExtensionPoint.PREFERENCES_SCREEN_BOTTOM`:
-+
-Panel will be shown at the bottom of the screen.
-
-** The following parameters are provided:
-*** `GerritUiExtensionPoint.Key.ACCOUNT_INFO`:
-+
-The link:rest-api-accounts.html#account-info[AccountInfo] entity for
-the current user.
-
-* User Profile Screen:
-** `GerritUiExtensionPoint.PROFILE_SCREEN_BOTTOM`:
-+
-Panel will be shown at the bottom of the screen below the grid with the
-profile data.
-
-** The following parameters are provided:
-*** `GerritUiExtensionPoint.Key.ACCOUNT_INFO`:
-+
-The link:rest-api-accounts.html#account-info[AccountInfo] entity for
-the current user.
-
-Example panel:
-[source,java]
-----
-public class MyPlugin extends PluginEntryPoint {
-  @Override
-  public void onPluginLoad() {
-    Plugin.get().panel(GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
-        "my_panel_name",
-        new Panel.EntryPoint() {
-          @Override
-          public void onLoad(Panel panel) {
-            panel.setWidget(new InlineLabel("My Panel for change "
-                + panel.getInt(GerritUiExtensionPoint.Key.CHANGE_ID, -1));
-          }
-        });
-  }
-}
-----
-
-Change Screen panel ordering may be specified in the
-project config. Values may be either "plugin name" or
-"plugin name"."panel name".
-Panels not specified in the config will be added
-to the end in load order. Panels specified in the config that
-are not found will be ignored.
-
-Example config:
-----
-[extension-panels "CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK"]
-  panel = helloworld.change_id
-  panel = myotherplugin
-  panel = myplugin.my_panel_name
-----
-
-
-
 [[actions]]
 === Actions
 
@@ -2249,7 +2151,7 @@
 light-weight plugin that links commits to external
 tools (GitBlit, CGit, company specific resources etc).
 
-PatchSetWebLinks will appear to the right of the commit-SHA1 in the UI.
+PatchSetWebLinks will appear to the right of the commit-SHA-1 in the UI.
 
 [source, java]
 ----
@@ -2264,7 +2166,8 @@
   private String imageUrl = "http://placehold.it/16x16.gif";
 
   @Override
-  public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+  public WebLinkInfo getPatchSetWebLink(String projectName, String commit,
+   String commitMessage, String branchName) {
     return new WebLinkInfo(name,
         imageUrl,
         String.format(placeHolderUrlProjectCommit, project, commit),
@@ -2273,7 +2176,7 @@
 }
 ----
 
-ParentWebLinks will appear to the right of the SHA1 of the parent
+ParentWebLinks will appear to the right of the SHA-1 of the parent
 revisions in the UI. The implementation should in most use cases direct
 to the same external service as PatchSetWebLink; it is provided as a
 separate interface because not all users want to have links for the
@@ -2479,6 +2382,32 @@
 Macros that start with `\` such as `\@KEEP@` will render as `@KEEP@`
 even if there is an expansion for `KEEP` in the future.
 
+Documentation should typically contain the following content:
+
+[width="100%",options="header"]
+|===================================================
+|File                                           | Content
+|`README.md`                                    | Home page of the plugin when browsing its source code on Git
+|`LICENSE`                                      | Open-source license
+|`resources/Documentation/about.md`             | Overview of the plugin and its purpose
+|`resources/Documentation/config.md`            | Plugin configuration settings and sample configs
+|`resources/Documentation/build.md`             | How to build the plugin
+|`resources/Documentation/cmd-<command>.md`     | SSH commands
+|`resources/Documentation/rest-api-<api>.md`    | REST API
+|`resources/Documentation/servlet-<servlet>.md` | HTTP Servlets
+|===================================================
+
+The documentation under resources/Documentation may contain macro that
+will be included and expanded by Gerrit once the plugin is loaded.
+
+The files in the root directory are not included in the plugin package
+and must not have any macro for expansion. It may also collect
+additional information that would make the plugin more discoverable, such as
+a more user-friendly description of its use-cases.
+
+The documentation can also include images that can help understanding more
+visually how the plugin can interact with the other Gerrit components.
+
 [[auto-index]]
 === Automatic Index
 
@@ -2537,23 +2466,18 @@
 Compiled plugins and extensions can be deployed to a running Gerrit
 server using the link:cmd-plugin-install.html[plugin install] command.
 
-Web UI plugins distributed as a single `.js` file (or `.html` file for
-Polygerrit) can be deployed without the overhead of JAR packaging. For
-more information refer to link:cmd-plugin-install.html[plugin install]
-command.
+Web UI plugins distributed as a single `.js` file can be deployed without the
+overhead of JAR packaging. For more information refer to
+link:cmd-plugin-install.html[plugin install] command.
 
 Plugins can also be copied directly into the server's directory at
-`$site_path/plugins/$name.(jar|js|html)`. For Web UI plugins, the name
-of the file, minus the `.js` or `.html` extension, will be used as the
+`$site_path/plugins/$name.(jar|js)`. For Web UI plugins, the name
+of the file, minus the `.js` extension, will be used as the
 plugin name. For JAR plugins, the value of the `Gerrit-PluginName`
 manifest attribute will be used, if provided, otherwise the name of
 the file, minus the `.jar` extension, will be used.
 
-For Web UI plugins, the plugin version is derived from the filename.
-If the filename contains one or more hyphens, the version is taken
-from the portion following the last hyphen. For example if the plugin
-filename is `my-plugin-1.0.js` the version will be `1.0`. For JAR
-plugins, the version is taken from the `Version` attribute in the
+For JAR plugins, the version is taken from the `Version` attribute in the
 manifest.
 
 Unless disabled, servers periodically scan the `$site_path/plugins`
@@ -2984,7 +2908,7 @@
 
 == SEE ALSO
 
-* link:js-api.html[JavaScript API]
+* link:pg-plugin-dev.html[JavaScript Plugin API]
 * link:dev-rest-api.html[REST API Developers' Notes]
 
 GERRIT
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index 5049831..e43e021 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -89,6 +89,15 @@
 already has dedicated seats in the steering committee (see section
 link:#steering-committee[steering committee]).
 
+If a non-Google seat on the steering committee becomes vacant before
+the current term ends, an exceptional election is conducted in order
+to replace the member(s) leaving the committee. The election will
+follow the same procedure as regular steering committee elections.
+The number of votes each maintainer gets in such exceptional election
+matches the number of seats to be filled. The term of the new member
+of the steering committee ends at the end of the current term of
+the steering committee when the next regular election concludes.
+
 [[contribution-process]]
 == Contribution Process
 
@@ -262,6 +271,15 @@
 It's also possible that the ESC decides that an issue is not a security issue
 and the embargo is lifted immediately.
 
+. Filing a CVE
++
+For every security issue a CVE that describes the issue and lists the affected
+releases should be filed. Filing a CVE can be done by any maintainer that works
+for an organization that can request CVE numbers (e.g. Googlers). The CVE
+number must be included in the release notes. The CVE itself is only made
+public after fixed released have been published and the embargo has been
+lifted.
+
 . Implementation of the security fix:
 +
 To keep the embargo intact, security fixes cannot be developed and reviewed in
@@ -325,6 +343,8 @@
 This ends the embargo and any issue that discusses the security vulnerability
 should be made public.
 
+. Publish the CVE
+
 . Follow-Up
 +
 The ESC should discuss if there are any learnings from the security
diff --git a/Documentation/dev-rest-api.txt b/Documentation/dev-rest-api.txt
index fec9c97..a28e230 100644
--- a/Documentation/dev-rest-api.txt
+++ b/Documentation/dev-rest-api.txt
@@ -50,6 +50,11 @@
  curl -X PUT --user john:2LlAB3K9B0PF --data-binary @project-desc.txt --header "Content-Type: application/json; charset=UTF-8" http://localhost:8080/a/projects/myproject/description
 ----
 
+[[pretty-json]]
+=== Pretty JSON
+
+By default any JSON in responses is compacted. To get pretty-printed JSON add `pp=1` to the request.
+
 === Authentication
 
 To test APIs that require authentication, the username and password must be specified on
diff --git a/Documentation/glossary.txt b/Documentation/glossary.txt
new file mode 100644
index 0000000..2b40b5b
--- /dev/null
+++ b/Documentation/glossary.txt
@@ -0,0 +1,50 @@
+:linkattrs:
+= Glossary
+
+[[event]]
+== Event
+
+It refers to the link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/java/com/google/gerrit/server/events/Event.java[com.google.gerrit.server.events.Event]
+base abstract class representing any possible action that is generated or
+received in a Gerrit instance. Actions can be associated with change set status
+updates, project creations, indexing of changes, etc.
+
+[[event-broker]]
+== Event broker
+
+Distributes Gerrit Events to listeners if they are allowed to see them.
+
+[[event-dispatcher]]
+== Event dispatcher
+
+Interface for posting events to the Gerrit event system. Implemented by default
+by link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/java/com/google/gerrit/server/events/EventBroker.java[com.google.gerrit.server.events.EventBroker].
+It can be implemented by plugins and allows to influence how events are managed.
+
+[[event-hierarchy]]
+== Event hierarchy
+
+Hierarchy of events representing anything that can happen in Gerrit.
+
+[[event-listener]]
+== Event listener
+
+API for listening to Gerrit events from plugins, without having any
+visibility restrictions.
+
+[[stream-events]]
+== Stream events
+
+Command that allows a user via CLI or a plugin to receive in a sequential way
+some events that are generated in Gerrit. The consumption of the stream by default
+is available via SSH connection.
+However, plugins can provide an alternative implementation of the event
+brokering by sending them over a reliable messaging queueing system (RabbitMQ)
+or a pub-sub (Kafka).
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/images/cross-repository-changes-add-topic.png b/Documentation/images/cross-repository-changes-add-topic.png
new file mode 100644
index 0000000..fc85b8f
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-add-topic.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-cp-menu.png b/Documentation/images/cross-repository-changes-cp-menu.png
new file mode 100644
index 0000000..e9004f8
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-cp-menu.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-cp-modal.png b/Documentation/images/cross-repository-changes-cp-modal.png
new file mode 100644
index 0000000..a4790fb
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-cp-modal.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-example.png b/Documentation/images/cross-repository-changes-example.png
new file mode 100644
index 0000000..e790f71
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-example.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-revert-topic-options.png b/Documentation/images/cross-repository-changes-revert-topic-options.png
new file mode 100644
index 0000000..f2e9f1a
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-revert-topic-options.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-revert-topic.png b/Documentation/images/cross-repository-changes-revert-topic.png
new file mode 100644
index 0000000..8d87191
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-revert-topic.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-submit-topic.png b/Documentation/images/cross-repository-changes-submit-topic.png
new file mode 100644
index 0000000..7e96743
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-submit-topic.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-submitted-together.png b/Documentation/images/cross-repository-changes-submitted-together.png
new file mode 100644
index 0000000..e7ea334
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-submitted-together.png
Binary files differ
diff --git a/Documentation/images/cross-repository-changes-topic.png b/Documentation/images/cross-repository-changes-topic.png
new file mode 100644
index 0000000..12d0e38
--- /dev/null
+++ b/Documentation/images/cross-repository-changes-topic.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-cannot-merge.png b/Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-change-info-cannot-merge.png
rename to Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info.png b/Documentation/images/gwt-user-review-ui-change-screen-change-info.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-change-info.png
rename to Documentation/images/gwt-user-review-ui-change-screen-change-info.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-update.png b/Documentation/images/gwt-user-review-ui-change-screen-change-update.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-change-update.png
rename to Documentation/images/gwt-user-review-ui-change-screen-change-update.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-commit-info-merge-commit.png b/Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-commit-info-merge-commit.png
rename to Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-commit-info.png b/Documentation/images/gwt-user-review-ui-change-screen-commit-info.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-commit-info.png
rename to Documentation/images/gwt-user-review-ui-change-screen-commit-info.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-history.png b/Documentation/images/gwt-user-review-ui-change-screen-history.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-history.png
rename to Documentation/images/gwt-user-review-ui-change-screen-history.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-included-in-list.png b/Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-included-in-list.png
rename to Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-inline-comments.png b/Documentation/images/gwt-user-review-ui-change-screen-inline-comments.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-inline-comments.png
rename to Documentation/images/gwt-user-review-ui-change-screen-inline-comments.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-not-current.png b/Documentation/images/gwt-user-review-ui-change-screen-not-current.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-not-current.png
rename to Documentation/images/gwt-user-review-ui-change-screen-not-current.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-permalink.png b/Documentation/images/gwt-user-review-ui-change-screen-permalink.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-permalink.png
rename to Documentation/images/gwt-user-review-ui-change-screen-permalink.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-plugin-extensions.png b/Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-plugin-extensions.png
rename to Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-reply-to-comment.png b/Documentation/images/gwt-user-review-ui-change-screen-reply-to-comment.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-reply-to-comment.png
rename to Documentation/images/gwt-user-review-ui-change-screen-reply-to-comment.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-reply.png b/Documentation/images/gwt-user-review-ui-change-screen-reply.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-reply.png
rename to Documentation/images/gwt-user-review-ui-change-screen-reply.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-replying.png b/Documentation/images/gwt-user-review-ui-change-screen-replying.png
similarity index 100%
rename from Documentation/images/user-review-ui-change-screen-replying.png
rename to Documentation/images/gwt-user-review-ui-change-screen-replying.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-comment-box.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-comment-box.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-comment-edit.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-comment-edit.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-comment-reply.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-comment-reply.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-comment.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-comment.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-commented.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-commented.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-navigation.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-navigation.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-no-differences.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-no-differences.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-patch-sets.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-patch-sets.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-project-and-file.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-project-and-file.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-rename.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-rename.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-rename.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-rename.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-replied-done.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen-replied-done.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen.png
similarity index 100%
rename from Documentation/images/user-review-ui-side-by-side-diff-screen.png
rename to Documentation/images/gwt-user-review-ui-side-by-side-diff-screen.png
Binary files differ
diff --git a/Documentation/images/inline-edit-enter-edit-mode-from-diff.png b/Documentation/images/inline-edit-enter-edit-mode-from-diff.png
deleted file mode 100644
index 46dd0ff..0000000
--- a/Documentation/images/inline-edit-enter-edit-mode-from-diff.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-enter-edit-mode-from-file-list.png b/Documentation/images/inline-edit-enter-edit-mode-from-file-list.png
deleted file mode 100644
index b8c52c9..0000000
--- a/Documentation/images/inline-edit-enter-edit-mode-from-file-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-file-list-in-edit-mode.png b/Documentation/images/inline-edit-file-list-in-edit-mode.png
deleted file mode 100644
index 8f355335..0000000
--- a/Documentation/images/inline-edit-file-list-in-edit-mode.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-full-screen-editor.png b/Documentation/images/inline-edit-full-screen-editor.png
deleted file mode 100644
index 474fae5..0000000
--- a/Documentation/images/inline-edit-full-screen-editor.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/intro-quick-hot-key-help.jpg b/Documentation/images/intro-quick-hot-key-help.jpg
deleted file mode 100644
index 41bcbe4..0000000
--- a/Documentation/images/intro-quick-hot-key-help.jpg
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-checks-overview.png b/Documentation/images/user-checks-overview.png
new file mode 100644
index 0000000..7a9864e
--- /dev/null
+++ b/Documentation/images/user-checks-overview.png
Binary files differ
diff --git a/Documentation/images/user-porting-comments-original-comment.png b/Documentation/images/user-porting-comments-original-comment.png
new file mode 100644
index 0000000..f8a62ee
--- /dev/null
+++ b/Documentation/images/user-porting-comments-original-comment.png
Binary files differ
diff --git a/Documentation/images/user-porting-comments-ported-comment.png b/Documentation/images/user-porting-comments-ported-comment.png
new file mode 100644
index 0000000..4e4a1b4
--- /dev/null
+++ b/Documentation/images/user-porting-comments-ported-comment.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-annotated.png b/Documentation/images/user-review-ui-change-screen-annotated.png
new file mode 100644
index 0000000..5c3f80a
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-annotated.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-actions.png b/Documentation/images/user-review-ui-change-screen-change-info-actions.png
index fd17d27..7c96861 100644
--- a/Documentation/images/user-review-ui-change-screen-change-info-actions.png
+++ b/Documentation/images/user-review-ui-change-screen-change-info-actions.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-labels.png b/Documentation/images/user-review-ui-change-screen-change-info-labels.png
index 2655089e..61e2b25 100644
--- a/Documentation/images/user-review-ui-change-screen-change-info-labels.png
+++ b/Documentation/images/user-review-ui-change-screen-change-info-labels.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-last-update.png b/Documentation/images/user-review-ui-change-screen-change-info-last-update.png
deleted file mode 100644
index 93c296a..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-last-update.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-owner.png b/Documentation/images/user-review-ui-change-screen-change-info-owner.png
deleted file mode 100644
index 3d73ef7..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-owner.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-project-branch-topic.png b/Documentation/images/user-review-ui-change-screen-change-info-project-branch-topic.png
deleted file mode 100644
index acba408..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-project-branch-topic.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-reviewers.png b/Documentation/images/user-review-ui-change-screen-change-info-reviewers.png
deleted file mode 100644
index 1f253ab..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-reviewers.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-submit-strategy.png b/Documentation/images/user-review-ui-change-screen-change-info-submit-strategy.png
deleted file mode 100644
index abc1239..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-submit-strategy.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-cherry-picks.png b/Documentation/images/user-review-ui-change-screen-cherry-picks.png
deleted file mode 100644
index 552d639..0000000
--- a/Documentation/images/user-review-ui-change-screen-cherry-picks.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-commit-message.png b/Documentation/images/user-review-ui-change-screen-commit-message.png
deleted file mode 100644
index acc78aa..0000000
--- a/Documentation/images/user-review-ui-change-screen-commit-message.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-conflicts-with.png b/Documentation/images/user-review-ui-change-screen-conflicts-with.png
deleted file mode 100644
index d74c934..0000000
--- a/Documentation/images/user-review-ui-change-screen-conflicts-with.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-download-commands-list.png b/Documentation/images/user-review-ui-change-screen-download-commands-list.png
deleted file mode 100644
index b12e1f0..0000000
--- a/Documentation/images/user-review-ui-change-screen-download-commands-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-download-commands.png b/Documentation/images/user-review-ui-change-screen-download-commands.png
index 6facd21..bccc61c 100644
--- a/Documentation/images/user-review-ui-change-screen-download-commands.png
+++ b/Documentation/images/user-review-ui-change-screen-download-commands.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-comments.png b/Documentation/images/user-review-ui-change-screen-file-list-comments.png
deleted file mode 100644
index 9e8b6be..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-comments.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-header.png b/Documentation/images/user-review-ui-change-screen-file-list-header.png
deleted file mode 100644
index c682d88..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-header.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-mark-as-reviewed.png b/Documentation/images/user-review-ui-change-screen-file-list-mark-as-reviewed.png
deleted file mode 100644
index deb625d..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-mark-as-reviewed.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-modification-type.png b/Documentation/images/user-review-ui-change-screen-file-list-modification-type.png
deleted file mode 100644
index d8057b3..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-modification-type.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-rename.png b/Documentation/images/user-review-ui-change-screen-file-list-rename.png
deleted file mode 100644
index 364e6c5..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-rename.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-repeating-paths.png b/Documentation/images/user-review-ui-change-screen-file-list-repeating-paths.png
deleted file mode 100644
index f73d6bf..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-repeating-paths.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list-size.png b/Documentation/images/user-review-ui-change-screen-file-list-size.png
deleted file mode 100644
index 4148b17..0000000
--- a/Documentation/images/user-review-ui-change-screen-file-list-size.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list.png b/Documentation/images/user-review-ui-change-screen-file-list.png
index 39c0e2b..721b229 100644
--- a/Documentation/images/user-review-ui-change-screen-file-list.png
+++ b/Documentation/images/user-review-ui-change-screen-file-list.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-included-in.png b/Documentation/images/user-review-ui-change-screen-included-in.png
index c2a900c..868368d 100644
--- a/Documentation/images/user-review-ui-change-screen-included-in.png
+++ b/Documentation/images/user-review-ui-change-screen-included-in.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-info-reviewers.png b/Documentation/images/user-review-ui-change-screen-info-reviewers.png
new file mode 100644
index 0000000..d79643e
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-info-reviewers.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
index 734ab29..9ef8f27 100644
--- a/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
+++ b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-patch-set-list.png b/Documentation/images/user-review-ui-change-screen-patch-set-list.png
deleted file mode 100644
index ef03135..0000000
--- a/Documentation/images/user-review-ui-change-screen-patch-set-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-patch-sets.png b/Documentation/images/user-review-ui-change-screen-patch-sets.png
index 45b4089..79dd664 100644
--- a/Documentation/images/user-review-ui-change-screen-patch-sets.png
+++ b/Documentation/images/user-review-ui-change-screen-patch-sets.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-quick-approve.png b/Documentation/images/user-review-ui-change-screen-quick-approve.png
index 638fc2f..f692d07 100644
--- a/Documentation/images/user-review-ui-change-screen-quick-approve.png
+++ b/Documentation/images/user-review-ui-change-screen-quick-approve.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-related-changes-indicators.png b/Documentation/images/user-review-ui-change-screen-related-changes-indicators.png
deleted file mode 100644
index d0a997e..0000000
--- a/Documentation/images/user-review-ui-change-screen-related-changes-indicators.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-related-changes.png b/Documentation/images/user-review-ui-change-screen-related-changes.png
deleted file mode 100644
index 9d2d84c..0000000
--- a/Documentation/images/user-review-ui-change-screen-related-changes.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-same-topic.png b/Documentation/images/user-review-ui-change-screen-same-topic.png
deleted file mode 100644
index 18896ab..0000000
--- a/Documentation/images/user-review-ui-change-screen-same-topic.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-star.png b/Documentation/images/user-review-ui-change-screen-star.png
deleted file mode 100644
index d438ca0..0000000
--- a/Documentation/images/user-review-ui-change-screen-star.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-topleft.png b/Documentation/images/user-review-ui-change-screen-topleft.png
new file mode 100644
index 0000000..a1f7813
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-topleft.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen.png b/Documentation/images/user-review-ui-change-screen.png
index 8952cac..ff2570b 100644
--- a/Documentation/images/user-review-ui-change-screen.png
+++ b/Documentation/images/user-review-ui-change-screen.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-column.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-column.png
deleted file mode 100644
index b599f6d..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-column.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-dark-theme.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-dark-theme.png
deleted file mode 100644
index c041311..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-dark-theme.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png
index ea14a21..1eb7665 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-comment.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-comment.png
index 8406ce8..66c46b7 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-comment.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-comment.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-commented.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-commented.png
deleted file mode 100644
index 1fd2033..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-file-level-commented.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-intraline-difference.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-intraline-difference.png
deleted file mode 100644
index 044f96f..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-intraline-difference.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences-popup.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences-popup.png
deleted file mode 100644
index 043c1ff..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences-popup.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences.png
index 7373b2f..e008f2b 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-preferences.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-red-bar.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-red-bar.png
deleted file mode 100644
index f817d66..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-red-bar.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-reviewed.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-reviewed.png
index c767452..e2a7957 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-reviewed.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-reviewed.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-scrollbar.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-scrollbar.png
deleted file mode 100644
index cbadd26..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-scrollbar.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-search.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-search.png
deleted file mode 100644
index e69bb0d..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-search.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-syntax-coloring.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-syntax-coloring.png
deleted file mode 100644
index a4b019a..0000000
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-syntax-coloring.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 8f36ecc..dc94b14 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -38,6 +38,7 @@
 ... link:user-changeid.html[Change-Id Lines]
 ... link:user-signedoffby.html[Signed-off-by Lines]
 ... link:user-change-cleanup.html[Change Cleanup]
+... link:cross-repository-changes.html[Cross Repository Changes using Topics]
 
 == Project Management
 . link:project-configuration.html[Project Configuration]
@@ -74,10 +75,12 @@
 . link:config-reverseproxy.html[Reverse Proxy]
 . link:config-auto-site-initialization.html[Automatic Site Initialization on Startup]
 . link:pgm-index.html[Server Side Administrative Tools]
+. link:repository-maintenance.html[Repository Maintenance]
 . link:user-request-tracing.html[Request Tracing]
 . link:note-db.html[NoteDb]
 . link:config-accounts.html[Accounts on NoteDb]
 . link:config-groups.html[Groups on NoteDb]
+. link:user-privacy.html[User data and privacy]
 
 == Concepts
 . link:config-labels.html[Review Labels]
@@ -85,6 +88,7 @@
 . link:concept-changes.html[Changes]
 . link:concept-refs-for-namespace.html[The refs/for Namespace]
 . link:concept-patch-sets.html[Patch Sets]
+. link:glossary.html[Glossary]
 
 == Resources
 * link:licenses.html[Licenses and Notices]
diff --git a/Documentation/intro-gerrit-walkthrough-github.txt b/Documentation/intro-gerrit-walkthrough-github.txt
index f16155b..8f3ff88 100644
--- a/Documentation/intro-gerrit-walkthrough-github.txt
+++ b/Documentation/intro-gerrit-walkthrough-github.txt
@@ -206,8 +206,8 @@
 When you `git commit --amend` to iterate on your change, you might be worried that
 you are changing your previous commit and may thus lose that state of your work.
 However, here the Change-Id appended to your commit message comes into play.
-While the SHA1 hash of your change (the commit ID used by Git) might change, the
-Change-Id stays the same (in fact it is the SHA1 hash of the very first version
+While the SHA-1 hash of your change (the commit ID used by Git) might change, the
+Change-Id stays the same (in fact it is the SHA-1 hash of the very first version
 of that commit). When this amended commit is uploaded to the Gerrit server,
 Gerrit knows that this commit is really an iteration of that previous commit
 (and the associated review) and will preserve both, the old and the new state.
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 7f932da..8a3b10e 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -634,9 +634,9 @@
 The Gerrit functionality can be extended by plugins and there are many
 extension points, e.g. plugins can
 +
-** link:dev-plugins.html#top-menu-extensions[add new menu entries]
-** link:dev-plugins.html#ui_extension[extend existing screens] and
-   link:dev-plugins.html#screen[add new screens]
+** link:pg-plugin-admin-api.html[add new menu entries]
+** link:pg-plugin-endpoints.html[hook into DOM elements] and
+   link:pg-plugin-dev.html#plugin-screen[add new pages]
 ** link:config-validation.html[do validation], e.g. of new commits
 ** add new REST endpoints and link:dev-plugins.html#ssh[SSH commands]
 
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 9909aac..dac1c6b 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -514,6 +514,9 @@
   $ git push origin HEAD:refs/heads/master -o topic=multi-master
 ----
 
+For more information about using topics, see the user guide:
+link:cross-repository-changes.html[Submitting Changes Across Repositories by using Topics].
+
 [[hashtags]]
 == Using Hashtags
 
diff --git a/Documentation/js-api.txt b/Documentation/js-api.txt
deleted file mode 100644
index 893ab36..0000000
--- a/Documentation/js-api.txt
+++ /dev/null
@@ -1,486 +0,0 @@
-= Gerrit Code Review - JavaScript API
-
-Gerrit Code Review supports an API for JavaScript plugins to interact
-with the web UI and the server process.
-
-== Entry Point
-
-JavaScript is loaded using a standard `<script src='...'>` HTML tag.
-Plugins should protect the global namespace by defining their code
-within an anonymous function passed to `Gerrit.install()`. The plugin
-will be passed an object describing its registration with Gerrit:
-
-[source,javascript]
-----
-Gerrit.install(function (self) {
-  // ... plugin JavaScript code here ...
-});
-----
-
-
-[[self]]
-== Plugin Instance
-
-The plugin instance is passed to the plugin's initialization function
-and provides a number of utility services to plugin authors.
-
-[[self_getServerInfo]]
-=== self.getServerInfo()
-Returns the server's link:rest-api-config.html#server-info[ServerInfo]
-data.
-
-[[self_getPluginName]]
-=== self.getPluginName()
-Returns the name this plugin was installed as by the server
-administrator. The plugin name is required to access REST API
-views installed by the plugin, or to access resources.
-
-[[self_on]]
-=== self.on()
-Register a JavaScript callback to be invoked when events occur within
-the web interface.
-
-.Signature
-[source,javascript]
-----
-self.on(event, callback);
-----
-
-* event: A supported event type. See below for description.
-
-* callback: JavaScript function to be invoked when event happens.
-  Arguments may be passed to this function, depending on the event.
-
-Supported events:
-
-* `history`: Invoked when the view is changed to a new screen within
-  the Gerrit web application.  The token after "#" is passed as the
-  argument to the callback function, for example "/c/42/" while
-  showing change 42.
-
-* `showchange`: Invoked when a change is made visible. A
-  link:rest-api-changes.html#change-info[ChangeInfo] and
-  link:rest-api-changes.html#revision-info[RevisionInfo]
-  are passed as arguments. PolyGerrit provides a third parameter which
-  is an object with a `mergeable` boolean.
-
-* `submitchange`: Invoked when the submit button is clicked
-  on a change. A link:rest-api-changes.html#change-info[ChangeInfo]
-  and link:rest-api-changes.html#revision-info[RevisionInfo] are
-  passed as arguments. Similar to a form submit validation, the
-  function must return true to allow the operation to continue, or
-  false to prevent it. The function may be called multiple times, for
-  example, if submitting a change shows a confirmation dialog, this
-  event may be called to validate that the check whether dialog can be
-  shown, and called again when the submit is confirmed to check whether
-  the actual submission action can proceed.
-
-* `comment`: Invoked when a DOM element that represents a comment is
-  created. This DOM element is passed as argument. This DOM element
-  contains nested elements that Gerrit uses to format the comment. The
-  DOM structure may differ between comment types such as inline
-  comments, file-level comments and summary comments, and it may change
-  with new Gerrit versions.
-
-* `highlightjs-loaded`: Invoked when the highlight.js library has
-  finished loading. The global `hljs` object (also now accessible via
-  `window.hljs`) is passed as an argument to the callback function.
-  This event can be used to register a new language highlighter with
-  the highlight.js library before syntax highlighting begins.
-
-[[self_changeActions]]
-=== self.changeActions()
-Returns an instance of ChangeActions API.
-
-.Signature
-[source,javascript]
-----
-self.changeActions();
-----
-
-[[self_screen]]
-=== self.screen()
-Register a module to be attached when the user navigates
-to an extension screen provided by the plugin. Extension screens are
-usually linked from the link:dev-plugins.html#top-menu-extensions[top menu].
-
-.Signature
-[source,javascript]
-----
-self.screen(pattern, opt_moduleName);
-----
-
-* pattern: URL token pattern to identify the screen. Argument can be
-  either a string (`'index'`) or a RegExp object (`/list\/(.*)/`).
-  If a RegExp is used the matching groups will be available inside of
-  the context as `token_match`.
-
-* opt_moduleName: The module to load when the user navigates to
-  the screen. The function will be passed a link:#ScreenContext[screen context].
-
-[[self_settings]]
-=== self.settings()
-Returns the Settings API.
-
-.Signature
-[source,javascript]
-----
-self.settings();
-----
-
-[[self_registerCustomComponent]]
-=== self.registerCustomComponent()
-Register a custom component to a specific endpoint.
-
-.Signature
-[source,javascript]
-----
-self.registerCustomComponent(endpointName, opt_moduleName, opt_options);
-----
-
-* endpointName: The endpoint this plugin should be reigistered to.
-
-* opt_moduleName: The module name the custom component will use.
-
-* opt_options: Options to register this custom component.
-
-[[self_url]]
-=== self.url()
-Returns a URL within the plugin's URL space. If invoked with no
-parameter the URL of the plugin is returned. If passed a string
-the argument is appended to the plugin URL.
-
-A plugin's URL is where this plugin is loaded, it doesn't
-necessary to be the same as the Gerrit host. Use `window.location`
-if you need to access the Gerrit host info.
-
-For preloaded plugins, the plugin url is based on a global
-configuration of where to load all plugins, default to current host.
-
-[source,javascript]
-----
-self.url();                    // "https://gerrit-review.googlesource.com/plugins/demo/"
-self.url('/static/icon.png');  // "https://gerrit-review.googlesource.com/plugins/demo/static/icon.png"
-----
-
-[[self_restApi]]
-=== self.restApi()
-Returns an instance of the Plugin REST API.
-
-.Signature
-[source,javascript]
-----
-self.restApi(prefix_url)
-----
-
-* prefix_url: Base url for subsequent .get(), .post() etc requests.
-
-[[PluginRestAPI]]
-== Plugin Rest API
-
-[[plugin_rest_delete]]
-=== restApi.delete()
-Issues a DELETE REST API request to the Gerrit server.
-Returns a promise with the response of the request.
-
-.Signature
-[source,javascript]
-----
-restApi.delete(url)
-----
-
-* url: URL relative to the base url.
-
-[[plugin_rest_get]]
-=== restApi.get()
-Issues a GET REST API request to the Gerrit server.
-Returns a promise with the response of the request.
-
-.Signature
-[source,javascript]
-----
-restApi.get(url)
-----
-
-* url: URL relative to the base url.
-
-[[plugin_rest_post]]
-=== restApi.post()
-Issues a POST REST API request to the Gerrit server.
-Returns a promise with the response of the request.
-
-.Signature
-[source,javascript]
-----
-restApi.post(url, opt_payload, opt_errFn, opt_contentType)
-----
-
-* url: URL relative to the base url.
-
-* opt_payload: JavaScript object to serialize as the request payload.
-
-* opt_errFn: JavaScript function to be invoked when error occured.
-
-* opt_contentType: Content-Type to be sent along with the request.
-
-[source,javascript]
-----
-restApi.post(
-  '/my-servlet',
-  {start_build: true, platform_type: 'Linux'});
-----
-
-[[plugin_rest_put]]
-=== restApi.put()
-Issues a PUT REST API request to the Gerrit server.
-Returns a promise with the response of the request.
-
-.Signature
-[source,javascript]
-----
-restApi.put(url, opt_payload, opt_errFn, opt_contentType)
-----
-
-* url: URL relative to the base url.
-
-* opt_payload: JavaScript object to serialize as the request payload.
-
-* opt_errFn: JavaScript function to be invoked when error occured.
-
-* opt_contentType: Content-Type to be sent along with the request.
-
-[source,javascript]
-----
-restApi.put(
-  '/builds',
-  {start_build: true, platform_type: 'Linux'});
-----
-
-[[ChangeActions]]
-== Change Actions API
-A new Change Actions API instance will be created when `changeActions()`
-is invoked.
-
-[[change_actions_add]]
-=== changeActions.add()
-Adds a new action to the change actions section.
-Returns the key of the newly added action.
-
-.Signature
-[source,javascript]
-----
-changeActions.add(type, label)
-----
-
-* type: The type of the action, either `change` or `revision`.
-
-* label: The label to be used in UI for this action.
-
-[source,javascript]
-----
-changeActions.add("change", "test")
-----
-
-[[change_actions_remove]]
-=== changeActions.remove()
-Removes an action from the change actions section.
-
-.Signature
-[source,javascript]
-----
-changeActions.remove(key)
-----
-
-* key: The key of the action.
-
-[[change_actions_addTapListener]]
-=== changeActions.addTapListener()
-Adds a tap listener to an action that will be invoked when the action
-is tapped.
-
-.Signature
-[source,javascript]
-----
-changeActions.addTapListener(key, callback)
-----
-
-* key: The key of the action.
-
-* callback: JavaScript function to be invoked when action tapped.
-
-[source,javascript]
-----
-changeActions.addTapListener("__key_for_my_action__", () => {
-  // do something when my action gets clicked
-})
-----
-
-[[change_actions_removeTapListener]]
-=== changeActions.removeTapListener()
-Removes an existing tap listener on an action.
-
-.Signature
-[source,javascript]
-----
-changeActions.removeTapListener(key, callback)
-----
-
-* key: The key of the action.
-
-* callback: JavaScript function to be removed.
-
-[[change_actions_setLabel]]
-=== changeActions.setLabel()
-Sets the label for an action.
-
-.Signature
-[source,javascript]
-----
-changeActions.setLabel(key, label)
-----
-
-* key: The key of the action.
-
-* label: The label of the action.
-
-[[change_actions_setTitle]]
-=== changeActions.setTitle()
-Sets the title for an action.
-
-.Signature
-[source,javascript]
-----
-changeActions.setTitle(key, title)
-----
-
-* key: The key of the action.
-
-* title: The title of the action.
-
-[[change_actions_setIcon]]
-=== changeActions.setIcon()
-Sets an icon for an action.
-
-.Signature
-[source,javascript]
-----
-changeActions.setIcon(key, icon)
-----
-
-* key: The key of the action.
-
-* icon: The name of the icon.
-
-[[change_actions_setEnabled]]
-=== changeActions.setEnabled()
-Sets an action to enabled or disabled.
-
-.Signature
-[source,javascript]
-----
-changeActions.setEnabled(key, enabled)
-----
-
-* key: The key of the action.
-
-* enabled: The status of the action, true to enable.
-
-[[change_actions_setActionHidden]]
-=== changeActions.setActionHidden()
-Sets an action to be hidden.
-
-.Signature
-[source,javascript]
-----
-changeActions.setActionHidden(type, key, hidden)
-----
-
-* type: The type of the action.
-
-* key: The key of the action.
-
-* hidden: True to hide the action, false to show the action.
-
-[[change_actions_setActionOverflow]]
-=== changeActions.setActionOverflow()
-Sets an action to show in overflow menu.
-
-.Signature
-[source,javascript]
-----
-changeActions.setActionOverflow(type, key, overflow)
-----
-
-* type: The type of the action.
-
-* key: The key of the action.
-
-* overflow: True to move the action to overflow menu, false to move
-  the action out of the overflow menu.
-
-[[PanelContext]]
-== Panel Context
-A new panel context is passed to the `panel` callback function each
-time a screen with the given extension point is loaded.
-
-[[panel_body]]
-=== panel.body
-Empty HTML `<div>` node the plugin should add the panel content to.
-The node is already attached to the document.
-
-[[PanelProperties]]
-=== Properties
-
-The extension panel parameters that are described in the
-link:dev-plugins.html#panels[plugin development documentation] are
-contained in the context as properties. Which properties are available
-depends on the extension point.
-
-[[Gerrit]]
-== Gerrit
-
-The `Gerrit` object is the only symbol provided into the global
-namespace by Gerrit Code Review. All top-level functions can be
-accessed through this name.
-
-[[Gerrit_css]]
-=== Gerrit.css()
-[WARNING]
-This method is deprecated. It doesn't work with Shadow DOM and
-will be removed in the future. Please, use link:pg-plugin-dev.html#plugin-styles[plugin.styles] instead.
-
-Creates a new unique CSS class and injects it into the document.
-The name of the class is returned and can be used by the plugin.
-
-Classes created with this function should be created once at install
-time and reused throughout the plugin.  Repeatedly creating the same
-class will explode the global stylesheet.
-
-.Signature
-[source,javascript]
-----
-Gerrit.install(function(self)) {
-  var style = {
-    name: Gerrit.css('background: #fff; color: #000;'),
-  };
-});
-----
-
-[[Gerrit_install]]
-=== Gerrit.install()
-Registers a new plugin by invoking the supplied initialization
-function. The function is passed the link:#self[plugin instance].
-
-[source,javascript]
-----
-Gerrit.install(function (self) {
-  // ... plugin JavaScript code here ...
-});
-----
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index ae494dd..e611ff8 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -247,6 +247,38 @@
 ----
 
 
+[[DefinitelyTyped]]
+DefinitelyTyped
+
+* @types/resize-observer-browser
+
+[[DefinitelyTyped_license]]
+----
+    MIT License
+
+    Copyright (c) Microsoft Corporation.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE
+
+----
+
+
 [[Polymer-2014]]
 Polymer-2014
 
@@ -330,15 +362,19 @@
 * @polymer/neon-animation
 * @polymer/paper-behaviors
 * @polymer/paper-button
+* @polymer/paper-card
 * @polymer/paper-dialog
 * @polymer/paper-dialog-behavior
 * @polymer/paper-dialog-scrollable
+* @polymer/paper-dropdown-menu
 * @polymer/paper-icon-button
 * @polymer/paper-input
 * @polymer/paper-item
 * @polymer/paper-listbox
+* @polymer/paper-menu-button
 * @polymer/paper-tabs
 * @polymer/paper-toggle-button
+* @polymer/paper-tooltip
 
 [[Polymer-2015_license]]
 ----
@@ -380,6 +416,52 @@
 ----
 
 
+[[Polymer-2016]]
+Polymer-2016
+
+* @polymer/iron-image
+* @polymer/paper-checkbox
+
+[[Polymer-2016_license]]
+----
+Copyright (c) 2016 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[Polymer-2017]]
 Polymer-2017
 
@@ -992,6 +1074,84 @@
 ----
 
 
+[[lit-element]]
+lit-element
+
+* lit-element
+
+[[lit-element_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017, The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[lit-html]]
+lit-html
+
+* lit-html
+
+[[lit-html_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017, The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[page]]
 page
 
@@ -1056,3 +1216,239 @@
 
 ----
 
+
+[[rxjs]]
+rxjs
+
+* rxjs
+
+[[rxjs_license]]
+----
+                               Apache License
+                         Version 2.0, January 2004
+                      http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+    "License" shall mean the terms and conditions for use, reproduction,
+    and distribution as defined by Sections 1 through 9 of this document.
+
+    "Licensor" shall mean the copyright owner or entity authorized by
+    the copyright owner that is granting the License.
+
+    "Legal Entity" shall mean the union of the acting entity and all
+    other entities that control, are controlled by, or are under common
+    control with that entity. For the purposes of this definition,
+    "control" means (i) the power, direct or indirect, to cause the
+    direction or management of such entity, whether by contract or
+    otherwise, or (ii) ownership of fifty percent (50%) or more of the
+    outstanding shares, or (iii) beneficial ownership of such entity.
+
+    "You" (or "Your") shall mean an individual or Legal Entity
+    exercising permissions granted by this License.
+
+    "Source" form shall mean the preferred form for making modifications,
+    including but not limited to software source code, documentation
+    source, and configuration files.
+
+    "Object" form shall mean any form resulting from mechanical
+    transformation or translation of a Source form, including but
+    not limited to compiled object code, generated documentation,
+    and conversions to other media types.
+
+    "Work" shall mean the work of authorship, whether in Source or
+    Object form, made available under the License, as indicated by a
+    copyright notice that is included in or attached to the work
+    (an example is provided in the Appendix below).
+
+    "Derivative Works" shall mean any work, whether in Source or Object
+    form, that is based on (or derived from) the Work and for which the
+    editorial revisions, annotations, elaborations, or other modifications
+    represent, as a whole, an original work of authorship. For the purposes
+    of this License, Derivative Works shall not include works that remain
+    separable from, or merely link (or bind by name) to the interfaces of,
+    the Work and Derivative Works thereof.
+
+    "Contribution" shall mean any work of authorship, including
+    the original version of the Work and any modifications or additions
+    to that Work or Derivative Works thereof, that is intentionally
+    submitted to Licensor for inclusion in the Work by the copyright owner
+    or by an individual or Legal Entity authorized to submit on behalf of
+    the copyright owner. For the purposes of this definition, "submitted"
+    means any form of electronic, verbal, or written communication sent
+    to the Licensor or its representatives, including but not limited to
+    communication on electronic mailing lists, source code control systems,
+    and issue tracking systems that are managed by, or on behalf of, the
+    Licensor for the purpose of discussing and improving the Work, but
+    excluding communication that is conspicuously marked or otherwise
+    designated in writing by the copyright owner as "Not a Contribution."
+
+    "Contributor" shall mean Licensor and any individual or Legal Entity
+    on behalf of whom a Contribution has been received by Licensor and
+    subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+    this License, each Contributor hereby grants to You a perpetual,
+    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+    copyright license to reproduce, prepare Derivative Works of,
+    publicly display, publicly perform, sublicense, and distribute the
+    Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+    this License, each Contributor hereby grants to You a perpetual,
+    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+    (except as stated in this section) patent license to make, have made,
+    use, offer to sell, sell, import, and otherwise transfer the Work,
+    where such license applies only to those patent claims licensable
+    by such Contributor that are necessarily infringed by their
+    Contribution(s) alone or by combination of their Contribution(s)
+    with the Work to which such Contribution(s) was submitted. If You
+    institute patent litigation against any entity (including a
+    cross-claim or counterclaim in a lawsuit) alleging that the Work
+    or a Contribution incorporated within the Work constitutes direct
+    or contributory patent infringement, then any patent licenses
+    granted to You under this License for that Work shall terminate
+    as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+    Work or Derivative Works thereof in any medium, with or without
+    modifications, and in Source or Object form, provided that You
+    meet the following conditions:
+
+    (a) You must give any other recipients of the Work or
+        Derivative Works a copy of this License; and
+
+    (b) You must cause any modified files to carry prominent notices
+        stating that You changed the files; and
+
+    (c) You must retain, in the Source form of any Derivative Works
+        that You distribute, all copyright, patent, trademark, and
+        attribution notices from the Source form of the Work,
+        excluding those notices that do not pertain to any part of
+        the Derivative Works; and
+
+    (d) If the Work includes a "NOTICE" text file as part of its
+        distribution, then any Derivative Works that You distribute must
+        include a readable copy of the attribution notices contained
+        within such NOTICE file, excluding those notices that do not
+        pertain to any part of the Derivative Works, in at least one
+        of the following places: within a NOTICE text file distributed
+        as part of the Derivative Works; within the Source form or
+        documentation, if provided along with the Derivative Works; or,
+        within a display generated by the Derivative Works, if and
+        wherever such third-party notices normally appear. The contents
+        of the NOTICE file are for informational purposes only and
+        do not modify the License. You may add Your own attribution
+        notices within Derivative Works that You distribute, alongside
+        or as an addendum to the NOTICE text from the Work, provided
+        that such additional attribution notices cannot be construed
+        as modifying the License.
+
+    You may add Your own copyright statement to Your modifications and
+    may provide additional or different license terms and conditions
+    for use, reproduction, or distribution of Your modifications, or
+    for any such Derivative Works as a whole, provided Your use,
+    reproduction, and distribution of the Work otherwise complies with
+    the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+    any Contribution intentionally submitted for inclusion in the Work
+    by You to the Licensor shall be under the terms and conditions of
+    this License, without any additional terms or conditions.
+    Notwithstanding the above, nothing herein shall supersede or modify
+    the terms of any separate license agreement you may have executed
+    with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+    names, trademarks, service marks, or product names of the Licensor,
+    except as required for reasonable and customary use in describing the
+    origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+    agreed to in writing, Licensor provides the Work (and each
+    Contributor provides its Contributions) on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+    implied, including, without limitation, any warranties or conditions
+    of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+    PARTICULAR PURPOSE. You are solely responsible for determining the
+    appropriateness of using or redistributing the Work and assume any
+    risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+    whether in tort (including negligence), contract, or otherwise,
+    unless required by applicable law (such as deliberate and grossly
+    negligent acts) or agreed to in writing, shall any Contributor be
+    liable to You for damages, including any direct, indirect, special,
+    incidental, or consequential damages of any character arising as a
+    result of this License or out of the use or inability to use the
+    Work (including but not limited to damages for loss of goodwill,
+    work stoppage, computer failure or malfunction, or any and all
+    other commercial damages or losses), even if such Contributor
+    has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+    the Work or Derivative Works thereof, You may choose to offer,
+    and charge a fee for, acceptance of support, warranty, indemnity,
+    or other liability obligations and/or rights consistent with this
+    License. However, in accepting such obligations, You may act only
+    on Your own behalf and on Your sole responsibility, not on behalf
+    of any other Contributor, and only if You agree to indemnify,
+    defend, and hold each Contributor harmless for any liability
+    incurred by, or claims asserted against, such Contributor by reason
+    of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+    To apply the Apache License to your work, attach the following
+    boilerplate notice, with the fields enclosed by brackets "[]"
+    replaced with your own identifying information. (Don't include
+    the brackets!)  The text should be enclosed in the appropriate
+    comment syntax for the file format. We also recommend that a
+    file or class name and description of purpose be included on the
+    same "printed page" as the copyright notice for easier
+    identification within third-party archives.
+
+ Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ 
+
+----
+
+
+[[tslib]]
+tslib
+
+* tslib
+
+[[tslib_license]]
+----
+Copyright (c) Microsoft Corporation.

+

+Permission to use, copy, modify, and/or distribute this software for any

+purpose with or without fee is hereby granted.

+

+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH

+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY

+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,

+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM

+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR

+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR

+PERFORMANCE OF THIS SOFTWARE.
+
+----
+
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 55ab4a0..0df8415 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -44,10 +44,12 @@
 
 * auto:auto-value
 * auto:auto-value-annotations
+* auto:auto-value-gson
 * commons:codec
 * commons:compress
 * commons:dbcp
 * commons:lang
+* commons:lang3
 * commons:net
 * commons:pool
 * commons:validator
@@ -82,6 +84,7 @@
 * mime4j:dom
 * mina:core
 * mina:sshd
+* mina:sshd-sftp
 * openid:consumer
 * openid:nekohtml
 * openid:xerces
@@ -923,6 +926,18 @@
 ----
 
 
+[[PublicDomain]]
+PublicDomain
+
+* guice:aopalliance
+
+[[PublicDomain_license]]
+----
+This software has been placed in the public domain by its author(s).
+
+----
+
+
 [[antlr]]
 antlr
 
@@ -2334,6 +2349,7 @@
 * jgit
 * jgit-archive
 * jgit-servlet
+* jgit-ssh-apache
 
 [[jgit_license]]
 ----
@@ -3190,6 +3206,38 @@
 ----
 
 
+[[DefinitelyTyped]]
+DefinitelyTyped
+
+* @types/resize-observer-browser
+
+[[DefinitelyTyped_license]]
+----
+    MIT License
+
+    Copyright (c) Microsoft Corporation.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE
+
+----
+
+
 [[Polymer-2014]]
 Polymer-2014
 
@@ -3273,15 +3321,19 @@
 * @polymer/neon-animation
 * @polymer/paper-behaviors
 * @polymer/paper-button
+* @polymer/paper-card
 * @polymer/paper-dialog
 * @polymer/paper-dialog-behavior
 * @polymer/paper-dialog-scrollable
+* @polymer/paper-dropdown-menu
 * @polymer/paper-icon-button
 * @polymer/paper-input
 * @polymer/paper-item
 * @polymer/paper-listbox
+* @polymer/paper-menu-button
 * @polymer/paper-tabs
 * @polymer/paper-toggle-button
+* @polymer/paper-tooltip
 
 [[Polymer-2015_license]]
 ----
@@ -3323,6 +3375,52 @@
 ----
 
 
+[[Polymer-2016]]
+Polymer-2016
+
+* @polymer/iron-image
+* @polymer/paper-checkbox
+
+[[Polymer-2016_license]]
+----
+Copyright (c) 2016 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[Polymer-2017]]
 Polymer-2017
 
@@ -3935,6 +4033,84 @@
 ----
 
 
+[[lit-element]]
+lit-element
+
+* lit-element
+
+[[lit-element_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017, The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[lit-html]]
+lit-html
+
+* lit-html
+
+[[lit-html_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017, The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[page]]
 page
 
@@ -4000,6 +4176,242 @@
 ----
 
 
+[[rxjs]]
+rxjs
+
+* rxjs
+
+[[rxjs_license]]
+----
+                               Apache License
+                         Version 2.0, January 2004
+                      http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+    "License" shall mean the terms and conditions for use, reproduction,
+    and distribution as defined by Sections 1 through 9 of this document.
+
+    "Licensor" shall mean the copyright owner or entity authorized by
+    the copyright owner that is granting the License.
+
+    "Legal Entity" shall mean the union of the acting entity and all
+    other entities that control, are controlled by, or are under common
+    control with that entity. For the purposes of this definition,
+    "control" means (i) the power, direct or indirect, to cause the
+    direction or management of such entity, whether by contract or
+    otherwise, or (ii) ownership of fifty percent (50%) or more of the
+    outstanding shares, or (iii) beneficial ownership of such entity.
+
+    "You" (or "Your") shall mean an individual or Legal Entity
+    exercising permissions granted by this License.
+
+    "Source" form shall mean the preferred form for making modifications,
+    including but not limited to software source code, documentation
+    source, and configuration files.
+
+    "Object" form shall mean any form resulting from mechanical
+    transformation or translation of a Source form, including but
+    not limited to compiled object code, generated documentation,
+    and conversions to other media types.
+
+    "Work" shall mean the work of authorship, whether in Source or
+    Object form, made available under the License, as indicated by a
+    copyright notice that is included in or attached to the work
+    (an example is provided in the Appendix below).
+
+    "Derivative Works" shall mean any work, whether in Source or Object
+    form, that is based on (or derived from) the Work and for which the
+    editorial revisions, annotations, elaborations, or other modifications
+    represent, as a whole, an original work of authorship. For the purposes
+    of this License, Derivative Works shall not include works that remain
+    separable from, or merely link (or bind by name) to the interfaces of,
+    the Work and Derivative Works thereof.
+
+    "Contribution" shall mean any work of authorship, including
+    the original version of the Work and any modifications or additions
+    to that Work or Derivative Works thereof, that is intentionally
+    submitted to Licensor for inclusion in the Work by the copyright owner
+    or by an individual or Legal Entity authorized to submit on behalf of
+    the copyright owner. For the purposes of this definition, "submitted"
+    means any form of electronic, verbal, or written communication sent
+    to the Licensor or its representatives, including but not limited to
+    communication on electronic mailing lists, source code control systems,
+    and issue tracking systems that are managed by, or on behalf of, the
+    Licensor for the purpose of discussing and improving the Work, but
+    excluding communication that is conspicuously marked or otherwise
+    designated in writing by the copyright owner as "Not a Contribution."
+
+    "Contributor" shall mean Licensor and any individual or Legal Entity
+    on behalf of whom a Contribution has been received by Licensor and
+    subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+    this License, each Contributor hereby grants to You a perpetual,
+    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+    copyright license to reproduce, prepare Derivative Works of,
+    publicly display, publicly perform, sublicense, and distribute the
+    Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+    this License, each Contributor hereby grants to You a perpetual,
+    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+    (except as stated in this section) patent license to make, have made,
+    use, offer to sell, sell, import, and otherwise transfer the Work,
+    where such license applies only to those patent claims licensable
+    by such Contributor that are necessarily infringed by their
+    Contribution(s) alone or by combination of their Contribution(s)
+    with the Work to which such Contribution(s) was submitted. If You
+    institute patent litigation against any entity (including a
+    cross-claim or counterclaim in a lawsuit) alleging that the Work
+    or a Contribution incorporated within the Work constitutes direct
+    or contributory patent infringement, then any patent licenses
+    granted to You under this License for that Work shall terminate
+    as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+    Work or Derivative Works thereof in any medium, with or without
+    modifications, and in Source or Object form, provided that You
+    meet the following conditions:
+
+    (a) You must give any other recipients of the Work or
+        Derivative Works a copy of this License; and
+
+    (b) You must cause any modified files to carry prominent notices
+        stating that You changed the files; and
+
+    (c) You must retain, in the Source form of any Derivative Works
+        that You distribute, all copyright, patent, trademark, and
+        attribution notices from the Source form of the Work,
+        excluding those notices that do not pertain to any part of
+        the Derivative Works; and
+
+    (d) If the Work includes a "NOTICE" text file as part of its
+        distribution, then any Derivative Works that You distribute must
+        include a readable copy of the attribution notices contained
+        within such NOTICE file, excluding those notices that do not
+        pertain to any part of the Derivative Works, in at least one
+        of the following places: within a NOTICE text file distributed
+        as part of the Derivative Works; within the Source form or
+        documentation, if provided along with the Derivative Works; or,
+        within a display generated by the Derivative Works, if and
+        wherever such third-party notices normally appear. The contents
+        of the NOTICE file are for informational purposes only and
+        do not modify the License. You may add Your own attribution
+        notices within Derivative Works that You distribute, alongside
+        or as an addendum to the NOTICE text from the Work, provided
+        that such additional attribution notices cannot be construed
+        as modifying the License.
+
+    You may add Your own copyright statement to Your modifications and
+    may provide additional or different license terms and conditions
+    for use, reproduction, or distribution of Your modifications, or
+    for any such Derivative Works as a whole, provided Your use,
+    reproduction, and distribution of the Work otherwise complies with
+    the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+    any Contribution intentionally submitted for inclusion in the Work
+    by You to the Licensor shall be under the terms and conditions of
+    this License, without any additional terms or conditions.
+    Notwithstanding the above, nothing herein shall supersede or modify
+    the terms of any separate license agreement you may have executed
+    with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+    names, trademarks, service marks, or product names of the Licensor,
+    except as required for reasonable and customary use in describing the
+    origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+    agreed to in writing, Licensor provides the Work (and each
+    Contributor provides its Contributions) on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+    implied, including, without limitation, any warranties or conditions
+    of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+    PARTICULAR PURPOSE. You are solely responsible for determining the
+    appropriateness of using or redistributing the Work and assume any
+    risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+    whether in tort (including negligence), contract, or otherwise,
+    unless required by applicable law (such as deliberate and grossly
+    negligent acts) or agreed to in writing, shall any Contributor be
+    liable to You for damages, including any direct, indirect, special,
+    incidental, or consequential damages of any character arising as a
+    result of this License or out of the use or inability to use the
+    Work (including but not limited to damages for loss of goodwill,
+    work stoppage, computer failure or malfunction, or any and all
+    other commercial damages or losses), even if such Contributor
+    has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+    the Work or Derivative Works thereof, You may choose to offer,
+    and charge a fee for, acceptance of support, warranty, indemnity,
+    or other liability obligations and/or rights consistent with this
+    License. However, in accepting such obligations, You may act only
+    on Your own behalf and on Your sole responsibility, not on behalf
+    of any other Contributor, and only if You agree to indemnify,
+    defend, and hold each Contributor harmless for any liability
+    incurred by, or claims asserted against, such Contributor by reason
+    of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+    To apply the Apache License to your work, attach the following
+    boilerplate notice, with the fields enclosed by brackets "[]"
+    replaced with your own identifying information. (Don't include
+    the brackets!)  The text should be enclosed in the appropriate
+    comment syntax for the file format. We also recommend that a
+    file or class name and description of purpose be included on the
+    same "printed page" as the copyright notice for easier
+    identification within third-party archives.
+
+ Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ 
+
+----
+
+
+[[tslib]]
+tslib
+
+* tslib
+
+[[tslib_license]]
+----
+Copyright (c) Microsoft Corporation.

+

+Permission to use, copy, modify, and/or distribute this software for any

+purpose with or without fee is hereby granted.

+

+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH

+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY

+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,

+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM

+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR

+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR

+PERFORMANCE OF THIS SOFTWARE.
+
+----
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/logs.txt b/Documentation/logs.txt
index f072984..e120fdd 100644
--- a/Documentation/logs.txt
+++ b/Documentation/logs.txt
@@ -51,6 +51,11 @@
 * `referer`: the `Referer` HTTP request header. This gives the site that
   the client reports having been referred from.
 * `client agent`: the client agent which sent the request.
+* `total_cpu`: total CPU time, CPU time in milliseconds to execute command.
+* `user_cpu`: user mode CPU time, CPU time in user mode in milliseconds to execute command.
+  CPU time in kernel mode is `total_cpu - user_cpu`.
+* `memory`: memory allocated in bytes to execute command. -1 if the JVM does
+  not support this metric.
 * `command status`: the overall result of the git command over HTTP. Currently
    populated only for the transfer phase of `git-upload-pack` commands.
    Possible values:
@@ -89,6 +94,11 @@
 * `wait`: command wait time, time in milliseconds the command waited for an execution thread.
 * `exec`: command execution time, time in milliseconds to execute the command.
 * `status`: status code. 0 means success, any other value is an error.
+* `total_cpu`: total CPU time, CPU time in milliseconds to execute command.
+* `user_cpu`: user mode CPU time, CPU time in user mode in milliseconds to execute command.
+  CPU time in kernel mode is `total_cpu - user_cpu`.
+* `memory`: memory allocated in bytes to execute command. -1 if the JVM does
+  not support this metric.
 
 The `git-upload-pack` command provides the following additional fields after the `exec`
 and before the `status` field. All times are in milliseconds. Fields are -1 if not available
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 74ebe144..7ac804c 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -50,6 +50,9 @@
 objects needing finalization.
 * `proc/jvm/gc/count`: Number of GCs.
 * `proc/jvm/gc/time`: Approximate accumulated GC elapsed time.
+* `proc/jvm/memory/pool/committed/<pool name>`: Committed amount of memory for pool.
+* `proc/jvm/memory/pool/max/<pool name>`: Maximum amount of memory for pool.
+* `proc/jvm/memory/pool/used/<pool name>`: Used amount of memory for pool.
 * `proc/jvm/thread/num_live`: Current live thread count.
 * `proc/jvm/thread/num_daemon_live`: Current live daemon threads count.
 * `proc/jvm/thread/num_peak_live`: Peak live thread count since the Java virtual machine started or peak was reset.
@@ -76,6 +79,12 @@
 * `change/submit_rule_evaluation`: Latency for evaluating submit rules on a change.
 * `change/submit_type_evaluation`: Latency for evaluating the submit type on a change.
 
+=== Comments
+
+* `ported_comments/as_patchset_level`: Total number of comments ported as patchset-level comments.
+* `ported_comments/as_file_level`: Total number of comments ported as file-level comments.
+* `ported_comments/as_range_comments`: Total number of comments having line/range values in the ported patchset.
+
 === HTTP
 
 ==== Jetty
@@ -187,6 +196,8 @@
 * `git/upload-pack/phase_compressing`: Time spent in the 'Compressing...' phase.
 * `git/upload-pack/phase_writing`: Time spent transferring bytes to client.
 * `git/upload-pack/pack_bytes`: Distribution of sizes of packs sent to clients.
+* `git/auto-merge/num_operations`: Number of auto merge operations and context.
+* `git/auto-merge/latency`: Latency of auto merge operations and context.
 
 === BatchUpdate
 
diff --git a/Documentation/pg-plugin-admin-api.txt b/Documentation/pg-plugin-admin-api.txt
index 1a41778..be31117 100644
--- a/Documentation/pg-plugin-admin-api.txt
+++ b/Documentation/pg-plugin-admin-api.txt
@@ -1,4 +1,4 @@
-= Gerrit Code Review - Admin customization API
+= Gerrit Code Review - JavaScript Plugin Admin API
 
 This API is provided by link:pg-plugin-dev.html#plugin-admin[plugin.admin()]
 and provides customization of the admin menu.
@@ -18,4 +18,4 @@
 create a link to open changes, use the value `/q/status:open`.
 
 See more about capability from
-link:rest-api-accounts.html#list-account-capabilities[List Account Capabilities].
\ No newline at end of file
+link:rest-api-accounts.html#list-account-capabilities[List Account Capabilities].
diff --git a/Documentation/pg-plugin-change-metadata-api.txt b/Documentation/pg-plugin-change-metadata-api.txt
deleted file mode 100644
index 8348da8..0000000
--- a/Documentation/pg-plugin-change-metadata-api.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-= Gerrit Code Review - Change metadata plugin API
-
-This API is provided by
-link:pg-plugin-dev.html#change-metadata[plugin.changeMetadata()] and provides
-interface for customization and data updates of change metadata.
-
-== onLabelsChanged
-`changeMetadataApi.onLabelsChanged(callback)`
-
-.Params
-- *callback* function that's executed when labels changed on the server.
-Callback receives labels with scores applied to the change, map of the label
-names to link:rest-api-changes.html#label-info[LabelInfo] entries
-
-.Returns
-- `GrChangeMetadataApi` for chaining.
diff --git a/Documentation/pg-plugin-checks-api.txt b/Documentation/pg-plugin-checks-api.txt
new file mode 100644
index 0000000..4e93da1
--- /dev/null
+++ b/Documentation/pg-plugin-checks-api.txt
@@ -0,0 +1,44 @@
+:linkattrs:
+= Gerrit Code Review - JavaScript Plugin Checks API
+
+This API is provided by link:pg-plugin-dev.html#plugin-checks[plugin.checks()].
+It allows plugins to contribute to the "Checks" tab and summary:
+
+image::images/user-checks-overview.png[width=800]
+
+Each plugin can link:#register[register] a checks provider that will be called
+when a change page is loaded. Such a call would return a list of `Runs` and each
+run can contain a list of `Results`.
+
+The details of the ChecksApi are documented in the
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/checks.ts[source code].
+Note that this link points to the `master` branch and might thus reflect a
+newer version of the API than your Gerrit installation.
+
+If no plugins are registered with the ChecksApi, then the Checks tab will be
+hidden.
+
+You can read about the motivation, the use cases and the original plans in the
+link:https://www.gerritcodereview.com/design-docs/ci-reboot.html[design doc].
+
+Here are some examples of open source plugins that make use of the Checks API:
+
+* link:https://gerrit.googlesource.com/plugins/checks/+/master/gr-checks/plugin.js[Gerrit Checks Plugin]
+* link:https://chromium.googlesource.com/infra/gerrit-plugins/buildbucket/+/master/src/main/resources/static/buildbucket.js[Chromium Buildbucket Plugin]
+* link:https://chromium.googlesource.com/infra/gerrit-plugins/code-coverage/+/master/src/main/resources/static/chromium-coverage.js[Chromium Coverage Plugin]
+
+[[register]]
+== register
+`checksApi.register(provider, config?)`
+
+.Params
+- *provider* Must implement a `fetch()` interface that returns a
+  `Promise<FetchResponse>` with runs and results. See also documentation in the
+  link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/checks.ts[source code].
+- *config* Optional configuration values for the checks provider.
+
+[[announceUpdate]]
+== announceUpdate
+`checksApi.announceUpdate()`
+
+Tells Gerrit to call `provider.fetch()`.
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 91bc476..dc7986f 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -1,39 +1,39 @@
 :linkattrs:
-= Gerrit Code Review - PolyGerrit Plugin Development
+= Gerrit Code Review - JavaScript Plugin Development and API
 
-CAUTION: Work in progress. Hard hat area. Please
-link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20plugins[send
-feedback,role=external,window=_blank] if something's not right.
-
-For migrating existing GWT UI plugins, please check out the
-link:pg-plugin-migration.html#migration[migration guide].
+Gerrit Code Review supports an API for JavaScript plugins to interact
+with the web UI and the server process.
 
 [[loading]]
 == Plugin loading and initialization
 
-link:js-api.html#_entry_point[Entry point] for the plugin.
+JavaScript is loaded using a standard `<script src='...'>` HTML tag.
+Plugins should protect the global namespace by defining their code
+within an anonymous function passed to `Gerrit.install()`. The plugin
+will be passed an object describing its registration with Gerrit.
 
 * The plugin provides pluginname.js, and can be a standalone file or a static
   asset in a jar as a link:dev-plugins.html#deployment[Web UI plugin].
 * pluginname.js contains a call to `Gerrit.install()`. There should
-  only be single `Gerrit.install()` per file.
-* PolyGerrit imports pluginname.js.
+  only be a single `Gerrit.install()` call per file.
+* The Gerrit web app imports pluginname.js.
 * For standalone plugins, the entry point file is a `pluginname.js` file
   located in `gerrit-site/plugins` folder, where `pluginname` is an alphanumeric
   plugin name.
 
-Note: Code examples target modern browsers (Chrome, Firefox, Safari, Edge).
-
+=== Examples
 Here's a recommended starter `myplugin.js`:
 
 ``` js
 Gerrit.install(plugin => {
-  'use strict';
-
   // Your code here.
 });
 ```
 
+You can find more elaborate examples in the
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/[polygerrit-ui/app/samples/]
+directory of the source tree.
+
 [[low-level-api-concepts]]
 == Low-level DOM API concepts
 
@@ -41,7 +41,7 @@
 decorating, replacing, and styling DOM elements exposed through a set of
 link:pg-plugin-endpoints.html[endpoints].
 
-PolyGerrit provides a simple way for accessing the DOM via DOM hooks API. A DOM
+Gerrit provides a simple way for accessing the DOM via DOM hooks API. A DOM
 hook is a custom element that is instantiated for the plugin endpoint. In the
 decoration case, a hook is set with a `content` attribute that points to the DOM
 element.
@@ -64,7 +64,7 @@
 [[low-level-decorating]]
 === Decorating DOM Elements
 
-For each endpoint, PolyGerrit provides a list of DOM properties (such as
+For each endpoint, Gerrit provides a list of DOM properties (such as
 attributes and events) that are supported in the long-term.
 
 ``` js
@@ -101,9 +101,9 @@
 `plugin.registerStyleModule(endpointName, moduleName)`. A style must be defined
 as a standalone `<dom-module>` defined in the same .js file.
 
-See `samples/theme-plugin.js` for examples.
-
-Note: TODO: Insert link to the full styling API.
+See
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/theme-plugin.js[samples/theme-plugin.js]
+for an example.
 
 ``` js
 const styleElement = document.createElement('dom-module');
@@ -146,45 +146,87 @@
 binding,role=external,window=_blank] for plugins that don't use Polymer. Can be used to bind element
 attribute changes to callbacks.
 
-See `samples/bind-parameters.js` for examples on both Polymer data bindings
-and `attibuteHelper` usage.
-
-=== eventHelper
-`plugin.eventHelper(element)`
-
-Note: TODO
+See
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/bind-parameters.js[samples/bind-parameters.js]
+for an example.
 
 === hook
 `plugin.hook(endpointName, opt_options)`
 
-See list of supported link:pg-plugin-endpoints.html[endpoints].
-
-Note: TODO
+See link:pg-plugin-endpoints.html[endpoints].
 
 === registerCustomComponent
 `plugin.registerCustomComponent(endpointName, opt_moduleName, opt_options)`
 
-See list of supported link:pg-plugin-endpoints.html[endpoints].
-
-Note: TODO
+See link:pg-plugin-endpoints.html[endpoints].
 
 === registerDynamicCustomComponent
 `plugin.registerDynamicCustomComponent(dynamicEndpointName, opt_moduleName,
 opt_options)`
 
-See list of supported link:pg-plugin-endpoints.html[endpoints].
-
-Note: TODO
+See link:pg-plugin-endpoints.html[endpoints].
 
 === registerStyleModule
 `plugin.registerStyleModule(endpointName, moduleName)`
 
-Note: TODO
+See link:#low-level-style[above].
+
+=== on
+Register a JavaScript callback to be invoked when events occur within
+the web interface. Signature
+
+``` js
+self.on(event, callback);
+```
+
+Parameters
+
+* event: A supported event type. See below for description.
+
+* callback: JavaScript function to be invoked when event happens.
+  Arguments may be passed to this function, depending on the event.
+
+Supported events:
+
+* `history`: Invoked when the view is changed to a new screen within
+  the Gerrit web application.  The token after "#" is passed as the
+  argument to the callback function, for example "/c/42/" while
+  showing change 42.
+
+* `showchange`: Invoked when a change is made visible. A
+  link:rest-api-changes.html#change-info[ChangeInfo] and
+  link:rest-api-changes.html#revision-info[RevisionInfo]
+  are passed as arguments. Gerrit provides a third parameter which
+  is an object with a `mergeable` boolean.
+
+* `submitchange`: Invoked when the submit button is clicked
+  on a change. A link:rest-api-changes.html#change-info[ChangeInfo]
+  and link:rest-api-changes.html#revision-info[RevisionInfo] are
+  passed as arguments. Similar to a form submit validation, the
+  function must return true to allow the operation to continue, or
+  false to prevent it. The function may be called multiple times, for
+  example, if submitting a change shows a confirmation dialog, this
+  event may be called to validate that the check whether dialog can be
+  shown, and called again when the submit is confirmed to check whether
+  the actual submission action can proceed.
+
+* `comment`: Invoked when a DOM element that represents a comment is
+  created. This DOM element is passed as argument. This DOM element
+  contains nested elements that Gerrit uses to format the comment. The
+  DOM structure may differ between comment types such as inline
+  comments, file-level comments and summary comments, and it may change
+  with new Gerrit versions.
+
+* `highlightjs-loaded`: Invoked when the highlight.js library has
+  finished loading. The global `hljs` object (also now accessible via
+  `window.hljs`) is passed as an argument to the callback function.
+  This event can be used to register a new language highlighter with
+  the highlight.js library before syntax highlighting begins.
 
 [[high-level-api]]
 == High-level API
 
-Plugin instance provides access to number of more specific APIs and methods
+Plugin instance provides access to a number of more specific APIs and methods
 to be used by plugin authors.
 
 === admin
@@ -196,95 +238,167 @@
 .Returns:
 - Instance of link:pg-plugin-admin-api.html[GrAdminApi].
 
+=== changeActions
+`self.changeActions()`
+
+Returns an instance of the
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/change-actions.ts[ChangeActionsPluginApi].
+
+==== changeActions.add()
+Adds a new action to the change actions section. Returns the key of the newly
+added action.
+
+``` js
+changeActions.add(type, label)
+```
+
+* type: The type of the action, either `change` or `revision`.
+
+* label: The label to be used in UI for this action.
+
+
+==== changeActions.remove()
+Removes an action from the change actions section.
+
+``` js
+changeActions.remove(key)
+```
+
+* key: The key of the action.
+
+
+==== changeActions.addTapListener()
+Adds a tap listener to an action that will be invoked when the action
+is tapped.
+
+``` js
+changeActions.addTapListener(key, callback)
+```
+
+* key: The key of the action.
+
+* callback: JavaScript function to be invoked when action tapped.
+
+
+==== changeActions.removeTapListener()
+Removes an existing tap listener on an action.
+
+``` js
+changeActions.removeTapListener(key, callback)
+```
+
+* key: The key of the action.
+
+* callback: JavaScript function to be removed.
+
+
+==== changeActions.setLabel()
+Sets the label for an action.
+
+``` js
+changeActions.setLabel(key, label)
+```
+
+* key: The key of the action.
+
+* label: The label of the action.
+
+
+==== changeActions.setTitle()
+Sets the title for an action.
+
+``` js
+changeActions.setTitle(key, title)
+```
+
+* key: The key of the action.
+
+* title: The title of the action.
+
+
+==== changeActions.setIcon()
+Sets an icon for an action.
+
+``` js
+changeActions.setIcon(key, icon)
+```
+
+* key: The key of the action.
+
+* icon: The name of the icon.
+
+
+==== changeActions.setEnabled()
+Sets an action to enabled or disabled.
+
+``` js
+changeActions.setEnabled(key, enabled)
+```
+
+* key: The key of the action.
+
+* enabled: The status of the action, true to enable.
+
+
+==== changeActions.setActionHidden()
+Sets an action to be hidden.
+
+``` js
+changeActions.setActionHidden(type, key, hidden)
+```
+
+* type: The type of the action.
+
+* key: The key of the action.
+
+* hidden: True to hide the action, false to show the action.
+
+
+==== changeActions.setActionOverflow()
+Sets an action to show in overflow menu.
+
+``` js
+changeActions.setActionOverflow(type, key, overflow)
+```
+
+* type: The type of the action.
+
+* key: The key of the action.
+
+* overflow: True to move the action to overflow menu, false to move
+  the action out of the overflow menu.
+
+
 === changeReply
 `plugin.changeReply()`
 
-Note: TODO
+Returns an instance of the
+link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/change-reply.ts[ChangeReplyPluginApi].
 
-=== delete
-`plugin.delete(url, opt_callback)`
+[[checks]]
+=== checks
+`plugin.checks()`
 
-Note: TODO
-
-=== get
-`plugin.get(url, opt_callback)`
-
-Note: TODO
+Returns an instance of the link:pg-plugin-checks-api.html[ChecksApi].
 
 === getPluginName
 `plugin.getPluginName()`
 
-Note: TODO
+Returns the name this plugin was installed as by the server
+administrator. The plugin name is required to access REST API
+views installed by the plugin, or to access resources.
 
 === getServerInfo
 `plugin.getServerInfo()`
 
-Note: TODO
-
-=== on
-`plugin.on(eventName, callback)`
-
-Note: TODO
-
-=== panel
-`plugin.panel(extensionpoint, callback)`
-
-Deprecated. Use `plugin.registerCustomComponent()` instead.
-
-``` js
-Gerrit.install(function(self) {
-  self.panel('CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', function(context) {
-    context.body.innerHTML =
-      'Sample link: <a href="http://some.com/foo">Foo</a>';
-    context.show();
-  });
-});
-```
-
-Here's the recommended approach that uses Polymer for generating custom elements:
-
-``` js
-class SomeCiModule extends Polymer.Element {
-  static get is() {
-    return "some-ci-module";
-  }
-  static get template() {
-    return Polymer.html`
-      Sample link: <a href="http://some.com/foo">Foo</a>
-    `;
-  }
-}
-
-// Register this element
-customElements.define(SomeCiModule.is, SomeCiModule);
-
-// Install the plugin
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent('change-view-integration', 'some-ci-module');
-});
-```
-
-See `samples/` for more examples.
-
-Here's a minimal example that uses low-level DOM Hooks API for the same purpose:
-
-``` js
-Gerrit.install(plugin => {
-  plugin.hook('change-view-integration', el => {
-    el.innerHTML = 'Sample link: <a href="http://some.com/foo">Foo</a>';
-  });
-});
-```
+Returns the host config as a link:rest-api-config.html#server-info[ServerInfo]
+object.
 
 === popup
 `plugin.popup(moduleName)`
 
-Note: TODO
-
-=== post
-`plugin.post(url, payload, opt_callback)`
-
-Note: TODO
+Creates a popup that contains the given web components. Can be controlled with
+calling `open()` and `close()` on the return value.
 
 [[plugin-rest-api]]
 === restApi
@@ -295,26 +409,17 @@
   e.g. `changes/1/revisions/1/cookbook~say-hello`
 
 .Returns:
-- Instance of link:pg-plugin-rest-api.html[GrPluginRestApi].
+- Instance of link:pg-plugin-rest-api.html[RestPluginApi].
 
-[[plugin-repo]]
-=== repo
-`plugin.repo()`
-
-.Params:
-- none
-
-.Returns:
-- Instance of link:pg-plugin-repo-api.html[GrRepoApi].
-
-=== put
-`plugin.put(url, payload, opt_callback)`
-
-Note: TODO
-
+[[plugin-screen]]
 === screen
 `plugin.screen(screenName, opt_moduleName)`
 
+Registers a web component as a dedicated top-level page that the router
+understands and that has a URL (/x/pluginname/screenname) that can be navigated
+to. Extension screens are usually linked from the
+link:dev-plugins.html#top-menu-extensions[top menu].
+
 .Params:
 - `*string* screenName` URL path fragment of the screen, e.g.
 `/x/pluginname/*screenname*`
@@ -322,57 +427,20 @@
 screen.
 
 .Returns:
-- Instance of GrDomHook.
-
-=== screenUrl
-`plugin.url(opt_screenName)`
-
-.Params:
-- `*string* screenName` (optional) URL path fragment of the screen, e.g.
-`/x/pluginname/*screenname*`
-
-.Returns:
-- Absolute URL for the screen, e.g. `http://localhost/base/x/pluginname/screenname`
-
-[[plugin-settings]]
-=== settings
-`plugin.settings()`
-
-.Params:
-- none
-
-.Returns:
-- Instance of link:pg-plugin-settings-api.html[GrSettingsApi].
-
-=== settingsScreen
-`plugin.settingsScreen(path, menu, callback)`
-
-Deprecated. Use link:#plugin-settings[`plugin.settings()`] instead.
-
-[[plugin-styles]]
-=== styles
-`plugin.styles()`
-
-.Params:
-- none
-
-.Returns:
-- Instance of link:pg-plugin-styles-api.html[GrStylesApi]
-
-=== changeMetadata
-`plugin.changeMetadata()`
-
-.Params:
-- none
-
-.Returns:
-- Instance of link:pg-plugin-change-metadata-api.html[GrChangeMetadataApi].
-
-=== theme
-`plugin.theme()`
-
-
-Note: TODO
+- Instance of HookApi.
 
 === url
-`plugin.url(opt_path)`
\ No newline at end of file
+`plugin.url(opt_path)`
+
+Returns a URL within the plugin's URL space. If invoked with no
+parameter the URL of the plugin is returned. If passed a string
+the argument is appended to the plugin URL.
+
+A plugin's URL is where this plugin is loaded, it doesn't
+necessary to be the same as the Gerrit host. Use `window.location`
+if you need to access the Gerrit host info.
+
+``` js
+self.url();                    // "https://gerrit-review.googlesource.com/plugins/demo/"
+self.url('/static/icon.png');  // "https://gerrit-review.googlesource.com/plugins/demo/static/icon.png"
+```
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index a8b3330..c16d0d4 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -1,16 +1,24 @@
-= Gerrit Code Review - PolyGerrit Plugin Styling
+= Gerrit Code Review - JavaScript Plugin Endpoints
 
-Plugins should be html-based and imported following PolyGerrit's
-link:pg-plugin-dev.html#loading[dev guide].
+This document describes Gerrit JavaScript plugin endpoints that you can hook
+into for customizing the UI. It is assumed that you are familiar with
+link:pg-plugin-dev.html#loading[the general dev guide].
 
-Sample code for testing endpoints:
+You can either hook into an endpoint by calling `plugin.hook(endpoint)` and
+then interact with the returned `HookApi`, which has `onAttached(callback)` and
+`onDetached(callback)` methods.
+
+Or you can define a
+link:https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements[Web Component,role=external,window=_blank]
+and register it directly using
+`plugin.registerCustomComponent(endpoint, elementName)`.
+
+Sample code for using an endpoint:
 
 ``` js
 Gerrit.install(plugin => {
-  // Change endpoint below
   const endpoint = 'change-metadata-item';
   plugin.hook(endpoint).onAttached(element => {
-    console.log(endpoint, element);
     const el = element.appendChild(document.createElement('div'));
     el.textContent = 'Ah, there it is. Lovely.';
     el.style = 'background: pink; line-height: 4em; text-align: center;';
@@ -42,9 +50,8 @@
 
 === change-view-integration
 The `change-view-integration` extension point is located between `Files` and
-`Messages` section on the change view page, and it may take full page's
-width. Primary purpose is to enable plugins to display custom CI-related
-information (build status, etc).
+`Change Log` section on the change view page, and it may take full page's
+width.
 
 * `change`
 +
@@ -174,7 +181,8 @@
 
 == Dynamic Plugin endpoints
 
-The following endpoints are available to plugins.
+The following dynamic endpoints are available to plugins by calling
+`plugin.registerDynamicCustomComponent(endpoint, elementName)`.
 
 === change-list-header
 The `change-list-header` extension point adds a header to the change list view.
diff --git a/Documentation/pg-plugin-migration.txt b/Documentation/pg-plugin-migration.txt
deleted file mode 100644
index 061c687..0000000
--- a/Documentation/pg-plugin-migration.txt
+++ /dev/null
@@ -1,148 +0,0 @@
-:linkattrs:
-= Gerrit Code Review - PolyGerrit Plugin Development
-
-CAUTION: Work in progress. Hard hat area. Please
-link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20plugins[send
-feedback,role=external,window=_blank] if something's not right.
-
-[[migration]]
-== Incremental migration of existing GWT UI plugins
-
-link:pg-plugin-dev.html[PolyGerrit plugin API] is based on different concepts and
-provides a different type of API compared to the one available to GWT
-plugins. Depending on the plugin, it might require significant modifications to
-existing UI scripts to fully take advantage of the benefits provided by the PolyGerrit API.
-
-To make migration easier, PolyGerrit recommends an incremental migration
-strategy. Starting with a .js file that works for GWT UI, plugin author can
-incrementally migrate deprecated APIs to the new plugin API.
-
-The goal for this guide is to provide a migration path from .js-based UI script to
-a html based implementation
-
-NOTE: Web UI plugins distributed as a single .js file are not covered in this
-guide.
-
-Let's start with a basic plugin that has an UI module. Commonly, file tree
-should look like this:
-
-  ├── BUILD
-  ├── LICENSE
-  └── src
-      └── main
-          ├── java
-          │   └── com
-          │       └── foo
-          │           └── SamplePluginModule.java
-          └── resources
-              └── static
-                  └── sampleplugin.js
-
-For simplicity's sake, let's assume SamplePluginModule.java has following
-content:
-
-``` java
-public class SamplePluginModule extends AbstractModule {
-
-  @Override
-  protected void configure() {
-    DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new JavaScriptPlugin("sampleplugin.js"));
-  }
-}
-```
-
-=== Step 1: Create `sampleplugin.html`
-
-As a first step, create `sampleplugin.html` and include the UI script in the
-module file.
-
-NOTE: GWT UI ignores html files which it doesn't support.
-
-``` java
-  @Override
-  protected void configure() {
-    DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new JavaScriptPlugin("sampleplugin.js"));
-    DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new JavaScriptPlugin("sampleplugin.html"));
-  }
-```
-
-Here's recommended starter code for `sampleplugin.html`:
-
-NOTE: By specification, the `id` attribute of `dom-module` *must* contain a dash
-(-).
-
-``` html
-<dom-module id="sample-plugin">
-  <script>
-    Gerrit.install(plugin => {
-        // Setup block, is executed before sampleplugin.js
-    });
-  </script>
-
-  <script src="./sampleplugin.js"></script>
-
-  <script>
-    Gerrit.install(plugin => {
-        // Cleanup block, is executed after sampleplugin.js
-    });
-  </script>
-</dom-module>
-```
-
-Here's how this works:
-
-- PolyGerrit detects migration scenario because UI scripts have same filename
-and different extensions
- * PolyGerrit will load `sampleplugin.html` and skip `sampleplugin.js`
- * PolyGerrit will reuse `plugin` (aka `self`) instance for `Gerrit.install()`
-callbacks
-- `sampleplugin.js` is loaded since it's referenced in `sampleplugin.html`
-- setup script tag code is executed before `sampleplugin.js`
-- cleanup script tag code is executed after `sampleplugin.js`
-
-This means the plugin instance is shared between .html-based and .js-based
-code. This allows to gradually and incrementally transfer code to the new API.
-
-=== Step 2: Create cut-off marker in `sampleplugin.js`
-
-Commonly, window.Polymer is being used to detect in GWT UI script if it's being
-executed inside PolyGerrit. This could be used to separate code that was already
-migrated to new APIs from old not yet migrated code.
-
-During incremental migration, some of the UI code will be reimplemented using
-the PolyGerrit plugin API. However, old code still could be required for the plugin
-to work in GWT UI.
-
-To handle this case, add the following code at the end of the installation
-callback in `sampleplugin.js`
-
-``` js
-Gerrit.install(function(self) {
-
-  // Existing code here, not modified.
-
-  if (window.Polymer) { return; } // Cut-off marker
-
-  // Everything below was migrated to PolyGerrit plugin API.
-  // Code below is still needed for the plugin to work in GWT UI.
-});
-```
-
-=== Step 3: Migrate!
-
-The code that uses deprecated APIs should be eventually rewritten using
-non-deprecated counterparts. Duplicated pieces could be kept under cut-off
-marker to work in GWT UI.
-
-If some data or functions needs to be shared between code in .html and .js, it
-could be stored in the `plugin` (aka `self`) object that's shared between both
-
-=== Step 4: Cleanup
-
-Once deprecated APIs are migrated, `sampleplugin.js` will only contain
-duplicated code that's required for GWT UI to work. As soon as GWT support is removed from Gerrit
-that file can be simply deleted, along with the script tag loading it.
-
diff --git a/Documentation/pg-plugin-repo-api.txt b/Documentation/pg-plugin-repo-api.txt
deleted file mode 100644
index 1272ea6..0000000
--- a/Documentation/pg-plugin-repo-api.txt
+++ /dev/null
@@ -1,36 +0,0 @@
-= Gerrit Code Review - Repo admin customization API
-
-This API is provided by link:pg-plugin-dev.html#plugin-repo[plugin.repo()]
-and provides customization to admin page.
-
-== createCommand
-`repoApi.createCommand(title, checkVisibleCallback)`
-
-Create a repo command in the admin panel.
-
-.Params
-- *title* String title.
-- *checkVisibleCallback* function to configure command visibility.
-
-.Returns
-- GrRepoApi for chaining.
-
-`checkVisibleCallback(repoName, repoConfig)`
-
-.Params
-- *repoName* String project name.
-- *repoConfig* Object REST API response for repo config.
-
-.Returns
-- `false` to hide the command for the specific project.
-
-== onTap
-`repoApi.onTap(tapCalback)`
-
-Add a command tap callback.
-
-.Params
-- *tapCallback* function that's excuted on command tap.
-
-.Returns
-- Nothing
diff --git a/Documentation/pg-plugin-rest-api.txt b/Documentation/pg-plugin-rest-api.txt
index 0d42bf6..4771fbb 100644
--- a/Documentation/pg-plugin-rest-api.txt
+++ b/Documentation/pg-plugin-rest-api.txt
@@ -1,4 +1,4 @@
-= Gerrit Code Review - Repo admin customization API
+= Gerrit Code Review - JavaScript Plugin Rest API
 
 This API is provided by link:pg-plugin-dev.html#plugin-rest-api[plugin.restApi()]
 and provides interface for Gerrit REST API.
@@ -28,13 +28,14 @@
 == getConfig
 `repoApi.getConfig()`
 
-Get server config.
+Returns the host config as a link:rest-api-config.html#server-info[ServerInfo]
+object.
 
 .Params
 - None
 
 .Returns
-- Promise<Object>
+- Promise<ServerInfo>
 
 == get
 `repoApi.get(url)`
diff --git a/Documentation/pg-plugin-settings-api.txt b/Documentation/pg-plugin-settings-api.txt
deleted file mode 100644
index 985809d..0000000
--- a/Documentation/pg-plugin-settings-api.txt
+++ /dev/null
@@ -1,40 +0,0 @@
-= Gerrit Code Review - Settings admin customization API
-
-This API is provided by link:pg-plugin-dev.html#plugin-settings[plugin.settings()]
-and provides customization to settings page.
-
-== title
-`settingsApi.title(title)`
-
-.Params
-- `*string* title` Menu item and settings section title
-
-.Returns
-- `GrSettingsApi` for chaining.
-
-== token
-`settingsApi.token(token)`
-
-.Params
-- `*string* token` URL path fragment of the screen for direct link, e.g.
-`settings/#x/some-plugin/*token*`
-
-.Returns
-- `GrSettingsApi` for chaining.
-
-== module
-`settingsApi.module(token)`
-
-.Params
-- `*string* module` Custom element name for instantiating in the settings plugin
-area.
-
-.Returns
-- `GrSettingsApi` for chaining.
-
-== build
-
-.Params
-- none
-
-Apply all other configuration parameters and create required UI elements.
diff --git a/Documentation/pg-plugin-style-object.txt b/Documentation/pg-plugin-style-object.txt
deleted file mode 100644
index cdcfb55..0000000
--- a/Documentation/pg-plugin-style-object.txt
+++ /dev/null
@@ -1,33 +0,0 @@
-= Gerrit Code Review - GrStyleObject
-
-Store information about css style properties. You can't create this object
-directly. Instead you should use the link:pg-plugin-styles-api.html#css[css] method.
-This object allows to apply style correctly to elements within different shadow
-subtree.
-
-[[get-class-name]]
-== getClassName
-`styleObject.getClassName(element)`
-
-.Params
-- `element` - an HTMLElement.
-
-.Returns
-- `string` - class name. The class name is valid only within the shadow root of `element`.
-
-Creates a new unique CSS class and injects it into the appropriate place
-in DOM (it can be document or shadow root for element). This class can be later
-added to the element or to any other element in the same shadow root. It is guarantee,
-that method adds CSS class only once for each shadow root.
-
-== apply
-`styleObject.apply(element)`
-
-.Params
-- `element` - element to apply style.
-
-Create a new unique CSS class (see link:#get-class-name[getClassName]) and
-adds class to the element.
-
-
-
diff --git a/Documentation/pg-plugin-styles-api.txt b/Documentation/pg-plugin-styles-api.txt
deleted file mode 100644
index a829325..0000000
--- a/Documentation/pg-plugin-styles-api.txt
+++ /dev/null
@@ -1,29 +0,0 @@
-= Gerrit Code Review - Plugin styles API
-
-This API is provided by link:pg-plugin-dev.html#plugin-styles[plugin.styles()]
-and provides a way to apply dynamically created styles to elements in a
-document.
-
-[[css]]
-== css
-`styles.css(rulesStr)`
-
-.Params
-- `*string* rulesStr` string with CSS styling declarations.
-
-Example:
-----
-const styleObject = plugin.styles().css('background: black; color: white;');
-...
-const className = styleObject.getClassName(element)
-...
-element.classList.add(className);
-...
-styleObject.apply(someOtherElement);
-----
-
-.Returns
-- Instance of link:pg-plugin-style-object.html[GrStyleObject].
-
-
-
diff --git a/Documentation/pg-plugin-styling.txt b/Documentation/pg-plugin-styling.txt
deleted file mode 100644
index f600376..0000000
--- a/Documentation/pg-plugin-styling.txt
+++ /dev/null
@@ -1,72 +0,0 @@
-:linkattrs:
-= Gerrit Code Review - PolyGerrit Plugin Styling
-
-== Plugin styles
-
-Plugins may provide
-link:https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[Polymer
-style modules,role=external,window=_blank] for UI CSS-based customization.
-
-PolyGerrit UI implements number of styling endpoints, which apply CSS mixins
-link:https://tabatkins.github.io/specs/css-apply-rule/[using @apply,role=external,window=_blank] to its
-direct contents.
-
-NOTE: Only items (i.e. CSS properties and mixin targets) documented here are
-guaranteed to work in the long term, since they are covered by integration
-tests. + When there is a need to add new property or endpoint, please
-link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20Issue[file
-a bug,role=external,window=_blank] stating your use case to track and maintain for future releases.
-
-Plugins should be html-based and imported following PolyGerrit's
-link:pg-plugin-dev.html#loading[dev guide].
-
-Plugins should provide Style Module, for example:
-
-``` html
-  <dom-module id="some-style">
-    <template>
-      <style>
-        html {
-          --css-mixin-name: {
-            property: value;
-          }
-        }
-      </style>
-    </template>
-  </dom-module>
-```
-
-Plugins should register style module with a styling endpoint using
-`Plugin.prototype.registerStyleModule(endpointName, styleModuleName)`, for
-example:
-
-``` js
-  Gerrit.install(function(plugin) {
-    plugin.registerStyleModule('some-endpoint', 'some-style');
-  });
-```
-
-== Available styling endpoints
-=== change-metadata
-Following custom CSS mixins are recognized:
-
-* `--change-metadata-assignee`
-+
-is applied to `gr-change-metadata section.assignee`
-* `--change-metadata-label-status`
-+
-is applied to `gr-change-metadata section.labelStatus`
-* `--change-metadata-strategy`
-+
-is applied to `gr-change-metadata section.strategy`
-* `--change-metadata-topic`
-+
-is applied to `gr-change-metadata section.topic`
-
-Following CSS properties have
-link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html[long-term
-support via integration test,role=external,window=_blank]:
-
-* `display`
-+
-can be set to `none` to hide a section.
diff --git a/Documentation/project-configuration.txt b/Documentation/project-configuration.txt
index 23030a4..e583f45 100644
--- a/Documentation/project-configuration.txt
+++ b/Documentation/project-configuration.txt
@@ -108,6 +108,9 @@
 === Default Branch
 
 The default branch of a remote repository is defined by its `HEAD`.
+The default branch is selected from the initial branches of the newly created project,
+or set to link:config-gerrit.html#gerrit.defaultBranch[host-level default],
+if the project was created with empty branches.
 For convenience reasons, when the repository is cloned Git creates a
 local branch for this default branch and checks it out.
 
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 32c30b8..189ccfc 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -26,7 +26,7 @@
 link:https://groups.google.com/d/topic/repo-discuss/wJxTGhlHZMM/discussion[This
 discussion thread,role=external,window=_blank] explains why Prolog was chosen for the purpose of writing
 project specific submit rules.
-link:http://gerrit-documentation.googlecode.com/svn/ReleaseNotes/ReleaseNotes-2.2.2.html[Gerrit
+link:https://gerrit-documentation.storage.googleapis.com/ReleaseNotes/ReleaseNotes-2.2.2.html#_prolog[Gerrit
 2.2.2 ReleaseNotes,role=external,window=_blank] introduces Prolog support in Gerrit.
 
 [[SubmitType]]
diff --git a/Documentation/repository-maintenance.txt b/Documentation/repository-maintenance.txt
new file mode 100644
index 0000000..1672436
--- /dev/null
+++ b/Documentation/repository-maintenance.txt
@@ -0,0 +1,116 @@
+= Gerrit Code Review - Repository Maintenance
+
+== Description
+
+Each project in Gerrit is stored in a bare Git repository. Gerrit uses
+the JGit library to access (read and write to) these Git repositories.
+As modifications are made to a project, Git repository maintenance will
+be needed or performance will eventually suffer. When using the Git
+command line tool to operate on a Git repository, it will run `git gc`
+every now and then on the repository to ensure that Git garbage
+collection is performed. However regular maintenance does not happen as
+a result of normal Gerrit operations, so this is something that Gerrit
+administrators need to plan for.
+
+Gerrit has a built-in feature which allows it to run Git garbage
+collection on repositories. This can be
+link:config-gerrit.html#gc[configured] to run on a regular basis, and/or
+this can be run manually with the link:cmd-gc.html[gerrit gc] ssh
+command, or with the link:rest-api-projects.html#run-gc[run-gc] REST API.
+Some administrators will opt to run `git gc` or `jgit gc` outside of
+Gerrit instead. There are many reasons this might be done, the main one
+likely being that when it is run in Gerrit it can be very resource
+intensive and scheduling an external job to run Git garbage collection
+allows administrators to finely tune the approach and resource usage of
+this maintenance.
+
+== Git Garbage Collection Impacts
+
+Unlike a typical server database, access to Git repositories is not
+marshalled through a single process or a set of inter communicating
+processes. Unfortuntatlely the design of the on-disk layout of a Git
+repository does not allow for 100% race free operations when accessed by
+multiple actors concurrently. These design shortcomings are more likely
+to impact the operations of busy repositories since racy conditions are
+more likely to occur when there are more concurrent operations. Since
+most Gerrit servers are expected to run without interruptions, Git
+garbage collection likely needs to be run during normal operational hours.
+When it runs, it adds to the concurrency of the overall accesses. Given
+that many of the operations in garbage collection involve deleting files
+and directories, it has a higher chance of impacting other ongoing
+operations than most other operations.
+
+=== Interrupted Operations
+
+When Git garbage collection deletes a file or directory that is
+currently in use by an ongoing operation, it can cause that operation to
+fail. These sorts of failures are often single shot failures, i.e. the
+operation will succeed if tried again. An example of such a failure is
+when a pack file is deleted while Gerrit is sending an object in the
+file over the network to a user performing a clone or fetch. Usually
+pack files are only deleted when the referenced objects in them have
+been repacked and thus copied to a new pack file. So performing the same
+operation again after the fetch will likely send the same object from
+the new pack instead of the deleted one, and the operation will succeed.
+
+=== Data Loss
+
+It is possible for data loss to occur when Git garbage collection runs.
+This is very rare, but it can happen. This can happen when an object is
+believed to be unreferenced when object repacking is running, and then
+garbage collection deletes it. This can happen because even though an
+object may indeed be unreferenced when object repacking begins and
+reachability of all objects is determined, it can become referenced by
+another concurrent operation after this unreferenced determination but
+before it gets deleted. When this happens, a new reference can be
+created which points to a now missing object, and this will result in a
+loss.
+
+== Reducing Git Garbage Collection Impacts
+
+JGit has a `preserved` directory feature which is intended to reduce
+some of the impacts of Git garbage collection, and Gerrit can take
+advantage of the feature too. The `preserved` directory is a
+subdirectory of a repository's `objects/pack` directory where JGit will
+move pack files that it would normally delete when `jgit gc` is invoked
+with the `--preserve-oldpacks` option. It will later delete these files
+the next time that `jgit gc` is run if it is invoked with the
+`--prune-preserved` option. Using these flags together on every `jgit gc`
+invocation means that packfiles will get an extended lifetime by one
+full garbage collection cycle. Since an atomic move is used to move these
+files, any open references to them will continue to work, even on NFS. On
+a busy repository, preserving pack files can make operations much more
+reliable, and interrupted operations should almost entirely disappear.
+
+Moving files to the `preserved` directory also has the ability to reduce
+data loss. If JGit cannot find an object it needs in its current object
+DB, it will look into the `preserved` directory as a last resort. If it
+finds the object in a pack file there, it will restore the
+slated-to-be-deleted pack file back to the original `objects/pack`
+directory effectively "undeleting" it and making all the objects in it
+available again. When this happens, data loss is prevented.
+
+One advantage of restoring preserved packfiles in this way when an
+object is referenced in them, is that it makes loosening unreferenced
+objects during Git garbage collection, which is a potentially expensive,
+wasteful, and performance impacting operation, no longer desirable. It
+is recommended that if you use Git for garbage collection, that you use
+the `-a` option to `git repack` instead of the `-A` option to no longer
+perform this loosening.
+
+When Git is used for garbage collection instead of JGit, it is fairly
+easy to wrap `git gc` or `git repack` with a small script which has a
+`--prune-preserved` option which behaves as mentioned above by deleting
+any pack files currently in the preserved directory, and also has a
+`--preserve-oldpacks` option which then hardlinks all the currently
+existing pack files from the `objects/pack` directory into the
+`preserved` directory right before calling the real Git command. This
+approach will then behave similarly to `jgit gc` with respect to
+preserving pack files.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 6664aa2..45a39d8 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -406,14 +406,15 @@
 |`config_visible`     |not set if `false`|
 Whether the calling user can see the `refs/meta/config` branch of the
 project.
-|`groups`            ||A map of group UUID to
+|`groups`             ||A map of group UUID to
 link:rest-api-groups.html#group-info[GroupInfo] objects, with names and
 URLs for the group UUIDs used in the `local` map.
 This will include names for groups that might
 be invisible to the caller.
-|`configWebLinks`    ||
-A list of URLs that display the history of the configuration file
-governing this project's access rights.
+|`config_web_links`   |optional|
+Links to the history of the configuration file governing this project's access
+rights as list of link:rest-api-changes.html#web-link-info[WebLinkInfo]
+entities.
 |==================================
 
 
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 2a59d0c..8aa3173 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -2773,7 +2773,7 @@
 Allowed values are `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
-(PolyGerrit only).
+(Gerrit web app UI only).
 |`download_scheme`              |optional|
 The type of download URL the user prefers to use. May be any key from
 the `schemes` map in
@@ -2802,8 +2802,8 @@
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
 |`change_table`                           ||
-The columns to display in the change table (PolyGerrit only). The default is
-empty, which will default columns as determined by the frontend.
+The columns to display in the change table (Gerrit web app UI only). The
+default is empty, which will default columns as determined by the frontend.
 |`email_strategy`               ||
 The type of email strategy to use. On `ENABLED`, the user will receive emails
 from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
@@ -2816,6 +2816,8 @@
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
 Allowed values are `AUTO_MERGE` and `FIRST_PARENT`.
+|`disable_keyboard_shortcuts`     |not set if `false`|
+Whether to disable all keyboard shortcuts.
 |`publish_comments_on_push`     |not set if `false`|
 Whether to link:user-upload.html#publish-comments[publish draft comments] on
 push by default.
@@ -2840,7 +2842,7 @@
 Allowed values are `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
-(PolyGerrit only).
+(Gerrit web app UI only).
 |`download_scheme`              |optional|
 The type of download URL the user prefers to use.
 |`date_format`                  |optional|
@@ -2867,8 +2869,8 @@
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
 |`change_table`                           ||
-The columns to display in the change table (PolyGerrit only). The default is
-empty, which will default columns as determined by the frontend.
+The columns to display in the change table (Gerrit web app UI only). The
+default is empty, which will default columns as determined by the frontend.
 |`email_strategy`               |optional|
 The type of email strategy to use. On `ENABLED`, the user will receive emails
 from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 32bfc6b..8da7a9d 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -14,8 +14,11 @@
 'POST /changes/'
 --
 
-The change input link:#change-input[ChangeInput] entity must be provided in the
-request body.
+The change input link:#change-input[ChangeInput] entity must be
+provided in the request body. It is not allowed to create changes
+under `refs/tags/` or Gerrit internal ref namespaces such as
+`refs/changes/`, `refs/meta/external-ids/`, and `refs/users/`. The
+request would fail with `400 Bad Request` in this case.
 
 To create a change the calling user must be allowed to
 link:access-control.html#category_push_review[upload to code review].
@@ -561,6 +564,72 @@
   }
 ----
 
+Historical state of the change can be retrieved by specifying the
+`meta=SHA-1` parameter. This will use a historical NoteDb snapshot to
+populate ChangeInfo. If the SHA-1 is not reachable as a NoteDb state,
+status code 412 is returned.
+
+[[get-meta-diff]]
+=== Get Meta Diff
+--
+'GET /changes/link:#change-id[\{change-id\}]/meta_diff?old=SHA-1&meta=SHA-1'
+--
+
+Retrieves the difference between two historical states of a change
+by specifying the `old=SHA-1` and the `meta=SHA-1` parameters.
+
+If the `old` parameter is not provided, the parent of the `meta`
+SHA-1 is used. If the `meta` parameter is not provided, the current
+state of the change is used. If neither are provided, the
+difference between the current state of the change and its previous
+state is returned.
+
+Additional fields can be obtained by adding `o` parameters, analogous
+to link:#get-change[Get Change], and the same concerns for Get Change hold for
+this endpoint too. Fields are described in link:#list-changes[Query Changes].
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/meta_diff?old=b083abc34eb6dbdb9e154ba092fc198000e997b4&meta=63b81f2bde703ae07787a940e8fdf9a1828605b1 HTTP/1.0
+----
+
+As a response, two link:#change-info[ChangeInfo] entities are returned
+that describe information added and removed from the `old` change state.
+Only fields that differ between the change's two states are returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "added": {
+      "attention_set": [
+        {
+          "account": {
+            "name": "John Doe"
+          },
+         "last_update": "2013-02-21 11:16:36.775000000",
+         "reason": "reviewer or cc replied"
+        }
+      ]
+      "updated": "2013-02-21 11:16:36.775000000",
+      "topic": "new-topic"
+    },
+    "removed": {
+      "updated": "2013-02-20 12:05:34.111000000",
+      "topic": "old-topic"
+    }
+  }
+----
+
+If the provided SHA-1 for `meta` is not reachable as a NoteDb
+state, the status code 412 is returned. If the SHA-1 for `old`
+is not reachable, the difference between the change at state
+`meta` and an empty change is returned.
+
 [[get-change-detail]]
 === Get Change Detail
 --
@@ -1131,7 +1200,7 @@
 --
 
 Check if the given change is a pure revert of the change it references in `revertOf`.
-Optionally, the query parameter `o` can be passed in to specify a commit (SHA1 in
+Optionally, the query parameter `o` can be passed in to specify a commit (SHA-1 in
 40 digit hex representation) to check against. It takes precedence over `revertOf`.
 If the change has no reference in `revertOf`, the parameter is mandatory.
 
@@ -1394,6 +1463,8 @@
 
 The destination branch must be provided in the request body inside a
 link:#move-input[MoveInput] entity.
+Only veto votes that are blocking the change from submission are moved to
+the destination branch by default.
 
 .Request
 ----
@@ -2048,10 +2119,14 @@
 comments for each path are sorted by patch set number. Each comment has
 the `patch_set` and `author` fields set.
 
-If the `enable_context` request parameter is set to true, the comment entries
+If the `enable-context` request parameter is set to true, the comment entries
 will contain a list of link:#context-line[ContextLine] containing the lines of
 the source file where the comment was written.
 
+The `context-padding` request parameter can be used to specify an extra number
+of context lines to be added before and after the comment range. This parameter
+only works if `enable-context` is set to true.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/comments HTTP/1.0
@@ -2165,6 +2240,10 @@
 comments for each path are sorted by patch set number. Each comment has
 the `patch_set` field set, and no `author`.
 
+The `enable-context` and `context-padding` request parameters can be used to
+request comment context. See link:#list-change-comments[List Change Comments]
+for more details.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/drafts HTTP/1.0
@@ -5143,6 +5222,8 @@
 Different than the link:#get-ported-comments[Get Ported Comments] endpoint, the `author` of the
 returned comments is not filled for this endpoint as only comments of the calling user are returned.
 
+This endpoint requires authentication.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/ported_drafts/ HTTP/1.0
@@ -5944,6 +6025,8 @@
 If a user is added while already in the attention set, the
 request is silently ignored.
 
+The user must be a reviewer, cc, uploader, or owner on the change.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention HTTP/1.0
@@ -6454,6 +6537,8 @@
 Only set if link:#current-revision[the current revision] is requested
 (in which case it will only contain a key for the current revision) or
 if link:#all-revisions[all revisions] are requested.
+|`meta_rev_id`           |optional|
+The SHA-1 of the NoteDb meta ref.
 |`tracking_ids`       |optional|
 A list of link:#tracking-id-info[TrackingIdInfo] entities describing
 references to external tracking systems. Only set if
@@ -6480,8 +6565,12 @@
 The callers must not rely on the format of the submission ID.
 |`cherry_pick_of_change`   |optional|
 The numeric Change-Id of the change that this change was cherry-picked from.
+Only set if the cherry-pick has been done through the Gerrit REST API (and
+not if a cherry-picked commit was pushed).
 |`cherry_pick_of_patch_set`|optional|
 The patchset number of the change that this change was cherry-picked from.
+Only set if the cherry-pick has been done through the Gerrit REST API (and
+not if a cherry-picked commit was pushed).
 |`contains_git_conflicts`  |optional, not set if `false`|
 Whether the change contains conflicts. +
 If `true`, some of the file contents of the change contain git conflict
@@ -6687,12 +6776,16 @@
 Contains the link:rest-api-changes.html#change-message-info[id] of the change
 message that this comment is linked to.
 |`commit_id` |optional|
-Hex commit SHA1 (40 characters string) of the commit of the patchset to which
+Hex commit SHA-1 (40 characters string) of the commit of the patchset to which
 this comment applies.
 |`context_lines` |optional|
 A list of link:#context-line[ContextLine] containing the lines of the source
-file where the comment was written. Available only if the "enable_context"
+file where the comment was written. Available only if the "enable-context"
 parameter (see link:#list-change-comments[List Change Comments]) is set.
+|`source_content_type` |optional|
+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.
 
 |===========================
 
@@ -7356,6 +7449,11 @@
 |`destination_branch`||Destination branch
 |`message`           |optional|
 A message to be posted in this change's comments
+|`keep_all_votes`    |optional, defaults to false|
+By default, only veto votes that are blocking the change from submission are moved to
+the destination branch. Using this option is only allowed for administrators,
+because it can affect the submission behaviour of the change (depending on the label access
+configuration and submissions rules).
 |===========================
 
 [[notify-info]]
@@ -7470,11 +7568,18 @@
 |===========================
 |Field Name    ||Description
 |`base`        |optional|
-The new parent revision. This can be a ref or a SHA1 to a concrete patchset. +
+The new parent revision. This can be a ref or a SHA-1 to a concrete patchset. +
 Alternatively, a change number can be specified, in which case the current
 patch set is inferred. +
 Empty string is used for rebasing directly on top of the target branch,
 which effectively breaks dependency towards a parent change.
+|`allow_conflicts`|optional, defaults to false|
+If `true`, the rebase also succeeds if there are conflicts. +
+If there are conflicts the file contents of the rebased patch set contain
+git conflict markers to indicate the conflicts. +
+Callers can find out whether there were conflicts by checking the
+`contains_git_conflicts` field in the returned link:#change-info[ChangeInfo]. +
+If there are conflicts the change is marked as work-in-progress.
 |===========================
 
 [[related-change-and-commit-info]]
@@ -7627,9 +7732,12 @@
 The message to be added as review comment.
 |`tag`                                  |optional|
 Apply this tag to the review comment message, votes, and inline
-comments. Tags may be used by CI or other automated systems to
-distinguish them from human reviews. Votes/comments that contain `tag` with
-'autogenerated:' prefix can be filtered out in the web UI.
+comments. Tags with an 'autogenerated:' prefix may be used by CI or other
+automated systems to distinguish them from human reviews. If another
+message was posted on a newer patchset, but with the same tag, then the older
+message will be hidden in the UI. Suffixes starting with `~` are not considered,
+so `autogenerated:my-ci-system~trigger` and `autogenerated:my-ci-system~result`
+will be considered being the same tag with regards to the hiding rule.
 |`labels`                               |optional|
 The votes that should be added to the revision as a map that maps the
 label names to the voting values.
@@ -7674,7 +7782,8 @@
 `ready` and `work_in_progress` to be true.
 |`add_to_attention_set`                |optional|
 list of link:#attention-set-input[AttentionSetInput] entities to add
-to the link:#attention-set[attention set].
+to the link:#attention-set[attention set]. Users that are not reviewers,
+ccs, owner, or uploader are silently ignored.
 |`remove_from_attention_set`           |optional|
 list of link:#attention-set-input[AttentionSetInput] entities to remove
 from the link:#attention-set[attention set].
@@ -8061,13 +8170,14 @@
 === WebLinkInfo
 The `WebLinkInfo` entity describes a link to an external site.
 
-[options="header",cols="1,6"]
-|======================
-|Field Name|Description
-|`name`    |The link name.
-|`url`     |The link URL.
-|`image_url`|URL to the icon of the link.
-|======================
+[options="header",cols="1,^1,5"]
+|========================
+|Field Name ||Description
+|`name`     ||The link name.
+|`url`      ||The link URL.
+|`image_url`|optional|URL to the icon of the link.
+|`target`   |optional|The target window in which the web link should be opened.
+|========================
 
 [[work-in-progress-input]]
 === WorkInProgressInput
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index a62ed47..41a8729 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1552,9 +1552,6 @@
 |`allow_blame`        |not set if `false`|
 link:config-gerrit.html#change.allowBlame[Whether blame on side by side diff is
 allowed].
-|`large_change`       ||
-link:config-gerrit.html#change.largeChange[Number of changed lines from
-which on a change is considered as a large change].
 |`reply_label`        ||
 link:config-gerrit.html#change.replyTooltip[Label name for the reply
 button].
@@ -1842,6 +1839,8 @@
 Whether to enable the web UI for editing GPG keys.
 |`report_bug_url`    |optional|
 link:config-gerrit.html#gerrit.reportBugUrl[URL to report bugs].
+|`instance_id`       |optional|
+link:config-gerrit.html#gerrit.instanceId[Short identifier for this Gerrit installation].
 |=================================
 
 [[index-config-info]]
@@ -1942,6 +1941,9 @@
 |Field Name    ||Description
 |`has_avatars` |not set if `false`|
 Whether an avatar provider is registered.
+|`js_resource_paths`||
+A list of relative paths (strings). Each path points to a frontend plugin that
+should be loaded, e.g. `plugins/codemirror_editor/static/codemirror_editor.js`.
 |===========================
 
 [[receive-info]]
@@ -2010,7 +2012,7 @@
 link:config-gerrit.html#user[user] section as link:#user-config-info[
 UserConfigInfo] entity.
 |`default_theme`           |optional|
-URL to a default PolyGerrit UI theme plugin, if available.
+URL to a default Gerrit UI theme plugin, if available.
 Located in `/static/gerrit-theme.js` by default.
 |=======================================
 
diff --git a/Documentation/rest-api-documentation.txt b/Documentation/rest-api-documentation.txt
index 0a7ff16..e69750d 100644
--- a/Documentation/rest-api-documentation.txt
+++ b/Documentation/rest-api-documentation.txt
@@ -47,10 +47,6 @@
       "url": "Documentation/rest-api.html"
     },
     {
-      "title": "Gerrit Code Review - JavaScript API",
-      "url": "Documentation/js-api.html"
-    },
-    {
       "title": "Gerrit Code Review - /plugins/ REST API",
       "url": "Documentation/rest-api-plugins.html"
     },
@@ -67,10 +63,14 @@
       "url": "Documentation/rest-api-access.html"
     },
     {
-      "title": "Gerrit Code Review - Plugin Development",
+      "title": "Gerrit Code Review - Java Plugin Development",
       "url": "Documentation/dev-plugins.html"
     },
     {
+      "title": "Gerrit Code Review - JavaScript Plugin Development and API",
+      "url": "Documentation/pg-plugin-dev.html"
+    },
+    {
       "title": "Gerrit Code Review - Developer Setup",
       "url": "Documentation/dev-readme.html"
     },
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 84da169..725920e 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1702,7 +1702,9 @@
 'GET /projects/link:#project-name[\{project-name\}]/branches/link:#branch-id[\{branch-id\}]'
 --
 
-Retrieves a branch of a project.
+Retrieves a branch of a project. For the "All-Users" repository, the magic
+branch "refs/users/self" is automatically resolved to the user branch of the
+calling user.
 
 .Request
 ----
@@ -1734,7 +1736,9 @@
 Creates a new branch.
 
 In the request body additional data for the branch can be provided as
-link:#branch-input[BranchInput].
+link:#branch-input[BranchInput]. The link:#branch-id[\{branch-id\}] in the URL
+should exactly match with the `ref` field of link:#branch-input[BranchInput], or
+otherwise the request would fail with `400 Bad Request`.
 
 .Request
 ----
@@ -1846,8 +1850,9 @@
 
 Gets whether the source is mergeable with the target branch.
 
-The `source` query parameter is required, which can be anything that could be
-resolved to a commit, see examples of the `source` attribute in
+The `source` query parameter is required, which can be anything that
+could be resolved to a commit, and is visible to the caller. See
+examples of the `source` attribute in
 link:rest-api-changes.html#merge-input[MergeInput].
 
 Also takes an optional parameter `strategy`, which can be `recursive`, `resolve`,
@@ -2702,7 +2707,10 @@
 The integer-valued request parameter `parent` changes the response to return a
 list of the files which are different in this commit compared to the given
 parent commit. This is useful for supporting review of merge commits. The value
-is the 1-based index of the parent's position in the commit object.
+is the 1-based index of the parent's position in the commit object. If the
+value 0 is used for `parent`, the default base commit will be used, which is
+the only parent for commits having one parent or the auto-merge commit
+otherwise.
 
 [[dashboard-endpoints]]
 == Dashboard Endpoints
@@ -3396,6 +3404,8 @@
 |`status`                    ||The HTTP status code for the access.
 200 means success and 403 means denied.
 |`message`                   |optional|A clarifying message if `status` is not 200.
+|`debug_logs`                |optional|
+Debug logs that may help to understand why a permission is denied or allowed.
 |=========================================
 
 [[auto_closeable_changes_check_input]]
@@ -3914,7 +3924,7 @@
 |`value`            ||
 The effective boolean value.
 |`configured_value` ||
-The configured value, can be `TRUE`, `FALSE` or `INHERITED`.
+The configured value, can be `TRUE`, `FALSE` or `INHERIT`.
 |`inherited_value`  |optional|
 The boolean value inherited from the parent. +
 Not set if there is no parent.
@@ -3967,6 +3977,9 @@
 |`copy_all_scores_on_trivial_rebase`|`false` if not set|
 Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
 copyAllScoresOnTrivialRebase] is set on the label.
+|`copy_all_scores_if_list_of_files_did_not_change`|`false` if not set|
+Whether link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
+copyAllScoresIfListOfFilesDidNotChange] is set on the label.
 |`copy_all_scores_on_merge_first_parent_update`|`false` if not set|
 Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
 copyAllScoresOnMergeFirstParentUpdate] is set on the label.
@@ -4034,6 +4047,9 @@
 |`copy_all_scores_on_trivial_rebase`|optional|
 Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
 copyAllScoresOnTrivialRebase] is set on the label.
+|`copy_all_scores_if_list_of_files_did_not_change`|optional|
+Whether link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
+copyAllScoresIfListOfFilesDidNotChange] is set on the label.
 |`copy_all_scores_on_merge_first_parent_update`|optional|
 Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
 copyAllScoresOnMergeFirstParentUpdate] is set on the label.
@@ -4197,7 +4213,13 @@
 repository.<name>.defaultSubmitType] is set to a different value.
 |`branches`                  |optional|
 A list of branches that should be initially created. +
-For the branch names the `refs/heads/` prefix can be omitted.
+For the branch names the `refs/heads/` prefix can be omitted. +
+The first entry of the list will be the
+link:project-configuration.html#default-branch[default branch]. +
+If the list is empty, link:config-gerrit.html#gerrit.defaultBranch[host-level default]
+is used. +
+Branches in the Gerrit internal ref space are not allowed, such as
+refs/groups/, refs/changes/, etc...
 |`owners`                    |optional|
 A list of groups that should be assigned as project owner. +
 Each group in the list must be specified as
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 5e2906f..95e1258 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -48,6 +48,7 @@
 * For merged and abandoned changes the owner is added only when a human creates
   an unresolved comment.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
+* The rules for service accounts are different, see link:#bots[Bots].
 
 *!IMPORTANT!* These rules are not meant to be super smart and to always do the
 right thing, e.g. if the change owner sends a reply, then they are often
@@ -85,7 +86,7 @@
 
 image::images/user-attention-set-reply-select.png["reply dialog section for selecting users", align="center"]
 
-=== Bots
+=== Bots [[bots]]
 
 The attention set is meant for human reviews only. Triggering bots and reacting
 to their results is a different workflow and not in scope of the attenion set.
@@ -145,7 +146,7 @@
 instead.
 
 The "Assignee" feature can be turned on/off with the
-link:config-gerrit.html#change.enableAttentionSet[enableAssignee] config option.
+link:config-gerrit.html#change.enableAssignee[enableAssignee] config option.
 
 === Bold Changes / Mark Reviewed
 
diff --git a/Documentation/user-inline-edit.txt b/Documentation/user-inline-edit.txt
index cd26792..4a9d18f 100644
--- a/Documentation/user-inline-edit.txt
+++ b/Documentation/user-inline-edit.txt
@@ -38,11 +38,11 @@
    *  Select branch for new change: Specify the destination branch of the
       change.
 
-   *  Provide base commit SHA1 for change: Leave this field blank.
+   *  Provide base commit SHA-1 for change: Leave this field blank.
 
 +
-IMPORTANT: Git uses a unique SHA1 value to identify each and every commit (in
-other words, each Git commit generates a new SHA1 hash). This value differs
+IMPORTANT: Git uses a unique SHA-1 value to identify each and every commit (in
+other words, each Git commit generates a new SHA-1 hash). This value differs
 from a Gerrit Change-Id, which is used by Gerrit to uniquely identify a
 change. The Gerrit Change-Id remains static throughout the life of a Gerrit
 change.
diff --git a/Documentation/user-named-destinations.txt b/Documentation/user-named-destinations.txt
index 1b6f143..a1ab258 100644
--- a/Documentation/user-named-destinations.txt
+++ b/Documentation/user-named-destinations.txt
@@ -13,6 +13,7 @@
 row in a destination file represents a single destination in the
 named set.  The left column represents the ref of the destination,
 and the right column represents the project of the destination.
+The named destinations can be publicly accessible by other users.
 
 Example destination file named `destinations/myreviews`:
 
diff --git a/Documentation/user-named-queries.txt b/Documentation/user-named-queries.txt
index e79b3da..c01f790 100644
--- a/Documentation/user-named-queries.txt
+++ b/Documentation/user-named-queries.txt
@@ -7,7 +7,8 @@
 link:intro-user.html#user-refs[user's ref] in the `All-Users` project.  The
 user's queries file is a 2 column tab delimited file.  The left
 column represents the name of the query, and the right column
-represents the query expression represented by the name.
+represents the query expression represented by the name. The named queries
+can be publicly accessible by other users.
 
 Example queries file:
 
diff --git a/Documentation/user-porting-comments.txt b/Documentation/user-porting-comments.txt
new file mode 100644
index 0000000..8b6c005
--- /dev/null
+++ b/Documentation/user-porting-comments.txt
@@ -0,0 +1,37 @@
+#  Porting Comments User Documentation
+
+Report a bug or send feedback using this [Monorail template](https://bugs.chromium.org/p/gerrit/issues/entry?template=Porting+Comments). You can also report a bug through the bug icon in the comment.
+
+Comments in Gerrit are associated with a patchset. When a new patchset is uploaded, the comments are lost since they are not associated with the newer patchset.
+
+image::images/user-porting-comments-original-comment.png["Comment left on Patchset 15", align="center"]
+
+To solve this issue, Gerrit now has “Ported Comments”. These are comments that were left on an older patchset displayed on all the newer patchsets uploaded. For example, a comment left on Patchset 6 will be ported over to Patchset 7, 8 and all subsequent patchsets that are uploaded, not just the latest patchset.
+
+Ported comments are not copies of the comment but the comment simply shown in another place.
+
+Which comments are ported over?
+
+*   Unresolved comments
+*   Unresolved drafts
+*   Resolved drafts
+
+Resolved comments are not ported over.
+
+image::images/user-porting-comments-ported-comment.png["Comment ported over to patchset 16", align="center"]
+
+## Interaction
+
+Ported comments are visually the same as normal comments. They have a link at the top which shows the original patchset of the comment and links to it.
+
+Interacting with the ported comments is exactly the same as interacting with the original comment (again, they are simply the original comment shown in a different location). \
+Marking a ported comment resolved/unresolved will also update the original comment.
+
+
+## Position
+
+Gerrit tries to calculate the position of this comment on the new version of the file and shows the comment on that position for the newer patchset.
+
+It’s not always possible to calculate an appropriate position for a comment. In this case, Gerrit attaches these comments as File Level Comments.
+
+In some exceptional cases (such as the entire file being reverted), there is no appropriate file to associate this comment with. In this case we do not port this comment over. The comment is still present at its original location and visible in the Comments Tab & Change Log.
diff --git a/Documentation/user-privacy.txt b/Documentation/user-privacy.txt
new file mode 100644
index 0000000..d61ee76
--- /dev/null
+++ b/Documentation/user-privacy.txt
@@ -0,0 +1,113 @@
+:linkattrs:
+= Gerrit Code Review - User Privacy
+
+== Purpose
+
+This page documents how Gerrit handles user data.
+
+|===
+| Note: Gerrit has extensive support for link:config-plugins.html[plugins]
+  which extend Gerrits functionality, and these plugins could access, export, or
+  maniuplate user data. This document only focuses on the behavior of Gerrit
+  core and its link:dev-core-plugins.html[core plugins].
+|===
+
+== Types of User Data
+
+Gerrit stores account data required for collaborating on source code changes.
+This data is described by
+link:config-accounts.html#account-data-in-user-branch[Account Data in User
+Branch] and includes link:config-accounts.html#external-ids[External IDs],
+link:config-accounts.html#preferences[User Preferences],
+link:config-accounts.html#project-watches[Project Watches] and personally
+identifiable information, including  name and email address. The email
+address is required to associate Git commits with a Gerrit user account. All
+data except passwords is made accessible to other users who you are visible to,
+as detailed below.
+
+== User Visibility
+
+Gerrit has a concept of link:config-gerrit.html#accounts[account visibility]
+which determines what users a given user can see. This visibility configuration
+applies in account search, reviewer suggestion, and when accessing data through
+the link:rest-api-accounts.html#account-endpoints[Account REST endpoints]. If
+you can see a user, you have read access to most of the
+link:rest-api-accounts.html#account-info[AccountInfo] for that user, including
+name and email address. Additional information, including secondary emails, is
+included in AccountInfo if the caller has “Modify Account” permissions.
+
+Additionally, all users on a change (author, cc’d, reviewer) can see each other,
+irrespective of the  account visibility settings. For example: Say you are a
+reviewer on a change where user Foo is also a reviewer. Even if by account
+visibility you could not search for Foo, you'd still see their avatar, name,
+and email now because you can see the change; this information is required to
+collaborate on a code review. If Foo wasn't on that change, you could not add
+them because reviewer suggestions would not find them due to the account
+visibility settings.
+
+By default, account visibility on a Gerrit instance is set to `ALL` which allows
+all users to be visible to other users, even anonymous (i.e. unauthenticated)
+users. Depending on your installation type, you may want to change this:
+
+* For completely company-internal Gerrit installations (no external users), the
+`ALL` default may make sense.
+
+* If you work with multiple vendors who have
+access to their own independent sets of repos, `VISIBLE_GROUP` may be more
+appropriate as you wouldn’t want vendor A to see accounts from vendor B.
+
+* For public installations, e.g. for open source projects, you may want to
+change this setting or add a notice for users when they create an account e.g.
+“Most of what you submit on this site, including your email address and name,
+will be visible to others who use this service. You may prefer to use an email
+account specifically for this purpose.” One way to do this is using
+link:config-gerrit.html[`auth.registerPageUrl`] in `gerrit.config`.
+
+== ACLs and User Visibility
+
+User suggestions for changes, when adding a reviewer or cc-ing someone, always
+respect ACLs for that change: only users who can see the change are suggested.
+The suggested users are an intersection of who you can see and who can see the
+change.
+
+Consider the following situation:
+
+* `READ` permission for Registered Users on the host
+* User visibility is set to `VISIBILE_GROUP`, so only users of the same domain can
+  see each other
+* a@foo.com creates change 123
+
+This would mean:
+
+* a@foo.com cannot add b@bar.com to the change because these users cannot see
+  each other due to the user visibility setting.
+* b@bar.com can find change 123
+  because they have READ permission and could add themselves to the change.
+* a@foo.com would then be able to see b@bar.com’s name, avatar, and email on
+  change 123
+
+The only caveat to the above are Private Changes, which are only visible to the
+owner and reviewers; reviewers can only see the change once they are added to
+the change (if ACLs allow them to be added in the first place), not before.
+
+## Right to be Forgotten Limitations
+
+As a source control system, Gerrit has limited abilities to remove personally
+identifiable information. Notably, Gerrit cannot:
+
+* Remove a user's e-mail from all existing commits
+* Remove a user's username
+
+There is also a known
+link:https://bugs.chromium.org/p/gerrit/issues/detail?id=14185[bug] where a
+user's username is stored in metadata for link:user-attention-set.html[Attention
+Set].
+
+
+## Open Source Software Limitations
+
+Gerrit is open-source software licensed under the Apache 2.0 license.  Unless
+required by applicable law or agreed to in writing, software distributed under
+the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS
+OF ANY KIND, either express or implied. See the License for the specific
+language governing permissions and limitations under the License.
\ No newline at end of file
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 06c5ab7..b33bea8 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -3,8 +3,13 @@
 
 Reviewing changes is an important task and the Gerrit Web UI provides
 many functionalities to make the review process comfortable and
-efficient. This is a guide through the review UI that explains the
-different functions and UI elements.
+efficient.
+
+The UI has three different main views,
+
+** The dashboard, which shows all changes that are relevant to you
+** The change screen, which shows the change with all its metadata
+** The diff view, which shows changes to a single file
 
 [[change-screen]]
 == Change Screen
@@ -14,41 +19,27 @@
 
 image::images/user-review-ui-change-screen.png[width=800, link="images/user-review-ui-change-screen.png"]
 
-[[commit-message]]
-=== Commit Message Block
+Here are the main areas of the screen
 
-The focus of the change screen is on the commit message since this is
-the most important information about a change. The numeric change ID
-and the change status are displayed right above the commit message.
+image::images/user-review-ui-change-screen-annotated.png[width=800, link="images/user-review-ui-change-screen-annotated.png"]
 
-image::images/user-review-ui-change-screen-commit-message.png[width=800, link="images/user-review-ui-change-screen-commit-message.png"]
 
-[[permalink]]
-The numeric change ID is a link to the change and clicking on it
-refreshes the change screen. By copying the link location you can get
-the permalink of the change.
+=== Top info
 
-image::images/user-review-ui-change-screen-permalink.png[width=800, link="images/user-review-ui-change-screen-permalink.png"]
+Top left, you find the status of the change, and a permalink.
+
+image::images/user-review-ui-change-screen-topleft.png[width=400, link="images/user-review-ui-change-screen-topleft.png"]
 
 [[change-status]]
 The change status shows the state of the change:
 
-- [[needs]]`Needs <label>`:
+- [[active]]`Active`:
 +
-The change is in review and an approval on the shown label is still
-required to make the change submittable.
+The change is under active review.
 
-- [[not]]`Not <label>`:
+- [[merge-conflict]]`Merge Conflict`:
 +
-The change is in review and a veto vote on the shown label is
-preventing the submit.
-
-- [[not-current]]`Not Current`:
-+
-The currently viewed patch set is outdated.
-+
-Please note that some operations, like voting, are not available on
-outdated patch sets, but only on the current patch set.
+The change can't be merged due to conflicts.
 
 - [[ready-to-submit]]`Ready to Submit`:
 +
@@ -62,52 +53,28 @@
 +
 The change was abandoned.
 
-[[commit-info]]
-=== Commit Info Block
+[[star]]
+=== Star Change
 
-The commit info block shows information about the commit of the
-currently viewed patch set.
-
-It displays the author and the committer as links to a list of this
-person's changes that have the same status as the currently viewed
-change.
-
-The commit ID, the parent commit(s) and the link:user-changeid.html[Change-Id] are
-displayed with a copy-to-clipboard icon that allows the ID to be copied
-into the clipboard.
-
-If a Git web browser, such as gitweb or Gitiles, is configured, there
-is also a link to the commit in the Git web browser.
-
-image::images/user-review-ui-change-screen-commit-info.png[width=800, link="images/user-review-ui-change-screen-commit-info.png"]
-
-If a merge commit is viewed this is highlighted by an icon.
-
-image::images/user-review-ui-change-screen-commit-info-merge-commit.png[width=800, link="images/user-review-ui-change-screen-commit-info-merge-commit.png"]
+Clicking the star icon marks the change as a favorite: it turns on
+email notifications for this change, and the change is added to the
+list under `Your` > `Starred Changes`. They can be queried by the
+link:user-search.html#is[is:starred] search operator.
 
 [[change-info]]
-=== Change Info Block
+=== Change metadata
 
-The change info block contains detailed information about the change
+The change metadata block contains detailed information about the change
 and offers actions on the change.
 
-image::images/user-review-ui-change-screen-change-info.png[width=800, link="images/user-review-ui-change-screen-change-info.png"]
-
-- [[change-owner]]Change Owner:
-+
-The owner of the change is displayed as a link to a list of the owner's
-changes that have the same status as the currently viewed change.
-+
-image::images/user-review-ui-change-screen-change-info-owner.png[width=800, link="images/user-review-ui-change-screen-change-info-owner.png"]
-
 - [[reviewers]]Reviewers:
 +
-The reviewers of the change are displayed as chip tokens.
+The reviewers of the change are displayed as chips.
 +
 For each reviewer there is a tooltip that shows on which labels the
 reviewer is allowed to vote.
 +
-New reviewers can be added by clicking on the `Add...` button. Typing
+New reviewers can be added by clicking on the pencil icon. Typing
 into the pop-up text field activates auto completion of user and group
 names.
 +
@@ -126,7 +93,7 @@
    and Gerrit administrators may remove anyone.
 
 +
-image::images/user-review-ui-change-screen-change-info-reviewers.png[width=800, link="images/user-review-ui-change-screen-change-info-reviewers.png"]
+image::images/user-review-ui-change-screen-info-reviewers.png[width=600, link="images/user-review-ui-change-screen-reviewers.png"]
 
 - [[project-branch-topic]]Project / Branch / Topic:
 +
@@ -135,9 +102,6 @@
 dashboard] of the project. If no default dashboard is defined, the link
 opens a list of open changes on the project.
 +
-Clicking on the settings icon on the right side navigates to the
-project administration screen.
-+
 The name of the destination branch is displayed as a link to a list
 with all changes on this branch that have the same status as the
 currently viewed change.
@@ -147,28 +111,16 @@
 link:access-control.html#category_edit_topic_name[Edit Topic Name]
 access right. To be able to set a topic on a closed change, the
 `Edit Topic Name` must be assigned with the `force` flag.
-+
-image::images/user-review-ui-change-screen-change-info-project-branch-topic.png[width=800, link="images/user-review-ui-change-screen-change-info-project-branch-topic.png"]
 
 - [[submit-strategy]]Submit Strategy:
 +
 The link:project-setup.html#submit_type[submit strategy] that will be
 used to submit the change. The submit strategy is only displayed for
 open changes.
-+
-image::images/user-review-ui-change-screen-change-info-submit-strategy.png[width=800, link="images/user-review-ui-change-screen-change-info-submit-strategy.png"]
-+
-If a change cannot be merged due to path conflicts this is highlighted
-by a bold red `Cannot Merge` label.
-+
-image::images/user-review-ui-change-screen-change-info-cannot-merge.png[width=800, link="images/user-review-ui-change-screen-change-info-cannot-merge.png"]
-
-- [[update-time]]Time of Last Update:
-+
-image::images/user-review-ui-change-screen-change-info-last-update.png[width=800, link="images/user-review-ui-change-screen-change-info-last-update.png"]
 
 - [[actions]]Actions:
 +
+Actions buttons are at the top, and in the overflow menu.
 Depending on the change state and the permissions of the user, different
 actions are available on the change:
 
@@ -266,13 +218,13 @@
 ** [[plugin-actions]]Further actions may be available if plugins are installed.
 
 +
-image::images/user-review-ui-change-screen-change-info-actions.png[width=800, link="images/user-review-ui-change-screen-change-info-actions.png"]
+image::images/user-review-ui-change-screen-change-info-actions.png[width=600, link="images/user-review-ui-change-screen-change-info-actions.png"]
 
 - [[labels]]Labels & Votes:
 +
-Approving votes are colored green; veto votes are colored red.
+Approving votes are colored green; negative votes are colored red.
 +
-image::images/user-review-ui-change-screen-change-info-labels.png[width=800, link="images/user-review-ui-change-screen-change-info-labels.png"]
+image::images/user-review-ui-change-screen-change-info-labels.png[width=400, link="images/user-review-ui-change-screen-change-info-labels.png"]
 
 [[files]]
 === File List
@@ -299,128 +251,15 @@
 The list of commits that are being integrated into the destination
 branch by submitting the merge commit.
 
-[[change-screen-mark-reviewed]]
-The checkboxes in front of the file names allow files to be marked as reviewed.
-
-image::images/user-review-ui-change-screen-file-list-mark-as-reviewed.png[width=800, link="images/user-review-ui-change-screen-file-list-mark-as-reviewed.png"]
-
-[[modification-type]]
-The type of a file modification is indicated by the character in front
-of the file name:
-
-- `M` or 'no character' (Modified):
-+
-The file existed before this change and is modified.
-
-- `A` (Added):
-+
-The file is newly added.
-
-- `D` (Deleted):
-+
-The file is deleted.
-
-- `R` (Renamed):
-+
-The file is renamed.
-
-- `C` (Copied):
-+
-The file is new and is copied from an existing file.
-
-- `U` (Unchanged):
-+
-The file is unchanged and has the same content. Unchanged files only
-appear in the file list if 2 patch sets are compared and the file has
-comments on at least one of the sides. Otherwise unchanged files are
-filtered out.
-
-image::images/user-review-ui-change-screen-file-list-modification-type.png[width=800, link="images/user-review-ui-change-screen-file-list-modification-type.png"]
-
-[[rename-or-copy]]
-If a file is renamed or copied, the name of the original file is
-displayed in gray below the file name.
-
-image::images/user-review-ui-change-screen-file-list-rename.png[width=800, link="images/user-review-ui-change-screen-file-list-rename.png"]
-
-[[repeating-path-segments]]
-Repeating path segments are grayed out.
-
-image::images/user-review-ui-change-screen-file-list-repeating-paths.png[width=800, link="images/user-review-ui-change-screen-file-list-repeating-paths.png"]
-
-[[inline-comments-column]]
-Inline comments on a file are shown in the `Comments` column.
-
-Draft comments, i.e. comments that have been written by the current
-user but not yet published, are highlighted in red.
-
-New comments from other users, that were published after the current
-user last reviewed this change, are highlighted in bold.
-
-image::images/user-review-ui-change-screen-file-list-comments.png[width=800, link="images/user-review-ui-change-screen-file-list-comments.png"]
-
-[[size]]
-The size of the modifications in the files can be seen in the `Size` column. The
-footer row shows the total size of the change.
-
-The size information is useful to easily spot the files that contain
-the most modifications; these files are likely to be the most relevant
-files for this change. The total change size gives an estimate of how
-long a review of this change may take.
-
-When the "Show Change Sizes As Colored Bars" user preference is enabled, the
-`Size` column shows the sum of inserted and deleted lines as one number.  In
-addition, the change size is shown as a bar. The size of the bar indicates the
-amount of changed lines, and its coloring shows the proportion of insertions
-(green) to deletions (red).
-
-When the "Show Change Sizes As Colored Bars" user preference is disabled, the
-colored bar is not shown.  For added and renamed files, the `Size` column
-shows the number of inserted and deleted lines. For new files, the column only
-shows the total number of lines in the new file. No size is shown for binary
-files and deleted files.
-
-image::images/user-review-ui-change-screen-file-list-size.png[width=800, link="images/user-review-ui-change-screen-file-list-size.png"]
-
-[[diff-against]]
-In the header of the file list, the `Diff Against` selection can be
-changed. This selection allows one to choose if the currently viewed
-patch set should be compared against its base or against another patch
-set of this change. The file list is updated accordingly.
-
-[[open-all]]
-The file list header also provides an `Open All` button that opens the
-diff views for all files in the file list.
-
-image::images/user-review-ui-change-screen-file-list-header.png[width=800, link="images/user-review-ui-change-screen-file-list-header.png"]
-
 [[patch-sets]]
 === Patch Sets
 
 The change screen only presents one patch set at a time. Which patch
 set is currently viewed can be seen from the `Patch Sets` drop-down
-panel in the change header. It shows the number of the currently viewed
-patch set and the total number of patch sets, in the form: "current
-patch set/number of patch sets".
+panel in the change header.
 
-If a non-current patch set is viewed this is indicated by the
-link:#not-current[Not Current] change state. Please note that some
-operations are only available on the current patch set.
+image::images/user-review-ui-change-screen-patch-sets.png[width=487, link="images/user-review-ui-change-screen-patch-sets.png"]
 
-image::images/user-review-ui-change-screen-patch-sets.png[width=800, link="images/user-review-ui-change-screen-patch-sets.png"]
-
-Another indication is a highlighted drop-down label.
-
-image::images/user-review-ui-change-screen-not-current.png[width=800, link="images/user-review-ui-change-screen-not-current.png"]
-
-[[patch-set-drop-down]]
-The patch set drop-down list shows the list of patch sets and allows to
-switch between them. The patch sets are sorted in descending order so
-that the current patch set is always on top.
-
-Draft patch sets are marked with `DRAFT`.
-
-image::images/user-review-ui-change-screen-patch-set-list.png[width=800, link="images/user-review-ui-change-screen-patch-set-list.png"]
 
 [[download]]
 === Download
@@ -455,36 +294,18 @@
 formats (e.g. tar and tbz2); which formats are available depends on the
 configuration of the server.
 
-image::images/user-review-ui-change-screen-download-commands-list.png[width=800, link="images/user-review-ui-change-screen-download-commands-list.png"]
-
 [[included-in]]
 === Included In
 
-For merged changes the `Included In` drop-down panel is available in
-the change header.
+For merged changes the `Included In` drop-down panel is available
+through the overflow menu at the top. It shows the branches and tags
+in which the change is included. E.g. if a change fixes a bug, this
+shows which released versions contain the bug-fix (assuming that every
+release is tagged).
 
 image::images/user-review-ui-change-screen-included-in.png[width=800, link="images/user-review-ui-change-screen-included-in.png"]
 
-The `Included In` drop-down panel shows the branches and tags in which
-the change is included. E.g. if a change fixes a bug, this allows to
-quickly see in which released versions the bug-fix is contained
-(assuming that every release is tagged).
 
-image::images/user-review-ui-change-screen-included-in-list.png[width=800, link="images/user-review-ui-change-screen-included-in-list.png"]
-
-[[star]]
-=== Star Change
-
-The star icon in the change header allows to mark the change as a
-favorite. Clicking on the star icon again, unstars the change.
-
-image::images/user-review-ui-change-screen-star.png[width=800, link="images/user-review-ui-change-screen-star.png"]
-
-Starring a change turns on email notifications for this change.
-
-Starred changed are listed under `My` > `Starred Changes`.
-and can be queried by the link:user-search.html#is[is:starred] search
-operator.
 
 [[related-changes]]
 === Related Changes
@@ -513,29 +334,27 @@
 For merged changes this tab is only shown if there are open
 descendants.
 +
-image::images/user-review-ui-change-screen-related-changes.png[width=800, link="images/user-review-ui-change-screen-related-changes.png"]
-+
-Related changes may be decorated with an icon to signify dependencies
+Related changes may be annotated with dependencies
 on outdated patch sets, or commits that are not associated to changes
 under review:
 +
-** [[outdated]]Orange Dot:
+** [[not-current]]Not current:
 +
 The selected patch set of the change is outdated; it is not the current
 patch set of the change.
 +
-If an ancestor change is marked with an orange dot it means that the
+It means that the
 currently viewed patch set depends on a outdated patch set of the
 ancestor change. This is because a new patch set for the ancestor
 change was uploaded in the meantime and as result the currently viewed
 patch set now needs to be rebased.
 +
-If a descendant change is marked with an orange dot it means that an
+If a descendant change is marked "not current" it means that an
 old patch set of the descendant change depends on the currently viewed
 patch set. It may be that the descendant was rebased in the meantime
 and with the new patch set this dependency was removed.
 
-** [[indirect-descendant]]Green Tilde:
+** [[indirect-descendant]]Indirect descendant:
 +
 The selected patch set of the change is an indirect descendant of the
 currently viewed patch set; it has a dependency to another patch set of
@@ -544,7 +363,7 @@
 note that following the link to an indirect descendant change may
 result in a completely different related changes listing.
 
-** [[closed-ancestor]]Black Dot:
+** [[closed-ancestor]]Closed ancestor:
 +
 Indicates a closed ancestor, e.g. the commit was directly pushed into
 the repository bypassing code review, or the ancestor change was
@@ -552,38 +371,24 @@
 the user has accidentally pushed the commit to the wrong branch, e.g.
 the commit was done on `branch-a`, but was then pushed to
 `refs/for/branch-b`.
-A black dot is also present if the change was abandoned.
 
-** [[closed-ancestor-abandoned]]Strikethrough Subject:
+** [[closed-ancestor-abandoned]]Abandoned:
 +
-When the commit is abandoned, its subject line will be striked
-through.
-
-+
-image::images/user-review-ui-change-screen-related-changes-indicators.png[width=800, link="images/user-review-ui-change-screen-related-changes-indicators.png"]
+Indicates an abandoned change.
 
 - [[conflicts-with]]`Conflicts With`:
 +
-This tab page shows changes that conflict with the current change.
+This section shows changes that conflict with the current change.
 Non-mergeable changes are filtered out; only conflicting changes that
 are mergeable are shown.
 +
 If this change is merged, its conflicting changes will have merge
 conflicts and must be rebased. The rebase of the other changes with the
 conflict resolution must then be done manually.
-+
-image::images/user-review-ui-change-screen-conflicts-with.png[width=800, link="images/user-review-ui-change-screen-conflicts-with.png"]
-
-- [[same-topic]]`Same Topic`:
-+
-This tab page shows changes that have the same topic as the current
-change. Only open changes are included in the list.
-+
-image::images/user-review-ui-change-screen-same-topic.png[width=800, link="images/user-review-ui-change-screen-same-topic.png"]
 
 - [[submitted-together]]`Submitted Together`:
 +
-This tab page shows changes that will be submitted together with the
+This section shows changes that will be submitted together with the
 currently viewed change, when clicking the submit button. It includes
 ancestors of the current patch set.
 +
@@ -594,7 +399,7 @@
 
 - [[cherry-picks]]`Cherry-Picks`:
 +
-This tab page shows changes with the same link:user-changeid.html[
+This section shows changes with the same link:user-changeid.html[
 Change-Id] for the current project.
 +
 Abandoned changes are filtered out.
@@ -602,7 +407,6 @@
 For each change in this list the destination branch is shown as a
 prefix in front of the change subject.
 +
-image::images/user-review-ui-change-screen-cherry-picks.png[width=800, link="images/user-review-ui-change-screen-cherry-picks.png"]
 
 If there are no related changes for a tab, the tab is not displayed.
 
@@ -613,7 +417,7 @@
 currently viewed patch set; one can add a summary comment, publish
 inline draft comments, and vote on the labels.
 
-image::images/user-review-ui-change-screen-reply.png[width=800, link="images/user-review-ui-change-screen-reply.png"]
+image::images/gwt-user-review-ui-change-screen-reply.png[width=800, link="images/gwt-user-review-ui-change-screen-reply.png"]
 
 Clicking on the `Reply...` button opens a popup panel.
 
@@ -639,7 +443,7 @@
 
 The `Post` button publishes the comments and the votes.
 
-image::images/user-review-ui-change-screen-replying.png[width=800, link="images/user-review-ui-change-screen-replying.png"]
+image::images/gwt-user-review-ui-change-screen-replying.png[width=800, link="images/gwt-user-review-ui-change-screen-replying.png"]
 
 [[quick-approve]]
 If a user can approve a label that is still required, a quick approve
@@ -658,7 +462,7 @@
 comments; a summary comment is only added if the reply popup panel is
 open when the quick approve button is clicked.
 
-image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/user-review-ui-change-screen-quick-approve.png"]
+image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/gwt-user-review-ui-change-screen-quick-approve.png"]
 
 [[history]]
 === History
@@ -672,7 +476,7 @@
 Messages with new comments from other users, that were published after
 the current user last reviewed this change, are automatically expanded.
 
-image::images/user-review-ui-change-screen-history.png[width=800, link="images/user-review-ui-change-screen-history.png"]
+image::images/gwt-user-review-ui-change-screen-history.png[width=800, link="images/gwt-user-review-ui-change-screen-history.png"]
 
 [[reply-to-message]]
 It is possible to directly reply to a change message by clicking on the
@@ -683,13 +487,13 @@
 Please note that for a correct rendering it is important to leave a blank
 line between a quoted block and the reply to it.
 
-image::images/user-review-ui-change-screen-reply-to-comment.png[width=800, link="images/user-review-ui-change-screen-reply-to-comment.png"]
+image::images/gwt-user-review-ui-change-screen-reply-to-comment.png[width=800, link="images/gwt-user-review-ui-change-screen-reply-to-comment.png"]
 
 [[inline-comments-in-history]]
 Inline comments are directly displayed in the change history and there
 are links to navigate to the inline comments.
 
-image::images/user-review-ui-change-screen-inline-comments.png[width=800, link="images/user-review-ui-change-screen-inline-comments.png"]
+image::images/gwt-user-review-ui-change-screen-inline-comments.png[width=800, link="images/gwt-user-review-ui-change-screen-inline-comments.png"]
 
 [[expand-all]]
 The `Expand All` button expands all messages; the `Collapse All` button
@@ -706,7 +510,7 @@
 it is 30 seconds. Polling may also be completely disabled by the
 administrator.
 
-image::images/user-review-ui-change-screen-change-update.png[width=800, link="images/user-review-ui-change-screen-change-update.png"]
+image::images/gwt-user-review-ui-change-screen-change-update.png[width=800, link="images/gwt-user-review-ui-change-screen-change-update.png"]
 
 [[plugin-extensions]]
 === Plugin Extensions
@@ -715,7 +519,7 @@
 additional actions to the change info block and display arbitrary UI
 controls below the change info block.
 
-image::images/user-review-ui-change-screen-plugin-extensions.png[width=800, link="images/user-review-ui-change-screen-plugin-extensions.png"]
+image::images/gwt-user-review-ui-change-screen-plugin-extensions.png[width=800, link="images/gwt-user-review-ui-change-screen-plugin-extensions.png"]
 
 [[side-by-side]]
 == Side-by-Side Diff Screen
@@ -726,7 +530,7 @@
 
 This screen allows to review a patch and to comment on it.
 
-image::images/user-review-ui-side-by-side-diff-screen.png[width=800, link="images/user-review-ui-side-by-side-diff-screen.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen.png"]
 
 [[side-by-side-header]]
 In the screen header the project name and the name of the viewed patch
@@ -736,31 +540,15 @@
 the file path are displayed as links to the project and the folder in
 the Git web browser.
 
-image::images/user-review-ui-side-by-side-diff-screen-project-and-file.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-project-and-file.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png"]
 
 [[side-by-side-mark-reviewed]]
-The checkbox in front of the project name and the file name allows the
+The checkbox in front of the file name allows the
 patch to be marked as reviewed. The link:#mark-reviewed[Mark Reviewed]
 diff preference allows to control whether the files should be
 automatically marked as reviewed when they are viewed.
 
-image::images/user-review-ui-side-by-side-diff-screen-reviewed.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-reviewed.png"]
-
-[[scrollbar]]
-The scrollbar shows patch diffs and inline comments as annotations.
-This provides a good overview of the lines in the patch that are
-relevant for reviewing. By clicking on an annotation one can quickly
-navigate to the corresponding line in the patch.
-
-image::images/user-review-ui-side-by-side-diff-screen-scrollbar.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-scrollbar.png"]
-
-[[gaps]]
-A gap between lines in the file content that is caused by aligning the
-left and right side or by displaying inline comments is shown as a
-vertical red bar in the line number column. This prevents a gap from
-being mistaken for blank lines in the file
-
-image::images/user-review-ui-side-by-side-diff-screen-red-bar.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-red-bar.png"]
+image::images/user-review-ui-side-by-side-diff-screen-reviewed.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-reviewed.png"]
 
 [[patch-set-selection]]
 In the header, on each side, the list of patch sets is shown. Clicking
@@ -780,7 +568,7 @@
 version before, may see what has changed since that version by
 comparing the old patch against the current patch.
 
-image::images/user-review-ui-side-by-side-diff-screen-patch-sets.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-patch-sets.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png"]
 
 [[download-file]]
 The download icon next to the patch set list allows to download the
@@ -791,14 +579,14 @@
 If the compared patches are identical, this is highlighted by a red
 `No Differences` label in the screen header.
 
-image::images/user-review-ui-side-by-side-diff-screen-no-differences.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-no-differences.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png"]
 
 [[side-by-side-rename]]
 If a file was renamed, the old and new file paths are shown in the
 header together with a similarity index that shows how much of the file
 content is unmodified.
 
-image::images/user-review-ui-side-by-side-diff-screen-rename.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-rename.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen-rename.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-rename.png"]
 
 [[navigation]]
 For navigating between the patches in a patch set there are navigation
@@ -807,7 +595,7 @@
 the next patch. The arrow up button leads back to the change screen. In
 all cases the selection for the patch set comparison is kept.
 
-image::images/user-review-ui-side-by-side-diff-screen-navigation.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-navigation.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png"]
 
 [[inline-comments]]
 === Inline Comments
@@ -833,7 +621,7 @@
 If the diff preference link:#expand-all-comments[Expand All Comments]
 is set to `Expand`, all inline comments will be automatically expanded.
 
-image::images/user-review-ui-side-by-side-diff-screen-inline-comments.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-inline-comments.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png"]
 
 [[comment]]
 In the header of the comment box, the name of the comment author and
@@ -842,7 +630,7 @@
 top left corner. Below the actual comment there are buttons to reply to
 the comment.
 
-image::images/user-review-ui-side-by-side-diff-screen-comment-box.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-comment-box.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png"]
 
 [[reply-inline-comment]]
 Clicking on the `Reply` button opens an editor to type the reply.
@@ -861,7 +649,7 @@
 
 Clicking on the `Discard` button deletes the inline draft comment.
 
-image::images/user-review-ui-side-by-side-diff-screen-comment-reply.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-comment-reply.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png"]
 
 [[draft-inline-comment]]
 Draft comments are marked by the text "Draft" in the header in the
@@ -870,14 +658,14 @@
 A draft comment can be edited by clicking on the `Edit` button, or
 deleted by clicking on the `Discard` button.
 
-image::images/user-review-ui-side-by-side-diff-screen-comment-edit.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-comment-edit.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png"]
 
 [[done]]
 Clicking on the `Done` button is a quick way to reply with "Done" to a
 comment. This is used to mark a comment as addressed by a follow-up
 patch set.
 
-image::images/user-review-ui-side-by-side-diff-screen-replied-done.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-replied-done.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png"]
 
 [[add-inline-comment]]
 To add a new inline comment there are several possibilities:
@@ -903,7 +691,7 @@
 ** press 'V' + arrow keys (or 'j', 'k') to select a code block line-wise
 ** type 'bvw' to select a word
 
-image::images/user-review-ui-side-by-side-diff-screen-comment.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-comment.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen-comment.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-comment.png"]
 
 For typing the new comment, a new comment box is shown under the code
 that is commented.
@@ -914,57 +702,15 @@
 
 Clicking on the `Discard` button deletes the new comment.
 
-image::images/user-review-ui-side-by-side-diff-screen-commented.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-commented.png"]
+image::images/gwt-user-review-ui-side-by-side-diff-screen-commented.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-commented.png"]
 
 [[file-level-comments]]
 === File Level Comments
 
-Comments that apply to a whole file can be added on file level.
+File level comments are added by clicking the 'File' header at the top
+of the file.
 
-File level comments are added by clicking on the comment icon in the
-header above the file.
-
-image::images/user-review-ui-side-by-side-diff-screen-file-level-comment.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-file-level-comment.png"]
-
-Clicking on the comment icon opens a comment box for typing the file
-level comment.
-
-image::images/user-review-ui-side-by-side-diff-screen-file-level-commented.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-file-level-commented.png"]
-
-[[search]]
-=== Search
-
-For searching within a patch file, a Vim-like search is supported.
-Typing `/` opens the search box. Typing in the search box immediately
-highlights matches in the patch file with a yellow background. Using
-JavaScript regular expressions in the search term is supported. The
-search is case insensitive. After confirming the search by `ENTER` one
-can navigate between the matches by `n` / `N` to go to the next /
-previous match. Skipped lines are automatically expanded if they
-contain a match and one navigates to it.
-
-For additional possibilities to search please check the
-link:http://www.vim.org/docs.php[Vim documentation,role=external,window=_blank]. There are other
-useful ways to search, e.g. while the cursor is on a word, pressing `*`
-or `#` searches for the next or previous occurrence of the word.
-
-Searching by `Ctrl-F` finds matches only in the visible area of the
-screen unless the link:#render[Render] diff preference is set to `Slow`.
-
-image::images/user-review-ui-side-by-side-diff-screen-search.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-search.png"]
-
-[[key-navigation]]
-=== Key Navigation
-
-Vim-like commands can be used to navigate within a patch file:
-
-- `h` / `j` / `k` / `l` moves the cursor left / down / up / right
-- `0` / `$` moves the cursor to the start / end of the line
-- `gg` / `G` moves to cursor to the start / end of the file
-- `Ctrl-D` / `Ctrl-U` scrolls downwards / upwards
-
-Please check the link:http://www.vim.org/docs.php[Vim documentation,role=external,window=_blank]
-for further information.
+image::images/user-review-ui-side-by-side-diff-screen-file-level-comment.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-file-level-comment.png"]
 
 [[diff-preferences]]
 === Diff Preferences
@@ -974,27 +720,10 @@
 preferences. The diff preferences can be accessed by clicking on the
 settings icon in the screen header.
 
-image::images/user-review-ui-side-by-side-diff-screen-preferences.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-preferences.png"]
-
-The diff preferences popup allows to change the diff preferences.
-By clicking on the `Save` button changes to the diff preferences are
-saved permanently. Clicking on the `Apply` button applies the new
-diff preferences to the current screen, but they are discarded when the
-screen is refreshed. The `Save` button is only available if the user is
-signed in.
-
-image::images/user-review-ui-side-by-side-diff-screen-preferences-popup.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-preferences-popup.png"]
+image::images/user-review-ui-side-by-side-diff-screen-preferences.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-preferences.png"]
 
 The following diff preferences can be configured:
 
-- [[theme]]`Theme`:
-+
-Controls the theme that is used to render the file content.
-+
-E.g. users could choose to work with a dark theme.
-+
-image::images/user-review-ui-side-by-side-diff-screen-dark-theme.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-dark-theme.png"]
-
 - [[ignore-whitespace]]`Ignore Whitespace`:
 +
 Controls whether differences in whitespace should be ignored or not.
@@ -1003,11 +732,11 @@
 +
 All differences in whitespace are highlighted.
 +
-** `At Line End`:
+** `Trailing`:
 +
 Whitespace differences at the end of lines are ignored.
 +
-** `Leading, At Line End`:
+** `Leading, Trailing`:
 +
 Whitespace differences at the beginning and end of lines are ignored.
 +
@@ -1021,11 +750,7 @@
 
 - [[columns]]`Columns`:
 +
-Sets the preferred line length. At this position a vertical dashed line
-is displayed so that one can easily detect lines the exceed the
-preferred line length.
-+
-image::images/user-review-ui-side-by-side-diff-screen-column.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-column.png"]
+Sets the preferred line length. At this position, lines are wrapped.
 
 - [[lines-of-context]]`Lines Of Context`:
 +
@@ -1042,134 +767,42 @@
 If many lines are skipped there are additional links to expand the
 context by ten lines before and after the skipped block.
 +
-image::images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png"]
-
-- [[intraline-difference]]`Intraline Difference`:
-+
-Controls whether intraline differences should be highlighted.
-+
-image::images/user-review-ui-side-by-side-diff-screen-intraline-difference.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-intraline-difference.png"]
+image::images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png"]
 
 - [[syntax-highlighting]]`Syntax Highlighting`:
 +
 Controls whether syntax highlighting should be enabled.
 +
 The language for the syntax highlighting is automatically detected from
-the file extension. The language can also be set manually by selecting
-it from the `Language` drop-down list.
-+
-image::images/user-review-ui-side-by-side-diff-screen-syntax-coloring.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-syntax-coloring.png"]
+the file extension.
 
-- [[whitespace-errors]]`Whitespace Errors`:
+- [[whitespace-errors]]`Show trailing whitespace`:
 +
-Controls whether whitespace errors are highlighted.
+Controls whether trailing whitespace is highlighted.
 
 - [[show-tabs]]`Show Tabs`:
 +
 Controls whether tabs are highlighted.
 
-- [[line-numbers]]`Line Numbers`:
-+
-Controls whether line numbers are shown.
-
-- [[empty-pane]]`Empty Pane`:
-+
-Controls whether empty panes are shown or not. The Left pane is empty when a
-file was added; the right pane is empty when a file was deleted.
-
-- [[left-side]]`Left Side`:
-+
-Controls whether the left side is shown. This preference is not
-persistent and is ignored by the `Save` button. Every time a
-patch diff is opened, this preference is reset to `Show`.
-
-- [[top-menu]]`Top Menu`:
-+
-Controls whether the top menu is shown.
-
-- [[auto-hide-diff-table-header]]`Auto Hide Diff Table Header`:
-+
-Controls whether the diff table header should be automatically hidden
-when scrolling down more than half of a page.
-
 - [[mark-reviewed]]`Mark Reviewed`:
 +
 Controls whether the files of the patch set should be automatically
 marked as reviewed when they are viewed.
 
-- [[expand-all-comments]]`Expand All Comments`:
-+
-Controls whether all comments should be automatically expanded.
-
-- [[render]]`Render`:
-+
-Controls how patch files that exceed the screen size are rendered.
-+
-If `Fast` is selected file contents which are outside of the visible
-area are not attached to the browser's DOM tree. This makes the
-rendering fast, but searching by `Ctrl+F` only finds content which is
-in the visible area.
-+
-If `Slow` is selected all file contents are attached to the browser's
-DOM tree, which makes the rendering slow for large files. The advantage
-of this setting is that `Ctrl+F` can be used to search in the complete
-file.
-+
-Large files that exceed 4000 lines will not be fully rendered.
-
-- [[line-wrapping]]`Line Wrapping`:
-+
-Controls whether to enable line wrapping or not.
-+
-If `false` is selected then line wrapping is disabled.
-This is the default option.
-+
-If `true` is selected then line wrapping is enabled.
-
 [[keyboard-shortcuts]]
 == Keyboard Shortcuts
 
 Navigation within the review UI can be completely done by keys, and
 most actions can be controlled by keyboard shortcuts. Typing `?` opens
-a popup that shows a list of available keyboard shortcuts:
+a popup that shows a list of available keyboard shortcuts.
 
-- Change Screen
-+
-image::images/user-review-ui-change-screen-keyboard-shortcuts.png[width=800, link="images/user-review-ui-change-screen-keyboard-shortcuts.png"]
 
-- Side-by-Side Diff Screen
-+
-image::images/user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png"]
-+
+image::images/user-review-ui-change-screen-keyboard-shortcuts.png[width=800, link="images/gwt-user-review-ui-change-screen-keyboard-shortcuts.png"]
+
+
 In addition, Vim-like commands can be used to link:#key-navigation[
 navigate] and link:#search[search] within a patch file.
 
-[[new-vs-old]]
-== New Review UI vs. Old Review UI
-
-There are some important conceptual differences between the old and
-new review UIs:
-
-- The old change screen directly shows all patch sets of the change.
-  With the new change screen only a single patch set is displayed;
-  users can switch between the patch sets by choosing another patch
-  set from the link:#patch-sets[Patch Sets] drop down panel in the
-  screen header.
-- On the old side-by-side diff screen, new comments are inserted by
-  double-clicking on a line. With the new side-by-side diff screen
-  double-click is used to select a word for commenting on it; there
-  are link:#add-inline-comment[several ways to insert new comments],
-  e.g. by selecting a code block and clicking on the popup comment
-  icon.
-
-[[limitations]]
-Limitations of the new review UI:
-
-- The new side-by-side diff screen cannot render images.
-
-- The new side-by-side diff screen isn't able to highlight line
-  endings.
-
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index b22788a..8055f5f 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -35,7 +35,7 @@
 |=============================================================
 
 For change searches (i.e. those using a numerical id, Change-Id, or commit
-SHA1), if the search results in a single change that change will be
+SHA-1), if the search results in a single change that change will be
 presented instead of a list.
 
 For more predictable results, use explicit search operators as described
@@ -92,6 +92,18 @@
 format `2006-01-02[ 15:04:05[.890][ -0700]]`; omitting the time defaults
 to 00:00:00 and omitting the timezone defaults to UTC.
 
+[[mergedbefore]]
+mergedbefore:'TIME'::
++
+Changes merged before the given 'TIME'. The matching behaviour is consistent
+with `before:'TIME'`.
+
+[[mergedafter]]
+mergedafter:'TIME'::
++
+Changes merged after the given 'TIME'. The matching behaviour is consistent
+with `after:'TIME'`.
+
 [[change]]
 change:'ID'::
 +
@@ -106,9 +118,11 @@
 that was scraped out of the commit message.
 
 [[destination]]
-destination:'NAME'::
+destination:'[name=]NAME[,user=USER]'::
 +
-Changes which match the current user's destination named 'NAME'.
+Changes which match the specified USER's destination named 'NAME'. If 'USER'
+is unspecified, the current user is used. The named destinations can be
+publicly accessible by other users.
 (see link:user-named-destinations.html[Named Destinations]).
 
 [[owner]]
@@ -123,9 +137,11 @@
 Changes originally submitted by a user in 'GROUP'.
 
 [[query]]
-query:'NAME'::
+query:'[name=]NAME[,user=USER]'::
 +
-Changes which match the current user's query named 'NAME'
+Changes which match the specified USER's query named 'NAME'. If 'USER'
+is unspecified, the current user is used. The named queries can be
+publicly accessible by other users.
 (see link:user-named-queries.html[Named Queries]).
 
 [[reviewer]]
@@ -157,9 +173,9 @@
 Changes that have been, or need to be, reviewed by a user in 'GROUP'.
 
 [[commit]]
-commit:'SHA1'::
+commit:'SHA-1'::
 +
-Changes where 'SHA1' is one of the patch sets of the change.
+Changes where 'SHA-1' is one of the patch sets of the change.
 
 [[project]]
 project:'PROJECT', p:'PROJECT'::
@@ -174,6 +190,13 @@
 +
 Changes occurring in projects starting with 'PREFIX'.
 
+[[parentof]]
+parentof:'ID'::
+Changes which are parent to the change specified by 'ID'. Change 'ID' can be
+specified as a legacy numerical 'ID' such as 15183, or a Change-Id that can be
+picked from the commit message. This operator will return immediate parents
+and will not return grand parents or higher level ancestors of the given change.
+
 [[parentproject]]
 parentproject:'PROJECT'::
 +
@@ -474,7 +497,7 @@
 not find any abandoned but mergeable changes.
 +
 This operator only works if Gerrit indexes 'mergeable'. See
-link:config-gerrit.html#index.change.indexMergeable[indexMergeable]
+link:config-gerrit.html#change.mergeabilityComputationBehavior[change.mergeabilityComputationBehavior]
 for details.
 
 [[ignored]]
diff --git a/WORKSPACE b/WORKSPACE
index cdec888..b501243 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -30,7 +30,7 @@
 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:nongoogle.bzl", "TESTCONTAINERS_VERSION", "declare_nongoogle_deps")
 
 http_archive(
     name = "platforms",
@@ -43,20 +43,20 @@
 
 http_archive(
     name = "rbe_jdk11",
-    sha256 = "766796de71916118e528b9f4334c29c9c9b4e926227bf3264dee555e6a4306c8",
-    strip_prefix = "rbe_autoconfig-2.0.0",
+    sha256 = "5939e2a4e56d1fc53b6c44c6db97ee068c9f4bd18e86c762f6ab8b4fff5e294b",
+    strip_prefix = "rbe_autoconfig-3.0.0",
     urls = [
-        "https://gerrit-bazel.storage.googleapis.com/rbe_autoconfig/v2.0.0.tar.gz",
-        "https://github.com/davido/rbe_autoconfig/archive/v2.0.0.tar.gz",
+        "https://gerrit-bazel.storage.googleapis.com/rbe_autoconfig/v3.0.0.tar.gz",
+        "https://github.com/davido/rbe_autoconfig/archive/v3.0.0.tar.gz",
     ],
 )
 
 http_archive(
     name = "com_google_protobuf",
-    sha256 = "71030a04aedf9f612d2991c1c552317038c3c5a2b578ac4745267a45e7037c29",
-    strip_prefix = "protobuf-3.12.3",
+    sha256 = "d0f5f605d0d656007ce6c8b5a82df3037e1d8fe8b121ed42e536f569dec16113",
+    strip_prefix = "protobuf-3.14.0",
     urls = [
-        "https://github.com/protocolbuffers/protobuf/archive/v3.12.3.tar.gz",
+        "https://github.com/protocolbuffers/protobuf/archive/v3.14.0.tar.gz",
     ],
 )
 
@@ -103,10 +103,10 @@
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
-    sha256 = "a8d6b1b354d371a646d2f7927319974e0f9e52f73a2452d2b3877118169eb6bb",
+    sha256 = "4d838e2d70b955ef9dd0d0648f673141df1bc1d7ecf5c2d621dcc163f47dd38a",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
-        "https://github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
+        "https://github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
     ],
 )
 
@@ -118,10 +118,10 @@
 
 http_archive(
     name = "bazel_gazelle",
-    sha256 = "cdb02a887a7187ea4d5a27452311a75ed8637379a1287d8eeb952138ea485f7d",
+    sha256 = "222e49f034ca7a1d1231422cdb67066b885819885c356673cb1f72f748a3c9d4",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
-        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
+        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
     ],
 )
 
@@ -170,6 +170,12 @@
 )
 
 maven_jar(
+    name = "aopalliance",
+    artifact = "aopalliance:aopalliance:1.0",
+    sha1 = "0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8",
+)
+
+maven_jar(
     name = "javax_inject",
     artifact = "javax.inject:javax.inject:1",
     sha1 = "6975da39a7040257bd51d21a231b76c915872d38",
@@ -181,13 +187,6 @@
     sha1 = "3cd63d075497751784b2fa84be59432f4905bf7c",
 )
 
-# JGit's transitive dependencies
-maven_jar(
-    name = "hamcrest-library",
-    artifact = "org.hamcrest:hamcrest-library:1.3",
-    sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
-)
-
 maven_jar(
     name = "jzlib",
     artifact = "com.jcraft:jzlib:1.1.1",
@@ -213,14 +212,6 @@
     sha1 = "f645ed69d595b24d4cf8b3fbb64cc505bede8829",
 )
 
-load("//lib:guava.bzl", "GUAVA_BIN_SHA1", "GUAVA_VERSION")
-
-maven_jar(
-    name = "guava",
-    artifact = "com.google.guava:guava:" + GUAVA_VERSION,
-    sha1 = GUAVA_BIN_SHA1,
-)
-
 CAFFEINE_VERS = "2.8.5"
 
 maven_jar(
@@ -369,156 +360,156 @@
     sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
 )
 
-FLEXMARK_VERS = "0.34.18"
+FLEXMARK_VERS = "0.50.42"
 
 maven_jar(
     name = "flexmark",
     artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
-    sha1 = "65cc1489ef8902023140900a3a7fcce89fba678d",
+    sha1 = "ed537d7bc31883b008cc17d243a691c7efd12a72",
 )
 
 maven_jar(
     name = "flexmark-ext-abbreviation",
     artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
-    sha1 = "a0384932801e51f16499358dec69a730739aca3f",
+    sha1 = "dc27c3e7abbc8d2cfb154f41c68645c365bb9d22",
 )
 
 maven_jar(
     name = "flexmark-ext-anchorlink",
     artifact = "com.vladsch.flexmark:flexmark-ext-anchorlink:" + FLEXMARK_VERS,
-    sha1 = "6df2e23b5c94a5e46b1956a29179eb783f84ea2f",
+    sha1 = "6a8edb0165f695c9c19b7143a7fbd78c25c3b99c",
 )
 
 maven_jar(
     name = "flexmark-ext-autolink",
     artifact = "com.vladsch.flexmark:flexmark-ext-autolink:" + FLEXMARK_VERS,
-    sha1 = "069f8ff15e5b435cc96b23f31798ce64a7a3f6d3",
+    sha1 = "5da7a4d009ea08ef2d8714cc73e54a992c6d2d9a",
 )
 
 maven_jar(
     name = "flexmark-ext-definition",
     artifact = "com.vladsch.flexmark:flexmark-ext-definition:" + FLEXMARK_VERS,
-    sha1 = "ff177d8970810c05549171e3ce189e2c68b906c0",
+    sha1 = "862d17812654624ed81ce8fc89c5ef819ff45f87",
 )
 
 maven_jar(
     name = "flexmark-ext-emoji",
     artifact = "com.vladsch.flexmark:flexmark-ext-emoji:" + FLEXMARK_VERS,
-    sha1 = "410bf7d8e5b8bc2c4a8cff644d1b2bc7b271a41e",
+    sha1 = "f0d7db64cb546798742b1ffc6db316a33f6acd76",
 )
 
 maven_jar(
     name = "flexmark-ext-escaped-character",
     artifact = "com.vladsch.flexmark:flexmark-ext-escaped-character:" + FLEXMARK_VERS,
-    sha1 = "6f4fb89311b54284a6175341d4a5e280f13b2179",
+    sha1 = "6fd9ab77619df417df949721cb29c45914b326f8",
 )
 
 maven_jar(
     name = "flexmark-ext-footnotes",
     artifact = "com.vladsch.flexmark:flexmark-ext-footnotes:" + FLEXMARK_VERS,
-    sha1 = "35efe7d9aea97b6f36e09c65f748863d14e1cfe4",
+    sha1 = "e36bd69e43147cc6e19c3f55e4b27c0fc5a3d88c",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-issues",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-issues:" + FLEXMARK_VERS,
-    sha1 = "ec1d660102f6a1d0fbe5e57c13b7ff8bae6cff72",
+    sha1 = "5c825dd4e4fa4f7ccbe30dc92d7e35cdcb8a8c24",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-strikethrough",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:" + FLEXMARK_VERS,
-    sha1 = "6060442b742c9b6d4d83d7dd4f0fe477c4686dd2",
+    sha1 = "3256735fd77e7228bf40f7888b4d3dc56787add4",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-tables",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tables:" + FLEXMARK_VERS,
-    sha1 = "2fe597849e46e02e0c1ea1d472848f74ff261282",
+    sha1 = "62f0efcfb974756940ebe749fd4eb01323babc29",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-tasklist",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:" + FLEXMARK_VERS,
-    sha1 = "b3af19ce4efdc980a066c1bf0f5a6cf8c24c487a",
+    sha1 = "76d4971ad9ce02f0e70351ab6bd06ad8e405e40d",
 )
 
 maven_jar(
     name = "flexmark-ext-gfm-users",
     artifact = "com.vladsch.flexmark:flexmark-ext-gfm-users:" + FLEXMARK_VERS,
-    sha1 = "7456c5f7272c195ee953a02ebab4f58374fb23ee",
+    sha1 = "7b0fc7e42e4da508da167fcf8e1cbf9ba7e21147",
 )
 
 maven_jar(
     name = "flexmark-ext-ins",
     artifact = "com.vladsch.flexmark:flexmark-ext-ins:" + FLEXMARK_VERS,
-    sha1 = "13fe1a95a8f3be30b574451cfe8d3d5936fa3e94",
+    sha1 = "9e51809867b9c4db0fb1c29599b4574e3d2a78e9",
 )
 
 maven_jar(
     name = "flexmark-ext-jekyll-front-matter",
     artifact = "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:" + FLEXMARK_VERS,
-    sha1 = "e146e2bf3a740d6ef06a33a516c4d1f6d3761109",
+    sha1 = "44eb6dbb33b3831d3b40af938ddcd99c9c16a654",
 )
 
 maven_jar(
     name = "flexmark-ext-superscript",
     artifact = "com.vladsch.flexmark:flexmark-ext-superscript:" + FLEXMARK_VERS,
-    sha1 = "02541211e8e4a6c89ce0a68b07b656d8a19ac282",
+    sha1 = "35815b8cb91000344d1fe5df21cacde8553d2994",
 )
 
 maven_jar(
     name = "flexmark-ext-tables",
     artifact = "com.vladsch.flexmark:flexmark-ext-tables:" + FLEXMARK_VERS,
-    sha1 = "775d9587de71fd50573f32eee98ab039b4dcc219",
+    sha1 = "f6768e98c7210b79d5e8bab76fff27eec6db51e6",
 )
 
 maven_jar(
     name = "flexmark-ext-toc",
     artifact = "com.vladsch.flexmark:flexmark-ext-toc:" + FLEXMARK_VERS,
-    sha1 = "85b75fe1ebe24c92b9d137bcbc51d232845b6077",
+    sha1 = "1968d038fc6c8156f244f5a7eecb34e7e2f33705",
 )
 
 maven_jar(
     name = "flexmark-ext-typographic",
     artifact = "com.vladsch.flexmark:flexmark-ext-typographic:" + FLEXMARK_VERS,
-    sha1 = "c1bf0539de37d83aa05954b442f929e204cd89db",
+    sha1 = "6549b9862b61c4434a855a733237103df9162849",
 )
 
 maven_jar(
     name = "flexmark-ext-wikilink",
     artifact = "com.vladsch.flexmark:flexmark-ext-wikilink:" + FLEXMARK_VERS,
-    sha1 = "400b23b9a4e0c008af0d779f909ee357628be39d",
+    sha1 = "e105b09dd35aab6e6f5c54dfe062ee59bd6f786a",
 )
 
 maven_jar(
     name = "flexmark-ext-yaml-front-matter",
     artifact = "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:" + FLEXMARK_VERS,
-    sha1 = "491f815285a8e16db1e906f3789a94a8a9836fa6",
+    sha1 = "b2d3a1e7f3985841062e8d3203617e29c6c21b52",
 )
 
 maven_jar(
     name = "flexmark-formatter",
     artifact = "com.vladsch.flexmark:flexmark-formatter:" + FLEXMARK_VERS,
-    sha1 = "d46308006800d243727100ca0f17e6837070fd48",
+    sha1 = "a50c6cb10f6d623fc4354a572c583de1372d217f",
 )
 
 maven_jar(
     name = "flexmark-html-parser",
     artifact = "com.vladsch.flexmark:flexmark-html-parser:" + FLEXMARK_VERS,
-    sha1 = "fece2e646d11b6a77fc611b4bd3eb1fb8a635c87",
+    sha1 = "46c075f30017e131c1ada8538f1d8eacf652b044",
 )
 
 maven_jar(
     name = "flexmark-profile-pegdown",
     artifact = "com.vladsch.flexmark:flexmark-profile-pegdown:" + FLEXMARK_VERS,
-    sha1 = "297f723bb51286eaa7029558fac87d819643d577",
+    sha1 = "d9aafd47629959cbeddd731f327ae090fc92b60f",
 )
 
 maven_jar(
     name = "flexmark-util",
     artifact = "com.vladsch.flexmark:flexmark-util:" + FLEXMARK_VERS,
-    sha1 = "31e2e1fbe8273d7c913506eafeb06b1a7badb062",
+    sha1 = "417a9821d5d80ddacbfecadc6843ae7b259d5112",
 )
 
 # Transitive dependency of flexmark and gitiles
@@ -564,36 +555,36 @@
     sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
 )
 
-OW2_VERS = "7.2"
+OW2_VERS = "9.0"
 
 maven_jar(
     name = "ow2-asm",
     artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "fa637eb67eb7628c915d73762b681ae7ff0b9731",
+    sha1 = "af582ff60bc567c42d931500c3fdc20e0141ddf9",
 )
 
 maven_jar(
     name = "ow2-asm-analysis",
     artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "b6e6abe057f23630113f4167c34bda7086691258",
+    sha1 = "4630afefbb43939c739445dde0af1a5729a0fb4e",
 )
 
 maven_jar(
     name = "ow2-asm-commons",
     artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "ca2954e8d92a05bacc28ff465b25c70e0f512497",
+    sha1 = "5a34a3a9ac44f362f35d1b27932380b0031a3334",
 )
 
 maven_jar(
     name = "ow2-asm-tree",
     artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "3a23cc36edaf8fc5a89cb100182758ccb5991487",
+    sha1 = "9df939f25c556b0c7efe00701d47e77a49837f24",
 )
 
 maven_jar(
     name = "ow2-asm-util",
     artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "a3ae34e57fa8a4040e28247291d0cc3d6b8c7bcf",
+    sha1 = "7c059a94ab5eed3347bf954e27fab58e52968848",
 )
 
 AUTO_VALUE_VERSION = "1.7.4"
@@ -610,6 +601,38 @@
     sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
 )
 
+AUTO_VALUE_GSON_VERSION = "1.3.0"
+
+maven_jar(
+    name = "auto-value-gson-runtime",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "a69a9db5868bb039bd80f60661a771b643eaba59",
+)
+
+maven_jar(
+    name = "auto-value-gson-extension",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "6a61236d17b58b05e32b4c532bcb348280d2212b",
+)
+
+maven_jar(
+    name = "auto-value-gson-factory",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "b1f01918c0d6cb1f5482500e6b9e62589334dbb0",
+)
+
+maven_jar(
+    name = "javapoet",
+    artifact = "com.squareup:javapoet:1.13.0",
+    sha1 = "d6562d385049f35eb50403fa86bb11cce76b866a",
+)
+
+maven_jar(
+    name = "autotransient",
+    artifact = "io.sweers.autotransient:autotransient:1.0.0",
+    sha1 = "38b1c630b8e76560221622289f37be40105abb3d",
+)
+
 declare_nongoogle_deps()
 
 LUCENE_VERS = "6.6.5"
@@ -725,13 +748,6 @@
     sha1 = "b8ba1c1eb8b2e45cfd465d01218c6060e887572e",
 )
 
-# Keep this version of Soy synchronized with the version used in Gitiles.
-maven_jar(
-    name = "soy",
-    artifact = "com.google.template:soy:2019-10-08",
-    sha1 = "4518bf8bac2dbbed684849bc209c39c4cb546237",
-)
-
 maven_jar(
     name = "html-types",
     artifact = "com.google.common.html.types:types:1.0.8",
@@ -800,104 +816,66 @@
 # Test-only dependencies below.
 
 maven_jar(
-    name = "jimfs",
-    artifact = "com.google.jimfs:jimfs:1.1",
-    sha1 = "8fbd0579dc68aba6186935cc1bee21d2f3e7ec1c",
-)
-
-maven_jar(
     name = "junit",
     artifact = "junit:junit:4.12",
     sha1 = "2973d150c0dc1fefe998f834810d68f278ea58ec",
 )
 
 maven_jar(
-    name = "hamcrest-core",
-    artifact = "org.hamcrest:hamcrest-core:1.3",
-    sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
-)
-
-TRUTH_VERS = "1.1"
-
-maven_jar(
-    name = "truth",
-    artifact = "com.google.truth:truth:" + TRUTH_VERS,
-    sha1 = "6a096a16646559c24397b03f797d0c9d75ee8720",
-)
-
-maven_jar(
-    name = "truth-java8-extension",
-    artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-    sha1 = "258db6eb8df61832c5c059ed2bc2e1c88683e92f",
-)
-
-maven_jar(
-    name = "truth-liteproto-extension",
-    artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
-    sha1 = "bf65afa13aa03330e739bcaa5d795fe0f10fbf20",
-)
-
-maven_jar(
-    name = "truth-proto-extension",
-    artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
-    sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
-)
-
-maven_jar(
     name = "diffutils",
     artifact = "com.googlecode.java-diff-utils:diffutils:1.3.0",
     sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
 )
 
-JETTY_VERS = "9.4.35.v20201120"
+JETTY_VERS = "9.4.36.v20210114"
 
 maven_jar(
     name = "jetty-servlet",
     artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "3e61bcb471e1bfc545ce866cbbe33c3aedeec9b1",
+    sha1 = "b189e52a5ee55ae172e4e99e29c5c314f5daf4b9",
 )
 
 maven_jar(
     name = "jetty-security",
     artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "80dc2f422789c78315de76d289b7a5b36c3232d5",
+    sha1 = "42030d6ed7dfc0f75818cde0adcf738efc477574",
 )
 
 maven_jar(
     name = "jetty-server",
     artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "513502352fd689d4730b2935421b990ada8cc818",
+    sha1 = "88a7d342974aadca658e7386e8d0fcc5c0788f41",
 )
 
 maven_jar(
     name = "jetty-jmx",
     artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "38812031940a466d626ab5d9bbbd9d5d39e9f735",
+    sha1 = "bb3847eabe085832aeaedd30e872b40931632e54",
 )
 
 maven_jar(
     name = "jetty-http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "45d35131a35a1e76991682174421e8cdf765fb9f",
+    sha1 = "1eee89a55e04ff94df0f85d95200fc48acb43d86",
 )
 
 maven_jar(
     name = "jetty-io",
     artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "eb9460700b99b71ecd82a53697f5ff99f69b9e1c",
+    sha1 = "84a8faf9031eb45a5a2ddb7681e22c483d81ab3a",
 )
 
 maven_jar(
     name = "jetty-util",
     artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "ef61b83f9715c3b5355b633d9f01d2834f908ece",
+    sha1 = "925257fbcca6b501a25252c7447dbedb021f7404",
 )
 
 maven_jar(
     name = "jetty-util-ajax",
     artifact = "org.eclipse.jetty:jetty-util-ajax:" + JETTY_VERS,
-    sha1 = "ebbb43912c6423bedb3458e44aee28eeb4d66f27",
-    src_sha1 = "b3acea974a17493afb125a9dfbe783870ce1d2f9",
+    sha1 = "2f478130c21787073facb64d7242e06f94980c60",
+    src_sha1 = "7153d7ca38878d971fd90992c303bb7719ba7a21",
 )
 
 maven_jar(
@@ -920,28 +898,28 @@
 
 maven_jar(
     name = "mockito",
-    artifact = "org.mockito:mockito-core:2.24.0",
-    sha1 = "969a7bcb6f16e076904336ebc7ca171d412cc1f9",
+    artifact = "org.mockito:mockito-core:3.3.3",
+    sha1 = "4878395d4e63173f3825e17e5e0690e8054445f1",
 )
 
-BYTE_BUDDY_VERSION = "1.9.7"
+BYTE_BUDDY_VERSION = "1.10.7"
 
 maven_jar(
     name = "bytebuddy",
     artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
-    sha1 = "8fea78fea6449e1738b675cb155ce8422661e237",
+    sha1 = "1eefb7dd1b032b33c773ca0a17d5cc9e6b56ea1a",
 )
 
 maven_jar(
     name = "bytebuddy-agent",
     artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
-    sha1 = "8e7d1b599f4943851ffea125fd9780e572727fc0",
+    sha1 = "c472fad33f617228601172682aa64f8b78508045",
 )
 
 maven_jar(
     name = "objenesis",
-    artifact = "org.objenesis:objenesis:2.6",
-    sha1 = "639033469776fd37c08358c6b92a4761feb2af4b",
+    artifact = "org.objenesis:objenesis:3.0.1",
+    sha1 = "11cfac598df9dc48bb9ed9357ed04212694b7808",
 )
 
 load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
@@ -1190,3 +1168,19 @@
 )
 
 external_plugin_deps()
+
+# When upgrading elasticsearch-rest-client, also upgrade httpcore-nio
+# and httpasyncclient as necessary in tools/nongoogle.bzl. Consider
+# also the other org.apache.httpcomponents dependencies in
+# WORKSPACE.
+maven_jar(
+    name = "elasticsearch-rest-client",
+    artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.8.1",
+    sha1 = "59feefe006a96a39f83b0dfb6780847e06c1d0a8",
+)
+
+maven_jar(
+    name = "testcontainers-elasticsearch",
+    artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
+    sha1 = "6b778a270b7529fcb9b7a6f62f3ae9d38544ce2f",
+)
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
index e51e29d..774a382 100755
--- a/contrib/populate-fixture-data.py
+++ b/contrib/populate-fixture-data.py
@@ -157,7 +157,7 @@
         BASE_URL + "groups/?suggest=ad&p=All-Projects",
         headers=HEADERS,
         auth=ADMIN_BASIC_AUTH).text))
-    admin_group_name = r.keys()[0]
+    admin_group_name = list(r.keys())[0]
     GROUP_ADMIN = r[admin_group_name]
     GROUP_ADMIN["name"] = admin_group_name
 
@@ -305,7 +305,7 @@
     project_names = create_gerrit_projects(group_names)
 
     for idx, u in enumerate(gerrit_users):
-        for _ in xrange(random.randint(1, 5)):
-            create_change(u, project_names[4 * idx / len(gerrit_users)])
+        for _ in range(random.randint(1, 5)):
+            create_change(u, project_names[4 * idx // len(gerrit_users)])
 
 main()
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 482c804..426c806 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -48,6 +48,7 @@
 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.entities.AccessSection;
 import com.google.gerrit.entities.Account;
@@ -59,6 +60,7 @@
 import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
@@ -74,6 +76,7 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.projects.BranchApi;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -114,7 +117,6 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
@@ -143,7 +145,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
-import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
@@ -155,6 +156,7 @@
 import java.nio.file.FileSystems;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.security.KeyPair;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -168,10 +170,13 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -558,7 +563,7 @@
         && (adminSshSession == null || userSshSession == null)) {
       // Create Ssh sessions
       KeyPair adminKeyPair = sshKeys.getKeyPair(admin);
-      GitUtil.initSsh(adminKeyPair);
+      SshSessionFactory.initSsh(adminKeyPair);
       Context ctx = newRequestContext(user);
       atrScope.set(ctx);
       userSshSession = ctx.getSession();
@@ -580,6 +585,7 @@
     ProjectInput in = new ProjectInput();
     TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
     in.name = name("project");
+    in.branches = ImmutableList.of(Constants.R_HEADS + Constants.MASTER);
     if (ann != null) {
       in.parent = Strings.emptyToNull(ann.parent());
       in.description = Strings.emptyToNull(ann.description());
@@ -766,6 +772,57 @@
     return result;
   }
 
+  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.
+    // Each commit will contain all the fileNames. Commit i will have the following file names and
+    // their contents:
+    // {$file_1_name, ${file_1_name}-1}
+    // {$file_2_name, ${file_2_name}-1}, etc...
+    // The merge commit will have:
+    // {$file_1_name, ${file_1_name}-1}
+    // {$file_2_name, ${file_2_name}-2},
+    // {$file_3_name, ${file_3_name}-3}, etc...
+    // i.e. taking the ith file from the ith commit.
+    int n = fileNames.size();
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    List<PushOneCommit.Result> pushResults = new ArrayList<>();
+
+    for (int i = 1; i <= n; i++) {
+      int finalI = i;
+      pushResults.add(
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  testRepo,
+                  "parent " + i,
+                  fileNames.stream().collect(Collectors.toMap(f -> f, f -> f + "-" + finalI)))
+              .to(ref));
+
+      // reset HEAD in order to create a sibling of the first change
+      if (i < n) {
+        testRepo.reset(initial);
+      }
+    }
+
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "merge",
+            IntStream.range(1, n + 1)
+                .boxed()
+                .collect(
+                    Collectors.toMap(
+                        i -> fileNames.get(i - 1), i -> fileNames.get(i - 1) + "-" + i)));
+
+    m.setParents(pushResults.stream().map(PushOneCommit.Result::getCommit).collect(toList()));
+    PushOneCommit.Result result = m.to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
   protected PushOneCommit.Result createCommitAndPush(
       TestRepository<InMemoryRepository> repo,
       String ref,
@@ -1295,6 +1352,16 @@
     assertThat(rule.getMax()).isEqualTo(expectedMax);
   }
 
+  protected void assertHead(String projectName, String expectedRef) throws Exception {
+    // Assert gerrit's project head points to the correct branch
+    assertThat(getProjectBranches(projectName).get(Constants.HEAD).revision)
+        .isEqualTo(RefNames.shortName(expectedRef));
+    // Assert git head points to the correct branch
+    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
+      assertThat(repo.exactRef(Constants.HEAD).getTarget().getName()).isEqualTo(expectedRef);
+    }
+  }
+
   protected InternalGroup group(AccountGroup.UUID groupUuid) {
     InternalGroup group = groupCache.get(groupUuid).orElse(null);
     assertWithMessage(groupUuid.get()).that(group).isNotNull();
@@ -1568,6 +1635,12 @@
     return comments;
   }
 
+  protected ImmutableMap<String, BranchInfo> getProjectBranches(String projectName)
+      throws RestApiException {
+    return gApi.projects().name(projectName).branches().get().stream()
+        .collect(ImmutableMap.toImmutableMap(branch -> branch.ref, branch -> branch));
+  }
+
   protected AutoCloseable installPlugin(String pluginName, Class<? extends Module> sysModuleClass)
       throws Exception {
     return installPlugin(pluginName, sysModuleClass, null, null);
diff --git a/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
new file mode 100644
index 0000000..a4ed80a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.CommandModule;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import java.io.BufferedWriter;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.util.Collections;
+import java.util.List;
+
+public class AbstractDynamicOptionsTest extends AbstractDaemonTest {
+  protected static final String LS_SAMPLES = "ls-samples";
+
+  protected interface Bean {
+    void setSamples(List<String> samples);
+  }
+
+  protected static class ListSamples implements Bean, DynamicOptions.BeanReceiver {
+    protected List<String> samples = Collections.emptyList();
+
+    @Override
+    public void setSamples(List<String> samples) {
+      this.samples = samples;
+    }
+
+    public void display(OutputStream displayOutputStream) throws Exception {
+      PrintWriter stdout =
+          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, "UTF-8")));
+      try {
+        OutputFormat.JSON
+            .newGson()
+            .toJson(samples, new TypeToken<List<String>>() {}.getType(), stdout);
+        stdout.print('\n');
+      } finally {
+        stdout.flush();
+      }
+    }
+
+    @Override
+    public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {}
+  }
+
+  @CommandMetaData(name = LS_SAMPLES, runsAt = MASTER_OR_SLAVE)
+  protected static class ListSamplesCommand extends SshCommand {
+    @Inject private ListSamples impl;
+
+    @Override
+    protected void run() throws Exception {
+      impl.display(out);
+    }
+
+    @Override
+    protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
+      parseCommandLine(impl, pluginOptions);
+    }
+  }
+
+  public static class PluginOneSshModule extends CommandModule {
+    @Override
+    public void configure() {
+      command(LS_SAMPLES).to(ListSamplesCommand.class);
+    }
+  }
+
+  protected static class ListSamplesOptions implements DynamicOptions.BeanParseListener {
+    @Override
+    public void onBeanParseStart(String plugin, Object bean) {
+      ((Bean) bean).setSamples(Lists.newArrayList("sample1", "sample2"));
+    }
+
+    @Override
+    public void onBeanParseEnd(String plugin, Object bean) {}
+  }
+
+  protected static class PluginTwoModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(DynamicOptions.DynamicBean.class)
+          .annotatedWith(
+              Exports.named("com.google.gerrit.acceptance.AbstractDynamicOptionsTest.ListSamples"))
+          .to(ListSamplesOptionsClassNameProvider.class);
+    }
+  }
+
+  protected static class ListSamplesOptionsClassNameProvider
+      implements DynamicOptions.ClassNameProvider {
+    @Override
+    public String getClassName() {
+      return "com.google.gerrit.acceptance.AbstractDynamicOptionsTest$ListSamplesOptions";
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java b/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java
new file mode 100644
index 0000000..6acf486
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gerrit.sshd.commands.Query;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.internal.UniqueAnnotations;
+import java.util.Collections;
+import org.kohsuke.args4j.Option;
+
+public class AbstractLifecycleListenersTest extends AbstractDaemonTest {
+  protected static class SimpleModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(Query.class))
+          .to(MyClassNameProvider.class);
+      bind(DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(QueryChanges.class))
+          .to(MyClassNameProvider.class);
+    }
+  }
+
+  protected static class MyClassNameProvider implements DynamicOptions.ModulesClassNamesProvider {
+    @Override
+    public String getClassName() {
+      return "com.google.gerrit.acceptance.AbstractLifecycleListenersTest$MyOptions";
+    }
+
+    @Override
+    public Iterable<String> getModulesClassNames() {
+      return Collections.singleton(
+          "com.google.gerrit.acceptance.AbstractLifecycleListenersTest$MyOptions$MyOptionsModule");
+    }
+  }
+
+  public static class MyOptions implements DynamicOptions.DynamicBean {
+    @Option(name = "--opt")
+    public boolean opt;
+
+    public static class MyOptionsModule extends AbstractModule {
+      @Override
+      protected void configure() {
+        bind(LifecycleListener.class)
+            .annotatedWith(UniqueAnnotations.create())
+            .to(MyLifecycleListener.class);
+      }
+    }
+  }
+
+  protected static class MyLifecycleListener implements LifecycleListener {
+    protected final InvocationCheck invocationCheck;
+
+    @Inject
+    public MyLifecycleListener(InvocationCheck invocationCheck) {
+      this.invocationCheck = invocationCheck;
+    }
+
+    @Override
+    public void start() {
+      invocationCheck.setStartInvoked(true);
+    }
+
+    @Override
+    public void stop() {
+      invocationCheck.setStopInvoked(true);
+    }
+  }
+
+  @Singleton
+  public static class InvocationCheck {
+    private boolean isStartInvoked = false;
+    private boolean isStopInvoked = false;
+
+    public boolean isStartInvoked() {
+      return isStartInvoked;
+    }
+
+    public void setStartInvoked(boolean startInvoked) {
+      isStartInvoked = startInvoked;
+    }
+
+    public boolean isStopInvoked() {
+      return isStopInvoked;
+    }
+
+    public void setStopInvoked(boolean stopInvoked) {
+      isStopInvoked = stopInvoked;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index a91bc49..91fbf9e 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.GetChange;
@@ -40,7 +39,6 @@
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
-import com.google.inject.Module;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
@@ -86,21 +84,6 @@
     }
   }
 
-  protected static class NullAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class).toInstance((cd, bp, p) -> null);
-    }
-  }
-
-  protected static class SimpleAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
-          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
-    }
-  }
-
   protected static class PluginDefinedSimpleAttributeModule extends AbstractModule {
     @Override
     public void configure() {
@@ -170,21 +153,6 @@
     private String opt;
   }
 
-  protected static class OptionAttributeModule extends AbstractModule {
-    @Override
-    public void configure() {
-      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
-          .toInstance(
-              (cd, bp, p) -> {
-                MyOptions opts = (MyOptions) bp.getDynamicBean(p);
-                return opts != null ? new MyInfo("opt " + opts.opt) : null;
-              });
-      bind(DynamicBean.class).annotatedWith(Exports.named(Query.class)).to(MyOptions.class);
-      bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
-      bind(DynamicBean.class).annotatedWith(Exports.named(GetChange.class)).to(MyOptions.class);
-    }
-  }
-
   public static class BulkAttributeFactoryWithOption implements ChangePluginDefinedInfoFactory {
     protected MyOptions opts;
 
@@ -211,33 +179,6 @@
     }
   }
 
-  protected void getChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getter.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", NullAttributeModule.class)) {
-      assertThat(getter.call(id)).isNull();
-    }
-
-    assertThat(getter.call(id)).isNull();
-  }
-
-  protected void getChangeWithSimpleAttribute(PluginInfoGetter getter) throws Exception {
-    getChangeWithSimpleAttribute(getter, SimpleAttributeModule.class);
-  }
-
-  protected void getChangeWithSimpleAttribute(
-      PluginInfoGetter getter, Class<? extends Module> moduleClass) throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getter.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", moduleClass)) {
-      assertThat(getter.call(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
-    }
-
-    assertThat(getter.call(id)).isNull();
-  }
-
   protected void getSingleChangeWithPluginDefinedBulkAttribute(BulkPluginInfoGetterWithId getter)
       throws Exception {
     Change.Id id = createChange().getChange().getId();
@@ -298,30 +239,6 @@
     assertThat(pluginInfos.get(changeWithInfo)).isNull();
   }
 
-  protected void getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-      BulkPluginInfoGetter getter) throws Exception {
-    Change.Id id1 = createChange().getChange().getId();
-    Change.Id id2 = createChange().getChange().getId();
-
-    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
-    assertThat(pluginInfos.get(id1)).isNull();
-    assertThat(pluginInfos.get(id2)).isNull();
-
-    try (AutoCloseable ignored =
-            installPlugin("my-plugin-1", PluginDefinedSimpleAttributeModule.class);
-        AutoCloseable ignored1 = installPlugin("my-plugin-2", SimpleAttributeModule.class)) {
-      pluginInfos = getter.call();
-      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-1", "change " + id1));
-      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-2", "change " + id1));
-      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-1", "change " + id2));
-      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-2", "change " + id2));
-    }
-
-    pluginInfos = getter.call();
-    assertThat(pluginInfos.get(id1)).isNull();
-    assertThat(pluginInfos.get(id2)).isNull();
-  }
-
   protected void getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
       BulkPluginInfoGetter getter) throws Exception {
     Change.Id id1 = createChange().getChange().getId();
@@ -345,22 +262,6 @@
     assertThat(pluginInfos.get(id2)).isNull();
   }
 
-  protected void getChangeWithOption(
-      PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
-      throws Exception {
-    Change.Id id = createChange().getChange().getId();
-    assertThat(getterWithoutOptions.call(id)).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", OptionAttributeModule.class)) {
-      assertThat(getterWithoutOptions.call(id))
-          .containsExactly(new MyInfo("my-plugin", "opt null"));
-      assertThat(getterWithOptions.call(id, ImmutableListMultimap.of("my-plugin--opt", "foo")))
-          .containsExactly(new MyInfo("my-plugin", "opt foo"));
-    }
-
-    assertThat(getterWithoutOptions.call(id)).isNull();
-  }
-
   protected void getChangeWithPluginDefinedBulkAttributeOption(
       BulkPluginInfoGetterWithId getterWithoutOptions,
       BulkPluginInfoGetterWithIdAndOptions getterWithOptions)
@@ -387,7 +288,6 @@
 
     try (AutoCloseable ignored =
         installPlugin("my-plugin", PluginDefinedBulkExceptionModule.class)) {
-      PluginDefinedInfo errorInfo = new PluginDefinedInfo();
       List<PluginDefinedInfo> outputInfos = getter.call(id).get(id);
       assertThat(outputInfos).hasSize(1);
       assertThat(outputInfos.get(0).name).isEqualTo("my-plugin");
@@ -455,11 +355,6 @@
   }
 
   @FunctionalInterface
-  protected interface PluginInfoGetter {
-    List<PluginDefinedInfo> call(Change.Id id) throws Exception;
-  }
-
-  @FunctionalInterface
   protected interface BulkPluginInfoGetter {
     Map<Change.Id, List<PluginDefinedInfo>> call() throws Exception;
   }
@@ -474,10 +369,4 @@
     Map<Change.Id, List<PluginDefinedInfo>> call(
         Change.Id id, ImmutableListMultimap<String, String> pluginOptions) throws Exception;
   }
-
-  @FunctionalInterface
-  protected interface PluginInfoGetterWithOptions {
-    List<PluginDefinedInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
-        throws Exception;
-  }
 }
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java b/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java
new file mode 100644
index 0000000..60def29
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.systemstatus.ServerInformation;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.PluginLogFile;
+import com.google.gerrit.server.util.SystemLog;
+import com.google.gerrit.sshd.commands.Query;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.internal.UniqueAnnotations;
+import java.util.Collections;
+import org.apache.log4j.AsyncAppender;
+import org.apache.log4j.Layout;
+import org.apache.log4j.PatternLayout;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+
+public class AbstractPluginLogFileTest extends AbstractDaemonTest {
+  protected static class Module extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(Query.class))
+          .to(MyClassNameProvider.class);
+    }
+  }
+
+  protected static class MyClassNameProvider implements DynamicOptions.ModulesClassNamesProvider {
+    @Override
+    public String getClassName() {
+      return "com.google.gerrit.acceptance.AbstractPluginLogFileTest$MyOptions";
+    }
+
+    @Override
+    public Iterable<String> getModulesClassNames() {
+      return Collections.singleton(
+          "com.google.gerrit.acceptance.AbstractPluginLogFileTest$MyOptions$MyOptionsModule");
+    }
+  }
+
+  public static class MyOptions implements DynamicOptions.DynamicBean {
+    @Option(name = "--opt")
+    public boolean opt;
+
+    public static class MyOptionsModule extends AbstractModule {
+      @Override
+      protected void configure() {
+        bind(LifecycleListener.class)
+            .annotatedWith(UniqueAnnotations.create())
+            .to(MyPluginLogFile.class);
+      }
+    }
+  }
+
+  protected static class MyPluginLogFile extends PluginLogFile {
+    protected static final String logName = "test_log";
+
+    @Inject
+    public MyPluginLogFile(MySystemLog mySystemLog, ServerInformation serverInfo) {
+      super(mySystemLog, serverInfo, logName, new PatternLayout("[%d] [%t] %m%n"));
+    }
+  }
+
+  @Singleton
+  protected static class MySystemLog extends SystemLog {
+    protected InvocationCounter invocationCounter;
+
+    @Inject
+    public MySystemLog(SitePaths site, Config config, InvocationCounter invocationCounter) {
+      super(site, config);
+      this.invocationCounter = invocationCounter;
+    }
+
+    @Override
+    public AsyncAppender createAsyncAppender(
+        String name, Layout layout, boolean rotate, boolean forPlugin) {
+      invocationCounter.increment();
+      return super.createAsyncAppender(name, layout, rotate, forPlugin);
+    }
+  }
+
+  @Singleton
+  public static class InvocationCounter {
+    private int counter = 0;
+
+    public int getCounter() {
+      return counter;
+    }
+
+    public synchronized void increment() {
+      counter++;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AbstractPredicateTest.java b/java/com/google/gerrit/acceptance/AbstractPredicateTest.java
index c629959..c8bdb63 100644
--- a/java/com/google/gerrit/acceptance/AbstractPredicateTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPredicateTest.java
@@ -17,13 +17,14 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.DynamicOptions;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.restapi.change.QueryChanges;
@@ -33,8 +34,11 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import org.kohsuke.args4j.Option;
 
 public abstract class AbstractPredicateTest extends AbstractDaemonTest {
@@ -50,7 +54,7 @@
       bind(DynamicOptions.DynamicBean.class)
           .annotatedWith(Exports.named(QueryChanges.class))
           .to(MyQueryOptions.class);
-      bind(ChangeAttributeFactory.class)
+      bind(ChangePluginDefinedInfoFactory.class)
           .annotatedWith(Exports.named("sample"))
           .to(AttributeFactory.class);
     }
@@ -61,7 +65,7 @@
     public boolean sample;
   }
 
-  protected static class AttributeFactory implements ChangeAttributeFactory {
+  protected static class AttributeFactory implements ChangePluginDefinedInfoFactory {
     private final Provider<ChangeQueryBuilder> queryBuilderProvider;
 
     @Inject
@@ -70,23 +74,27 @@
     }
 
     @Override
-    public PluginDefinedInfo create(
-        ChangeData cd, DynamicOptions.BeanProvider beanProvider, String plugin) {
+    public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+        Collection<ChangeData> cds, DynamicOptions.BeanProvider beanProvider, String plugin) {
       MyQueryOptions options = (MyQueryOptions) beanProvider.getDynamicBean(plugin);
-      PluginDefinedInfo myInfo = new PluginDefinedInfo();
+      Map<Change.Id, PluginDefinedInfo> res = new HashMap<>();
       if (options.sample) {
         try {
           Predicate<ChangeData> predicate = queryBuilderProvider.get().parse("label:Code-Review+2");
-          if (predicate.isMatchable() && predicate.asMatchable().match(cd)) {
-            myInfo.message = "matched";
-          } else {
-            myInfo.message = "not matched";
+          for (ChangeData cd : cds) {
+            PluginDefinedInfo myInfo = new PluginDefinedInfo();
+            if (predicate.isMatchable() && predicate.asMatchable().match(cd)) {
+              myInfo.message = "matched";
+            } else {
+              myInfo.message = "not matched";
+            }
+            res.put(cd.getId(), myInfo);
           }
         } catch (QueryParseException e) {
           // ignored
         }
       }
-      return myInfo;
+      return res;
     }
   }
 
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 3ab1cec..1b0954e 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -22,12 +22,13 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.notedb.Sequences;
@@ -110,7 +111,7 @@
           throw new NoSuchGroupException(n);
         }
         addGroupMember(group.get().getGroupUUID(), id);
-        if ("Service Users".equals(n)) {
+        if (ServiceUserClassifier.SERVICE_USERS.equals(n)) {
           tags.add("SERVICE_USER");
         }
       }
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index db0dc84..5ee1a08 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -40,6 +40,7 @@
     "//lib:guava-retrying",
     "//lib:jgit",
     "//lib:jgit-ssh-jsch",
+    "//lib:jgit-ssh-apache",
     "//lib:jsch",
     "//lib/commons:compress",
     "//lib/commons:lang",
@@ -47,10 +48,12 @@
     "//lib/guice",
     "//lib/guice:guice-assistedinject",
     "//lib/guice:guice-servlet",
+    "//lib/log:log4j",
     "//lib/mail",
     "//lib/mina:sshd",
     "//lib:guava",
     "//lib/bouncycastle:bcpg",
+    "//lib/bouncycastle:bcpkix",
     "//lib/bouncycastle:bcprov",
     "//prolog:gerrit-prolog-common",
 ]
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index a5d8d19..d72ee3f 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -26,22 +26,26 @@
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
+import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.git.receive.PluginPushOption;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.git.validators.RefOperationValidationListener;
 import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
@@ -58,6 +62,7 @@
   private final DynamicSet<GroupIndexedListener> groupIndexedListeners;
   private final DynamicSet<ProjectIndexedListener> projectIndexedListeners;
   private final DynamicSet<CommitValidationListener> commitValidationListeners;
+  private final DynamicSet<TopicEditedListener> topicEditedListeners;
   private final DynamicSet<ExceptionHook> exceptionHooks;
   private final DynamicSet<PerformanceLogger> performanceLoggers;
   private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
@@ -71,6 +76,7 @@
   private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
   private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
   private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
+  private final DynamicSet<FileWebLink> fileWebLinks;
   private final DynamicSet<RevisionCreatedListener> revisionCreatedListeners;
   private final DynamicSet<GroupBackend> groupBackends;
   private final DynamicSet<AccountActivationValidationListener>
@@ -81,6 +87,8 @@
   private final DynamicMap<CapabilityDefinition> capabilityDefinitions;
   private final DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+  private final DynamicSet<PluginPushOption> pluginPushOptions;
+  private final DynamicSet<OnPostReview> onPostReviews;
 
   @Inject
   ExtensionRegistry(
@@ -89,6 +97,7 @@
       DynamicSet<GroupIndexedListener> groupIndexedListeners,
       DynamicSet<ProjectIndexedListener> projectIndexedListeners,
       DynamicSet<CommitValidationListener> commitValidationListeners,
+      DynamicSet<TopicEditedListener> topicEditedListeners,
       DynamicSet<ExceptionHook> exceptionHooks,
       DynamicSet<PerformanceLogger> performanceLoggers,
       DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
@@ -102,6 +111,7 @@
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
+      DynamicSet<FileWebLink> fileWebLinks,
       DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
       DynamicSet<GroupBackend> groupBackends,
       DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
@@ -110,12 +120,15 @@
       DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners,
       DynamicMap<CapabilityDefinition> capabilityDefinitions,
       DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions,
-      DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      DynamicSet<PluginPushOption> pluginPushOption,
+      DynamicSet<OnPostReview> onPostReviews) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
     this.projectIndexedListeners = projectIndexedListeners;
     this.commitValidationListeners = commitValidationListeners;
+    this.topicEditedListeners = topicEditedListeners;
     this.exceptionHooks = exceptionHooks;
     this.performanceLoggers = performanceLoggers;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
@@ -129,6 +142,7 @@
     this.refUpdatedListeners = refUpdatedListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
+    this.fileWebLinks = fileWebLinks;
     this.revisionCreatedListeners = revisionCreatedListeners;
     this.groupBackends = groupBackends;
     this.accountActivationValidationListeners = accountActivationValidationListeners;
@@ -138,6 +152,8 @@
     this.capabilityDefinitions = capabilityDefinitions;
     this.pluginProjectPermissionDefinitions = pluginProjectPermissionDefinitions;
     this.pluginConfigEntries = pluginConfigEntries;
+    this.pluginPushOptions = pluginPushOption;
+    this.onPostReviews = onPostReviews;
   }
 
   public Registration newRegistration() {
@@ -168,6 +184,10 @@
       return add(commitValidationListeners, commitValidationListener);
     }
 
+    public Registration add(TopicEditedListener topicEditedListener) {
+      return add(topicEditedListeners, topicEditedListener);
+    }
+
     public Registration add(ExceptionHook exceptionHook) {
       return add(exceptionHooks, exceptionHook);
     }
@@ -224,6 +244,10 @@
       return add(patchSetWebLinks, patchSetWebLink);
     }
 
+    public Registration add(FileWebLink fileWebLink) {
+      return add(fileWebLinks, fileWebLink);
+    }
+
     public Registration add(RevisionCreatedListener revisionCreatedListener) {
       return add(revisionCreatedListeners, revisionCreatedListener);
     }
@@ -262,6 +286,14 @@
       return add(pluginConfigEntries, pluginConfigEntry, exportName);
     }
 
+    public Registration add(PluginPushOption pluginPushOption) {
+      return add(pluginPushOptions, pluginPushOption);
+    }
+
+    public Registration add(OnPostReview onPostReview) {
+      return add(onPostReviews, onPostReview);
+    }
+
     private <T> Registration add(DynamicSet<T> dynamicSet, T extension) {
       return add(dynamicSet, extension, "gerrit");
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index cfc0ce4..d1e12f8 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.server.config.GerritRuntime;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
@@ -439,7 +440,8 @@
               protected void configure() {
                 bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
               }
-            }));
+            },
+            new ConfigExperimentFeatures.Module()));
     daemon.addAdditionalSysModuleForTesting(
         new ReindexProjectsAtStartup.Module(), new ReindexGroupsAtStartup.Module());
     daemon.start();
@@ -592,7 +594,7 @@
     httpAddress = new InetSocketAddress(uri.getHost(), uri.getPort());
   }
 
-  String getUrl() {
+  public String getUrl() {
     return url;
   }
 
diff --git a/java/com/google/gerrit/acceptance/GitUtil.java b/java/com/google/gerrit/acceptance/GitUtil.java
index ae72793..94d329d 100644
--- a/java/com/google/gerrit/acceptance/GitUtil.java
+++ b/java/com/google/gerrit/acceptance/GitUtil.java
@@ -20,17 +20,11 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.Project;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
-import com.jcraft.jsch.KeyPair;
-import com.jcraft.jsch.Session;
 import java.io.IOException;
 import java.util.List;
 import java.util.Optional;
-import java.util.Properties;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.FetchCommand;
@@ -47,41 +41,15 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.FetchResult;
-import org.eclipse.jgit.transport.JschConfigSessionFactory;
-import org.eclipse.jgit.transport.OpenSshConfig.Host;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
-import org.eclipse.jgit.transport.SshSessionFactory;
 import org.eclipse.jgit.util.FS;
 
 public class GitUtil {
   private static final AtomicInteger testRepoCount = new AtomicInteger();
   private static final int TEST_REPO_WINDOW_DAYS = 2;
 
-  public static void initSsh(KeyPair keyPair) {
-    final Properties config = new Properties();
-    config.put("StrictHostKeyChecking", "no");
-    JSch.setConfig(config);
-
-    // register a JschConfigSessionFactory that adds the private key as identity
-    // to the JSch instance of JGit so that SSH communication via JGit can
-    // succeed
-    SshSessionFactory.setInstance(
-        new JschConfigSessionFactory() {
-          @Override
-          protected void configure(Host hc, Session session) {
-            try {
-              final JSch jsch = getJSch(hc, FS.DETECTED);
-              jsch.addIdentity(
-                  "KeyPair", TestSshKeys.privateKey(keyPair), keyPair.getPublicKeyBlob(), null);
-            } catch (JSchException e) {
-              throw new RuntimeException(e);
-            }
-          }
-        });
-  }
-
   /**
    * Create a new {@link TestRepository} with a distinct commit clock.
    *
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index a3207e2..de9a43d 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
@@ -64,7 +63,7 @@
       bind(InMemoryRepositoryManager.class).in(SINGLETON);
     }
 
-    bind(MetricMaker.class).to(DisabledMetricMaker.class);
+    bind(MetricMaker.class).to(TestMetricMaker.class);
 
     listener().to(CreateSchema.class);
 
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index afd451a..67e26ec 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -43,8 +43,13 @@
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.TagCommand;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevBlob;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -277,6 +282,36 @@
     return this;
   }
 
+  public PushOneCommit addSymlink(String path, String target) throws Exception {
+    RevBlob blobId = testRepo.blob(target);
+    commitBuilder.edit(
+        new PathEdit(path) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.SYMLINK);
+            ent.setObjectId(blobId);
+          }
+        });
+    return this;
+  }
+
+  public PushOneCommit addGitSubmodule(String modulePath, ObjectId commitId) {
+    commitBuilder.edit(
+        new PathEdit(modulePath) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.GITLINK);
+            ent.setObjectId(commitId);
+          }
+        });
+    return this;
+  }
+
+  public PushOneCommit rmFile(String filename) {
+    commitBuilder.rm(filename);
+    return this;
+  }
+
   public Result to(String ref) throws Exception {
     for (Map.Entry<String, String> e : files.entrySet()) {
       commitBuilder.add(e.getKey(), e.getValue());
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index 21b216a..23bfd0b 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -14,31 +14,19 @@
 
 package com.google.gerrit.acceptance;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
-import com.jcraft.jsch.ChannelExec;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.KeyPair;
-import com.jcraft.jsch.Session;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
 import java.io.Reader;
 import java.net.InetSocketAddress;
-import java.nio.charset.StandardCharsets;
-import java.util.Scanner;
 
-public class SshSession {
-  private final TestSshKeys sshKeys;
-  private final InetSocketAddress addr;
-  private final TestAccount account;
-  private Session session;
-  private String error;
+public abstract class SshSession {
+  protected final TestSshKeys sshKeys;
+  protected final InetSocketAddress addr;
+  protected final TestAccount account;
+  protected String error;
 
   public SshSession(TestSshKeys sshKeys, InetSocketAddress addr, TestAccount account) {
     this.sshKeys = sshKeys;
@@ -46,58 +34,15 @@
     this.account = account;
   }
 
-  public void open() throws Exception {
-    getSession();
-  }
+  public abstract void open() throws Exception;
 
-  @SuppressWarnings("resource")
-  public String exec(String command) throws Exception {
-    ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
-    try {
-      channel.setCommand(command);
-      InputStream in = channel.getInputStream();
-      InputStream err = channel.getErrStream();
-      channel.connect();
+  public abstract void close();
 
-      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
-      error = s.hasNext() ? s.next() : null;
+  public abstract String exec(String command) throws Exception;
 
-      s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
-      return s.hasNext() ? s.next() : "";
-    } finally {
-      channel.disconnect();
-    }
-  }
+  public abstract int execAndReturnStatus(String command) throws Exception;
 
-  @SuppressWarnings("resource")
-  public int execAndReturnStatus(String command) throws Exception {
-    ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
-    try {
-      channel.setCommand(command);
-      InputStream err = channel.getErrStream();
-      channel.connect();
-
-      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
-      error = s.hasNext() ? s.next() : null;
-      return channel.getExitStatus();
-    } finally {
-      channel.disconnect();
-    }
-  }
-
-  public Reader execAndReturnReader(String command) throws Exception {
-    ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
-    channel.setCommand(command);
-    channel.connect();
-
-    return new InputStreamReader(channel.getInputStream(), StandardCharsets.UTF_8) {
-      @Override
-      public void close() throws IOException {
-        super.close();
-        channel.disconnect();
-      }
-    };
-  }
+  public abstract Reader execAndReturnReader(String command) throws Exception;
 
   private boolean hasError() {
     return error != null;
@@ -120,46 +65,23 @@
     assertThat(getError()).contains(error);
   }
 
-  public void close() {
-    if (session != null) {
-      session.disconnect();
-      session = null;
-    }
-  }
-
-  private Session getSession() throws Exception {
-    if (session == null) {
-      KeyPair keyPair = sshKeys.getKeyPair(account);
-      JSch jsch = new JSch();
-      jsch.addIdentity(
-          "KeyPair", TestSshKeys.privateKey(keyPair), keyPair.getPublicKeyBlob(), null);
-      String username =
-          account
-              .username()
-              .orElseThrow(
-                  () ->
-                      new IllegalStateException(
-                          "account " + account.accountId() + " must have a username to use SSH"));
-      session = jsch.getSession(username, addr.getAddress().getHostAddress(), addr.getPort());
-      session.setConfig("StrictHostKeyChecking", "no");
-      session.connect();
-    }
-    return session;
-  }
-
   public String getUrl() {
-    checkState(session != null, "session must be opened");
     StringBuilder b = new StringBuilder();
     b.append("ssh://");
-    b.append(session.getUserName());
+    b.append(account.username().get());
     b.append("@");
-    b.append(session.getHost());
+    b.append(addr.getAddress().getHostAddress());
     b.append(":");
-    b.append(session.getPort());
+    b.append(addr.getPort());
     return b.toString();
   }
 
-  public TestAccount getAccount() {
-    return account;
+  protected String getUsername() {
+    return account
+        .username()
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    "account " + account.accountId() + " must have a username to use SSH"));
   }
 }
diff --git a/java/com/google/gerrit/acceptance/SshSessionJsch.java b/java/com/google/gerrit/acceptance/SshSessionJsch.java
new file mode 100644
index 0000000..a86c2d6
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/SshSessionJsch.java
@@ -0,0 +1,174 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.jcraft.jsch.ChannelExec;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.Session;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringWriter;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Properties;
+import java.util.Scanner;
+import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
+import org.bouncycastle.openssl.jcajce.JcaPKCS8Generator;
+import org.bouncycastle.util.io.pem.PemObject;
+import org.eclipse.jgit.transport.JschConfigSessionFactory;
+import org.eclipse.jgit.transport.OpenSshConfig.Host;
+import org.eclipse.jgit.transport.SshSessionFactory;
+import org.eclipse.jgit.util.FS;
+
+public class SshSessionJsch extends SshSession {
+
+  private Session session;
+
+  public static void initClient(KeyPair keyPair) {
+    Properties config = new Properties();
+    config.put("StrictHostKeyChecking", "no");
+    JSch.setConfig(config);
+
+    // register a JschConfigSessionFactory that adds the private key as identity
+    // to the JSch instance of JGit so that SSH communication via JGit can
+    // succeed
+    SshSessionFactory.setInstance(
+        new JschConfigSessionFactory() {
+          @Override
+          protected void configure(Host hc, Session session) {
+            try {
+              JSch jsch = getJSch(hc, FS.DETECTED);
+              jsch.addIdentity(
+                  "KeyPair", privateKey(keyPair), TestSshKeys.publicKeyBlob(keyPair), null);
+            } catch (JSchException | GeneralSecurityException | IOException e) {
+              throw new RuntimeException(e);
+            }
+          }
+        });
+  }
+
+  public static KeyPairGenerator initKeyPairGenerator() throws NoSuchAlgorithmException {
+    KeyPairGenerator gen;
+    gen = KeyPairGenerator.getInstance("RSA");
+    gen.initialize(512, new SecureRandom());
+    return gen;
+  }
+
+  public SshSessionJsch(TestSshKeys sshKeys, InetSocketAddress addr, TestAccount account) {
+    super(sshKeys, addr, account);
+  }
+
+  @Override
+  public void open() throws Exception {
+    getJschSession();
+  }
+
+  @Override
+  public void close() {
+    if (session != null) {
+      session.disconnect();
+      session = null;
+    }
+  }
+
+  @SuppressWarnings("resource")
+  @Override
+  public String exec(String command) throws Exception {
+    ChannelExec channel = (ChannelExec) getJschSession().openChannel("exec");
+    try {
+      channel.setCommand(command);
+      InputStream in = channel.getInputStream();
+      InputStream err = channel.getErrStream();
+      channel.connect();
+
+      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
+      error = s.hasNext() ? s.next() : null;
+
+      s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
+      return s.hasNext() ? s.next() : "";
+    } finally {
+      channel.disconnect();
+    }
+  }
+
+  @SuppressWarnings("resource")
+  @Override
+  public int execAndReturnStatus(String command) throws Exception {
+    ChannelExec channel = (ChannelExec) getJschSession().openChannel("exec");
+    try {
+      channel.setCommand(command);
+      InputStream err = channel.getErrStream();
+      channel.connect();
+
+      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
+      error = s.hasNext() ? s.next() : null;
+      return channel.getExitStatus();
+    } finally {
+      channel.disconnect();
+    }
+  }
+
+  @Override
+  public Reader execAndReturnReader(String command) throws Exception {
+    ChannelExec channel = (ChannelExec) getJschSession().openChannel("exec");
+    channel.setCommand(command);
+    channel.connect();
+
+    return new InputStreamReader(channel.getInputStream(), StandardCharsets.UTF_8) {
+      @Override
+      public void close() throws IOException {
+        super.close();
+        channel.disconnect();
+      }
+    };
+  }
+
+  private Session getJschSession() throws Exception {
+    if (session == null) {
+      KeyPair keyPair = sshKeys.getKeyPair(account);
+      JSch jsch = new JSch();
+      jsch.addIdentity("KeyPair", privateKey(keyPair), TestSshKeys.publicKeyBlob(keyPair), null);
+      String username = getUsername();
+      session = jsch.getSession(username, addr.getAddress().getHostAddress(), addr.getPort());
+      session.setConfig("StrictHostKeyChecking", "no");
+      session.connect();
+    }
+    return session;
+  }
+
+  private static byte[] privateKey(KeyPair keyPair) throws IOException {
+    // unencrypted form of PKCS#8 file
+    JcaPKCS8Generator gen1 = new JcaPKCS8Generator(keyPair.getPrivate(), null);
+    PemObject obj1 = gen1.generate();
+    StringWriter sw1 = new StringWriter();
+    try (JcaPEMWriter pw = new JcaPEMWriter(sw1)) {
+      pw.writeObject(obj1);
+    }
+    return sw1.toString().getBytes(US_ASCII.name());
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/SshSessionMina.java b/java/com/google/gerrit/acceptance/SshSessionMina.java
new file mode 100644
index 0000000..4d8691b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/SshSessionMina.java
@@ -0,0 +1,179 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.io.CharSink;
+import com.google.common.io.Files;
+import com.google.common.io.MoreFiles;
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.KeyPairGenerator;
+import java.security.spec.InvalidKeySpecException;
+import java.util.Arrays;
+import java.util.Scanner;
+import org.apache.sshd.common.cipher.ECCurves;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter;
+import org.apache.sshd.common.util.security.SecurityUtils;
+import org.eclipse.jgit.transport.SshSessionFactory;
+import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.transport.sshd.DefaultProxyDataFactory;
+import org.eclipse.jgit.transport.sshd.JGitKeyCache;
+import org.eclipse.jgit.transport.sshd.SshdSession;
+import org.eclipse.jgit.transport.sshd.SshdSessionFactory;
+import org.eclipse.jgit.util.FS;
+
+public class SshSessionMina extends SshSession {
+  private static final int TIMEOUT = 100000;
+
+  private SshdSession session;
+
+  public static void initClient() {
+    JGitKeyCache keyCache = new JGitKeyCache();
+    SshdSessionFactory factory = new SshdSessionFactory(keyCache, new DefaultProxyDataFactory());
+    SshSessionFactory.setInstance(factory);
+  }
+
+  public static KeyPairGenerator initKeyPairGenerator()
+      throws GeneralSecurityException, InvalidKeySpecException, InvalidAlgorithmParameterException {
+    int size = 256;
+    KeyPairGenerator gen = SecurityUtils.getKeyPairGenerator(KeyUtils.EC_ALGORITHM);
+    ECCurves curve = ECCurves.fromCurveSize(size);
+    if (curve == null) {
+      throw new InvalidKeySpecException("Unknown curve for key size=" + size);
+    }
+    gen.initialize(curve.getParameters());
+    return gen;
+  }
+
+  public SshSessionMina(TestSshKeys sshKeys, InetSocketAddress addr, TestAccount account) {
+    super(sshKeys, addr, account);
+  }
+
+  @Override
+  public void open() throws Exception {
+    getMinaSession();
+  }
+
+  @Override
+  public void close() {
+    if (session != null) {
+      session.disconnect();
+      session = null;
+    }
+  }
+
+  @SuppressWarnings("resource")
+  @Override
+  public String exec(String command) throws Exception {
+    Process process = getMinaSession().exec(command, TIMEOUT);
+    InputStream in = process.getInputStream();
+    InputStream err = process.getErrorStream();
+
+    Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
+    error = s.hasNext() ? s.next() : null;
+
+    s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
+    return s.hasNext() ? s.next() : "";
+  }
+
+  @SuppressWarnings("resource")
+  @Override
+  public int execAndReturnStatus(String command) throws Exception {
+    Process process = getMinaSession().exec(command, 0);
+    InputStream in = process.getInputStream();
+    InputStream err = process.getErrorStream();
+
+    Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
+    error = s.hasNext() ? s.next() : null;
+
+    s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
+    try {
+      return process.exitValue();
+    } catch (IllegalThreadStateException e) {
+      // SSH command was interrupted
+      return -1;
+    }
+  }
+
+  @Override
+  public Reader execAndReturnReader(String command) throws Exception {
+    return new InputStreamReader(
+        getMinaSession().exec(command, 0).getInputStream(), StandardCharsets.UTF_8);
+  }
+
+  private SshdSession getMinaSession() throws Exception {
+    if (session == null) {
+      String username = getUsername();
+
+      URIish uri =
+          new URIish(
+              "ssh://"
+                  + username
+                  + "@"
+                  + addr.getAddress().getHostAddress()
+                  + ":"
+                  + addr.getPort());
+
+      // TODO(davido): Switch to memory only key resolving mode.
+      File userhome = Files.createTempDir();
+
+      FS fs = FS.DETECTED.setUserHome(userhome);
+      File sshDir = new File(userhome, ".ssh");
+      sshDir.mkdir();
+      OpenSSHKeyPairResourceWriter keyPairWriter = new OpenSSHKeyPairResourceWriter();
+      try (OutputStream out = new FileOutputStream(new File(sshDir, "id_ecdsa"))) {
+        keyPairWriter.writePrivateKey(sshKeys.getKeyPair(account), null, null, out);
+      }
+
+      // TODO(davido): Disable programmatically host key checking: "StrictHostKeyChecking: no" mode.
+      CharSink configFile = Files.asCharSink(new File(sshDir, "config"), UTF_8);
+      configFile.writeLines(Arrays.asList("Host *", "StrictHostKeyChecking no"));
+
+      JGitKeyCache keyCache = new JGitKeyCache();
+      try (SshdSessionFactory factory =
+          new SshdSessionFactory(keyCache, new DefaultProxyDataFactory())) {
+        factory.setHomeDirectory(userhome);
+        factory.setSshDirectory(sshDir);
+
+        session = factory.getSession(uri, null, fs, TIMEOUT);
+
+        session.addCloseListener(
+            future -> {
+              try {
+                MoreFiles.deleteRecursively(userhome.toPath(), ALLOW_INSECURE);
+              } catch (IOException e) {
+                e.printStackTrace();
+              }
+            });
+      }
+    }
+    return session;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/TestMetricMaker.java b/java/com/google/gerrit/acceptance/TestMetricMaker.java
new file mode 100644
index 0000000..d60ef1a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/TestMetricMaker.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.inject.Singleton;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.commons.lang.mutable.MutableLong;
+
+/**
+ * {@link com.google.gerrit.metrics.MetricMaker} to be bound in tests.
+ *
+ * <p>Records how often {@link Counter0} metrics are invoked. Metrics of other types are not
+ * recorded.
+ *
+ * <p>Allows test to check how much a {@link Counter0} metrics is increased by an operation.
+ *
+ * <p>Example:
+ *
+ * <pre>
+ * public class MyTest extends AbstractDaemonTest {
+ *   @Inject private TestMetricMaker testMetricMaker;
+ *
+ *   ...
+ *
+ *   @Test
+ *   public void testFoo() throws Exception {
+ *     testMetricMaker.reset();
+ *     doSomething();
+ *     assertThat(testMetricMaker.getCount("foo/bar_count")).isEqualsTo(1);
+ *   }
+ * }
+ * </pre>
+ */
+@Singleton
+public class TestMetricMaker extends DisabledMetricMaker {
+  private final Map<String, MutableLong> counts = new HashMap<>();
+
+  public long getCount(String counter0Name) {
+    return get(counter0Name).longValue();
+  }
+
+  public void reset() {
+    counts.clear();
+  }
+
+  private MutableLong get(String counter0Name) {
+    return counts.computeIfAbsent(counter0Name, name -> new MutableLong(0));
+  }
+
+  @Override
+  public Counter0 newCounter(String name, Description desc) {
+    return new Counter0() {
+      @Override
+      public void incrementBy(long value) {
+        get(name).add(value);
+      }
+
+      @Override
+      public void remove() {}
+    };
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
index 6c95360..277d219 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
@@ -18,19 +18,20 @@
 import static java.nio.charset.StandardCharsets.US_ASCII;
 
 import com.google.gerrit.acceptance.SshEnabled;
+import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
-import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
-import java.io.UnsupportedEncodingException;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
 import java.util.HashMap;
 import java.util.Map;
+import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter;
 
 @Singleton
 public class TestSshKeys {
@@ -86,27 +87,26 @@
 
   private KeyPair createKeyPair(Account.Id accountId, String username, @Nullable String email)
       throws Exception {
-    KeyPair keyPair = genSshKey();
+    KeyPair keyPair = SshSessionFactory.genSshKey();
     authorizedKeys.addKey(accountId, publicKey(keyPair, email));
     sshKeyCache.evict(username);
     return keyPair;
   }
 
-  public static KeyPair genSshKey() throws JSchException {
-    JSch jsch = new JSch();
-    return KeyPair.genKeyPair(jsch, KeyPair.ECDSA, 256);
-  }
-
   public static String publicKey(KeyPair sshKey, @Nullable String comment)
-      throws UnsupportedEncodingException {
-    ByteArrayOutputStream out = new ByteArrayOutputStream();
-    sshKey.writePublicKey(out, comment);
-    return out.toString(US_ASCII.name()).trim();
+      throws IOException, GeneralSecurityException {
+    return preparePublicKey(sshKey, comment).toString(US_ASCII.name()).trim();
   }
 
-  public static byte[] privateKey(KeyPair keyPair) {
+  public static byte[] publicKeyBlob(KeyPair sshKey) throws IOException, GeneralSecurityException {
+    return preparePublicKey(sshKey, null).toByteArray();
+  }
+
+  private static ByteArrayOutputStream preparePublicKey(KeyPair sshKey, String comment)
+      throws IOException, GeneralSecurityException {
+    OpenSSHKeyPairResourceWriter keyPairWriter = new OpenSSHKeyPairResourceWriter();
     ByteArrayOutputStream out = new ByteArrayOutputStream();
-    keyPair.writePrivateKey(out);
-    return out.toByteArray();
+    keyPairWriter.writePublicKey(sshKey, comment, out);
+    return out;
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
index 21d1232..cde5134 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -17,12 +17,12 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupUuid;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
index db730a6..895c7a0 100644
--- a/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImpl.java
@@ -19,7 +19,6 @@
 
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.GerritServer.TestSshServerAddress;
-import com.google.gerrit.acceptance.SshSession;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
@@ -82,7 +81,7 @@
   public AcceptanceTestRequestScope.Context setApiUser(TestAccount testAccount) {
     return atrScope.set(
         atrScope.newContext(
-            new SshSession(testSshKeys, sshAddress, testAccount),
+            SshSessionFactory.createSession(testSshKeys, sshAddress, testAccount),
             createIdentifiedUser(testAccount.accountId())));
   }
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java b/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java
new file mode 100644
index 0000000..d5dd28a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.request;
+
+import static com.google.gerrit.server.config.SshClientImplementation.getFromEnvironment;
+
+import com.google.gerrit.acceptance.SshSession;
+import com.google.gerrit.acceptance.SshSessionJsch;
+import com.google.gerrit.acceptance.SshSessionMina;
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import java.net.InetSocketAddress;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+
+public class SshSessionFactory {
+  public static SshSession createSession(
+      TestSshKeys testSshKeys, InetSocketAddress sshAddress, TestAccount testAccount) {
+    return getFromEnvironment().isMina()
+        ? new SshSessionMina(testSshKeys, sshAddress, testAccount)
+        : new SshSessionJsch(testSshKeys, sshAddress, testAccount);
+  }
+
+  public static void initSsh(KeyPair keyPair) {
+    if (getFromEnvironment().isMina()) {
+      SshSessionMina.initClient();
+    } else {
+      SshSessionJsch.initClient(keyPair);
+    }
+  }
+
+  private SshSessionFactory() {}
+
+  public static KeyPair genSshKey() throws GeneralSecurityException {
+    return (getFromEnvironment().isMina()
+            ? SshSessionMina.initKeyPairGenerator()
+            : SshSessionJsch.initKeyPairGenerator())
+        .generateKeyPair();
+  }
+}
diff --git a/java/com/google/gerrit/asciidoctor/DocIndexer.java b/java/com/google/gerrit/asciidoctor/DocIndexer.java
index 513bdd7..6cbe2a9 100644
--- a/java/com/google/gerrit/asciidoctor/DocIndexer.java
+++ b/java/com/google/gerrit/asciidoctor/DocIndexer.java
@@ -107,11 +107,19 @@
 
         String title;
         try (BufferedReader titleReader = Files.newBufferedReader(file.toPath(), UTF_8)) {
-          title = titleReader.readLine();
-          if (title != null && title.startsWith("[[")) {
+          while ((title = titleReader.readLine()) != null) {
             // Generally the first line of the txt is the title. In a few cases the
-            // first line is a "[[tag]]" and the second line is the title.
-            title = titleReader.readLine();
+            // first lines are "[[tag]]" and or ":attribute:" and the next line
+            // after those  is the title.
+            if (title.startsWith("[[")) {
+              continue;
+            }
+            // Skip attributes such as :linkattrs:
+            if (title.startsWith(":") && title.endsWith(":")) {
+              continue;
+            }
+            // We found the title
+            break;
           }
         }
         Matcher matcher = SECTION_HEADER.matcher(title);
diff --git a/java/com/google/gerrit/server/config/AuthModule.java b/java/com/google/gerrit/auth/AuthModule.java
similarity index 83%
rename from java/com/google/gerrit/server/config/AuthModule.java
rename to java/com/google/gerrit/auth/AuthModule.java
index 56f2e2a..1eabe3f 100644
--- a/java/com/google/gerrit/server/config/AuthModule.java
+++ b/java/com/google/gerrit/auth/AuthModule.java
@@ -12,30 +12,32 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.auth;
 
+import com.google.gerrit.auth.ldap.LdapModule;
+import com.google.gerrit.auth.oauth.OAuthRealm;
+import com.google.gerrit.auth.oauth.OAuthTokenCache;
+import com.google.gerrit.auth.openid.OpenIdRealm;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.account.DefaultRealm;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.InternalAuthBackend;
-import com.google.gerrit.server.auth.ldap.LdapModule;
-import com.google.gerrit.server.auth.oauth.OAuthRealm;
-import com.google.gerrit.server.auth.openid.OpenIdRealm;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
 
 public class AuthModule extends AbstractModule {
   private final AuthType loginType;
 
-  @Inject
-  AuthModule(AuthConfig authConfig) {
+  public AuthModule(AuthConfig authConfig) {
     loginType = authConfig.getAuthType();
   }
 
   @Override
   protected void configure() {
+    install(OAuthTokenCache.module());
+
     switch (loginType) {
       case HTTP_LDAP:
       case LDAP:
diff --git a/java/com/google/gerrit/auth/BUILD b/java/com/google/gerrit/auth/BUILD
new file mode 100644
index 0000000..e844696
--- /dev/null
+++ b/java/com/google/gerrit/auth/BUILD
@@ -0,0 +1,40 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:javadoc.bzl", "java_doc")
+
+# Giant kitchen-sink target.
+#
+# The only reason this hasn't been split up further is because we have too many
+# tangled dependencies (and Guice unfortunately makes it quite easy to get into
+# this state). Which means if you see an opportunity to split something off, you
+# should seize it.
+java_library(
+    name = "auth",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/server"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/logging",
+        "//java/com/google/gerrit/util/ssl",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib:protobuf",
+        "//lib:servlet-api",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
similarity index 90%
rename from java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
rename to java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
index 63cd426..8fb4d35 100644
--- a/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
@@ -12,15 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
-import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
+import static com.google.gerrit.auth.ldap.Helper.LDAP_UUID;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
@@ -76,7 +76,7 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
+  public GroupMembership membershipsOf(CurrentUser user) {
     return new ListGroupMembership(Collections.emptyList());
   }
 
diff --git a/java/com/google/gerrit/server/auth/ldap/Helper.java b/java/com/google/gerrit/auth/ldap/Helper.java
similarity index 99%
rename from java/com/google/gerrit/server/auth/ldap/Helper.java
rename to java/com/google/gerrit/auth/ldap/Helper.java
index b0f011a..a939c72 100644
--- a/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/auth/ldap/Helper.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.common.base.Throwables;
 import com.google.common.cache.Cache;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java b/java/com/google/gerrit/auth/ldap/LdapAuthBackend.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
rename to java/com/google/gerrit/auth/ldap/LdapAuthBackend.java
index f31954e..017655f 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapAuthBackend.java
+++ b/java/com/google/gerrit/auth/ldap/LdapAuthBackend.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.client.AuthType;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
similarity index 90%
rename from java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
rename to java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
index cd1e0a4..2947efd 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
@@ -12,28 +12,27 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
+import static com.google.gerrit.auth.ldap.Helper.LDAP_UUID;
+import static com.google.gerrit.auth.ldap.LdapModule.GROUP_CACHE;
+import static com.google.gerrit.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
 import static com.google.gerrit.server.account.GroupBackends.GROUP_REF_NAME_COMPARATOR;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
-import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_CACHE;
-import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
 
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.auth.ldap.Helper.LdapSchema;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.auth.ldap.Helper.LdapSchema;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -45,6 +44,7 @@
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import javax.naming.InvalidNameException;
@@ -178,21 +178,13 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
-    String id = findId(user.state().externalIds());
-    if (id == null) {
+  public GroupMembership membershipsOf(CurrentUser user) {
+    Optional<ExternalId.Key> id =
+        user.getExternalIdKeys().stream().filter(e -> e.isScheme(SCHEME_GERRIT)).findAny();
+    if (!id.isPresent()) {
       return GroupMembership.EMPTY;
     }
-    return new LdapGroupMembership(membershipCache, projectCache, id, gerritConfig);
-  }
-
-  private static String findId(Collection<ExternalId> extIds) {
-    for (ExternalId extId : extIds) {
-      if (extId.isScheme(SCHEME_GERRIT)) {
-        return extId.key().id();
-      }
-    }
-    return null;
+    return new LdapGroupMembership(membershipCache, projectCache, id.get().id(), gerritConfig);
   }
 
   private Set<GroupReference> suggestLdap(String name) {
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java b/java/com/google/gerrit/auth/ldap/LdapGroupMembership.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
rename to java/com/google/gerrit/auth/ldap/LdapGroupMembership.java
index a6aa2f6..0b8b6e2 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupMembership.java
+++ b/java/com/google/gerrit/auth/ldap/LdapGroupMembership.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.common.cache.LoadingCache;
 import com.google.gerrit.entities.AccountGroup;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapModule.java b/java/com/google/gerrit/auth/ldap/LdapModule.java
similarity index 97%
rename from java/com/google/gerrit/server/auth/ldap/LdapModule.java
rename to java/com/google/gerrit/auth/ldap/LdapModule.java
index 092b5ac..a5ee904 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapModule.java
+++ b/java/com/google/gerrit/auth/ldap/LdapModule.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapQuery.java b/java/com/google/gerrit/auth/ldap/LdapQuery.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/ldap/LdapQuery.java
rename to java/com/google/gerrit/auth/ldap/LdapQuery.java
index 3e549f6..409c9f5 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapQuery.java
+++ b/java/com/google/gerrit/auth/ldap/LdapQuery.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.metrics.Timer0;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/auth/ldap/LdapRealm.java
similarity index 99%
rename from java/com/google/gerrit/server/auth/ldap/LdapRealm.java
rename to java/com/google/gerrit/auth/ldap/LdapRealm.java
index b5972e2..9305914 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/auth/ldap/LdapRealm.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapType.java b/java/com/google/gerrit/auth/ldap/LdapType.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/ldap/LdapType.java
rename to java/com/google/gerrit/auth/ldap/LdapType.java
index fe1f1ff..c486335 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapType.java
+++ b/java/com/google/gerrit/auth/ldap/LdapType.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import javax.naming.NamingException;
 import javax.naming.directory.Attribute;
diff --git a/java/com/google/gerrit/server/auth/ldap/SearchScope.java b/java/com/google/gerrit/auth/ldap/SearchScope.java
similarity index 96%
rename from java/com/google/gerrit/server/auth/ldap/SearchScope.java
rename to java/com/google/gerrit/auth/ldap/SearchScope.java
index 0038608..75edd8d 100644
--- a/java/com/google/gerrit/server/auth/ldap/SearchScope.java
+++ b/java/com/google/gerrit/auth/ldap/SearchScope.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import javax.naming.directory.SearchControls;
 
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java b/java/com/google/gerrit/auth/oauth/OAuthRealm.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
rename to java/com/google/gerrit/auth/oauth/OAuthRealm.java
index 944bd44..c329cc0 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
+++ b/java/com/google/gerrit/auth/oauth/OAuthRealm.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.oauth;
+package com.google.gerrit.auth.oauth;
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
 
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
similarity index 98%
rename from java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
rename to java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
index 03ecd91..b0c1f51 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.oauth;
+package com.google.gerrit.auth.oauth;
 
 import static java.util.Objects.requireNonNull;
 
diff --git a/java/com/google/gerrit/server/auth/openid/OpenIdRealm.java b/java/com/google/gerrit/auth/openid/OpenIdRealm.java
similarity index 97%
rename from java/com/google/gerrit/server/auth/openid/OpenIdRealm.java
rename to java/com/google/gerrit/auth/openid/OpenIdRealm.java
index 4e1a1ce..2a65e76 100644
--- a/java/com/google/gerrit/server/auth/openid/OpenIdRealm.java
+++ b/java/com/google/gerrit/auth/openid/OpenIdRealm.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.openid;
+package com.google.gerrit.auth.openid;
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_HTTP;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_HTTPS;
diff --git a/java/com/google/gerrit/common/data/PatchScript.java b/java/com/google/gerrit/common/data/PatchScript.java
index e3c0ba6..1ba5592 100644
--- a/java/com/google/gerrit/common/data/PatchScript.java
+++ b/java/com/google/gerrit/common/data/PatchScript.java
@@ -34,7 +34,17 @@
   public enum FileMode {
     FILE,
     SYMLINK,
-    GITLINK
+    GITLINK;
+
+    public static FileMode fromJgitFileMode(org.eclipse.jgit.lib.FileMode jgitFileMode) {
+      PatchScript.FileMode fileMode = PatchScript.FileMode.FILE;
+      if (jgitFileMode == org.eclipse.jgit.lib.FileMode.SYMLINK) {
+        fileMode = FileMode.SYMLINK;
+      } else if (jgitFileMode == org.eclipse.jgit.lib.FileMode.GITLINK) {
+        fileMode = FileMode.GITLINK;
+      }
+      return fileMode;
+    }
   }
 
   public static class PatchScriptFileInfo {
diff --git a/java/com/google/gerrit/common/data/testing/BUILD b/java/com/google/gerrit/common/data/testing/BUILD
index b9ec30b..d39d05c 100644
--- a/java/com/google/gerrit/common/data/testing/BUILD
+++ b/java/com/google/gerrit/common/data/testing/BUILD
@@ -6,7 +6,6 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//lib/truth",
     ],
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index d53490b..6ed1a51 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -84,6 +84,9 @@
   protected static final String BULK = "_bulk";
   protected static final String MAPPINGS = "mappings";
   protected static final String ORDER = "order";
+  protected static final String DESC_SORT_ORDER = "desc";
+  protected static final String ASC_SORT_ORDER = "asc";
+  protected static final String UNMAPPED_TYPE = "unmapped_type";
   protected static final String SEARCH = "_search";
   protected static final String SETTINGS = "settings";
 
@@ -294,7 +297,7 @@
 
   protected JsonArray getSortArray(String idFieldName) {
     JsonObject properties = new JsonObject();
-    properties.addProperty(ORDER, "asc");
+    properties.addProperty(ORDER, ASC_SORT_ORDER);
 
     JsonArray sortArray = new JsonArray();
     addNamedElement(idFieldName, properties, sortArray);
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 625a598..162654d 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
@@ -57,6 +58,9 @@
 import com.google.gson.JsonObject;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.Optional;
 import java.util.Set;
@@ -133,14 +137,24 @@
 
   private JsonArray getSortArray() {
     JsonObject properties = new JsonObject();
-    properties.addProperty(ORDER, "desc");
+    properties.addProperty(ORDER, DESC_SORT_ORDER);
 
     JsonArray sortArray = new JsonArray();
     addNamedElement(ChangeField.UPDATED.getName(), properties, sortArray);
+    addNamedElement(ChangeField.MERGED_ON.getName(), getMergedOnSortOptions(), sortArray);
     addNamedElement(idField.getName(), properties, sortArray);
     return sortArray;
   }
 
+  private JsonObject getMergedOnSortOptions() {
+    JsonObject sortOptions = new JsonObject();
+    sortOptions.addProperty(ORDER, DESC_SORT_ORDER);
+    // Ignore the sort field if it does not exist in index. Otherwise the search would fail on open
+    // changes, because the corresponding documents do not have mergedOn field.
+    sortOptions.addProperty(UNMAPPED_TYPE, ElasticMapping.TIMESTAMP_FIELD_TYPE);
+    return sortOptions;
+  }
+
   @Override
   protected String getDeleteActions(Change.Id c) {
     return getDeleteRequest(c);
@@ -341,7 +355,7 @@
 
     // Ref-state.
     if (fields.contains(ChangeField.REF_STATE.getName())) {
-      cd.setRefStates(getByteArray(source, ChangeField.REF_STATE.getName()));
+      cd.setRefStates(RefState.parseStates(getByteArray(source, ChangeField.REF_STATE.getName())));
     }
 
     // Ref-state-pattern.
@@ -361,6 +375,10 @@
           cd);
     }
 
+    if (fields.contains(ChangeField.MERGED_ON.getName())) {
+      decodeMergedOn(source, cd);
+    }
+
     return cd;
   }
 
@@ -396,4 +414,18 @@
     }
     out.setUnresolvedCommentCount(count.getAsInt());
   }
+
+  private void decodeMergedOn(JsonObject doc, ChangeData out) {
+    JsonElement mergedOnField = doc.get(ChangeField.MERGED_ON.getName());
+
+    Timestamp mergedOn = null;
+    if (mergedOnField != null) {
+      // Parse from ElasticMapping.TIMESTAMP_FIELD_FORMAT.
+      // We currently use built-in ISO-based dateOptionalTime.
+      // https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-date-format.html#built-in-date-formats
+      DateTimeFormatter isoFormatter = DateTimeFormatter.ISO_INSTANT;
+      mergedOn = Timestamp.from(Instant.from(isoFormatter.parse(mergedOnField.getAsString())));
+    }
+    out.setMergedOn(mergedOn);
+  }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
index 06b128c..c4435297 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
@@ -26,8 +26,10 @@
 import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 import org.apache.http.HttpHost;
 import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.client.RestClientBuilder;
 
 @Singleton
 class ElasticConfiguration {
@@ -41,12 +43,16 @@
   static final String KEY_NUMBER_OF_SHARDS = "numberOfShards";
   static final String KEY_NUMBER_OF_REPLICAS = "numberOfReplicas";
   static final String KEY_MAX_RESULT_WINDOW = "maxResultWindow";
+  static final String KEY_CONNECT_TIMEOUT = "connectTimeout";
+  static final String KEY_SOCKET_TIMEOUT = "socketTimeout";
 
   static final String DEFAULT_PORT = "9200";
   static final String DEFAULT_USERNAME = "elastic";
   static final int DEFAULT_NUMBER_OF_SHARDS = 1;
   static final int DEFAULT_NUMBER_OF_REPLICAS = 1;
   static final int DEFAULT_MAX_RESULT_WINDOW = 10000;
+  static final int DEFAULT_CONNECT_TIMEOUT = RestClientBuilder.DEFAULT_CONNECT_TIMEOUT_MILLIS;
+  static final int DEFAULT_SOCKET_TIMEOUT = RestClientBuilder.DEFAULT_SOCKET_TIMEOUT_MILLIS;
 
   private final Config cfg;
   private final List<HttpHost> hosts;
@@ -56,6 +62,8 @@
   final int numberOfShards;
   final int numberOfReplicas;
   final int maxResultWindow;
+  final int connectTimeout;
+  final int socketTimeout;
   final String prefix;
 
   @Inject
@@ -74,6 +82,22 @@
         cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_NUMBER_OF_REPLICAS, DEFAULT_NUMBER_OF_REPLICAS);
     this.maxResultWindow =
         cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_MAX_RESULT_WINDOW, DEFAULT_MAX_RESULT_WINDOW);
+    this.connectTimeout =
+        (int)
+            cfg.getTimeUnit(
+                SECTION_ELASTICSEARCH,
+                null,
+                KEY_CONNECT_TIMEOUT,
+                DEFAULT_CONNECT_TIMEOUT,
+                TimeUnit.MILLISECONDS);
+    this.socketTimeout =
+        (int)
+            cfg.getTimeUnit(
+                SECTION_ELASTICSEARCH,
+                null,
+                KEY_SOCKET_TIMEOUT,
+                DEFAULT_SOCKET_TIMEOUT,
+                TimeUnit.MILLISECONDS);
     this.hosts = new ArrayList<>();
     for (String server : cfg.getStringList(SECTION_ELASTICSEARCH, null, KEY_SERVER)) {
       try {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index f8c2ec5..781ed43 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
@@ -28,7 +29,6 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.index.group.GroupIndex;
diff --git a/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
index f8c4168..edd05c9 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticMapping.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
@@ -21,6 +21,10 @@
 import java.util.Map;
 
 class ElasticMapping {
+
+  protected static final String TIMESTAMP_FIELD_TYPE = "date";
+  protected static final String TIMESTAMP_FIELD_FORMAT = "dateOptionalTime";
+
   static MappingProperties createMapping(Schema<?> schema, ElasticQueryAdapter adapter) {
     ElasticMapping.Builder mapping = new ElasticMapping.Builder(adapter);
     for (FieldDef<?, ?> field : schema.getFields().values()) {
@@ -71,9 +75,9 @@
     }
 
     Builder addTimestamp(String name) {
-      FieldProperties properties = new FieldProperties("date");
-      properties.type = "date";
-      properties.format = "dateOptionalTime";
+      FieldProperties properties = new FieldProperties(TIMESTAMP_FIELD_TYPE);
+      properties.type = TIMESTAMP_FIELD_TYPE;
+      properties.format = TIMESTAMP_FIELD_FORMAT;
       fields.put(name, properties);
       return this;
     }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
index d05e91c..40ac603 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.RegexPredicate;
 import com.google.gerrit.index.query.TimestampRangePredicate;
-import com.google.gerrit.server.query.change.AfterPredicate;
 import java.time.Instant;
 
 public class ElasticQueryBuilder {
@@ -130,7 +129,9 @@
   private <T> QueryBuilder timestampQuery(IndexPredicate<T> p) throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<T> r = (TimestampRangePredicate<T>) p;
-      if (p instanceof AfterPredicate) {
+      if (r.getMaxTimestamp().getTime() == Long.MAX_VALUE) {
+        // The time range only has the start value, search from the start to the max supported value
+        // Long.MAX_VALUE
         return QueryBuilders.rangeQuery(r.getField().getName())
             .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()));
       }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
index f635b23..b41f365 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -27,6 +27,7 @@
 import org.apache.http.auth.AuthScope;
 import org.apache.http.auth.UsernamePasswordCredentials;
 import org.apache.http.client.CredentialsProvider;
+import org.apache.http.client.config.RequestConfig;
 import org.apache.http.impl.client.BasicCredentialsProvider;
 import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
 import org.elasticsearch.client.Request;
@@ -128,10 +129,19 @@
 
   private RestClient build() {
     RestClientBuilder builder = RestClient.builder(cfg.getHosts());
+    setConfiguredTimeouts(builder);
     setConfiguredCredentialsIfAny(builder);
     return builder.build();
   }
 
+  private void setConfiguredTimeouts(RestClientBuilder builder) {
+    builder.setRequestConfigCallback(
+        (RequestConfig.Builder requestConfigBuilder) ->
+            requestConfigBuilder
+                .setConnectTimeout(cfg.connectTimeout)
+                .setSocketTimeout(cfg.socketTimeout));
+  }
+
   private void setConfiguredCredentialsIfAny(RestClientBuilder builder) {
     String username = cfg.username;
     String password = cfg.password;
diff --git a/java/com/google/gerrit/entities/BUILD b/java/com/google/gerrit/entities/BUILD
index 66d1869..c0f5de6 100644
--- a/java/com/google/gerrit/entities/BUILD
+++ b/java/com/google/gerrit/entities/BUILD
@@ -10,11 +10,13 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
+        "//lib:gson",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/auto:auto-value-gson",
         "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//proto:cache_java_proto",
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index 0b755b7..2a94bc8 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -14,19 +14,17 @@
 
 package com.google.gerrit.entities;
 
-import static com.google.common.base.Preconditions.checkState;
-
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
@@ -37,6 +35,8 @@
  */
 @AutoValue
 public abstract class CachedProjectConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public abstract Project getProject();
 
   public abstract ImmutableMap<AccountGroup.UUID, GroupReference> getGroups();
@@ -126,34 +126,10 @@
 
   public abstract ImmutableMap<String, String> getPluginConfigs();
 
-  /**
-   * Returns the {@link Config} that got parsed from the specified {@code fileName} on {@code
-   * refs/meta/config}. The returned instance is a defensive copy of the cached value.
-   *
-   * @param fileName the name of the file. Must end in {@code .config}.
-   * @return an {@link Optional} of the {@link Config}. {@link Optional#empty()} if the file was not
-   *     found or could not be parsed. {@link com.google.gerrit.server.project.ProjectConfig} will
-   *     surface validation errors in case of a parsing issue.
-   */
-  public Optional<Config> getProjectLevelConfig(String fileName) {
-    checkState(fileName.endsWith(".config"), "file name must end in .config");
-    if (getProjectLevelConfigs().containsKey(fileName)) {
-      Config config = new Config();
-      try {
-        config.fromText(getProjectLevelConfigs().get(fileName));
-      } catch (ConfigInvalidException e) {
-        // This is OK to propagate as IllegalStateException because it's a programmer error.
-        // The config was converted to a String using Config#toText. So #fromText must not
-        // throw a ConfigInvalidException
-        throw new IllegalStateException("invalid config for " + fileName, e);
-      }
-      return Optional.of(config);
-    }
-    return Optional.empty();
-  }
-
   public abstract ImmutableMap<String, String> getProjectLevelConfigs();
 
+  public abstract ImmutableMap<String, ImmutableConfig> getParsedProjectLevelConfigs();
+
   public static Builder builder() {
     return new AutoValue_CachedProjectConfig.Builder();
   }
@@ -235,8 +211,15 @@
 
     abstract ImmutableMap.Builder<String, String> projectLevelConfigsBuilder();
 
+    abstract ImmutableMap.Builder<String, ImmutableConfig> parsedProjectLevelConfigsBuilder();
+
     public Builder addProjectLevelConfig(String configFileName, String config) {
       projectLevelConfigsBuilder().put(configFileName, config);
+      try {
+        parsedProjectLevelConfigsBuilder().put(configFileName, ImmutableConfig.parse(config));
+      } catch (ConfigInvalidException e) {
+        logger.atInfo().withCause(e).log("Config for " + configFileName + " not parsable");
+      }
       return this;
     }
 
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 845a9bb..ca13db9 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -21,6 +21,9 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.annotations.SerializedName;
 import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.Optional;
@@ -100,6 +103,7 @@
     return new AutoValue_Change_Id(id);
   }
 
+  /** The numeric change ID */
   @AutoValue
   public abstract static class Id {
     /**
@@ -283,6 +287,7 @@
       return Change.key(KeyUtil.decode(str));
     }
 
+    @SerializedName("id")
     abstract String key();
 
     public String get() {
@@ -307,6 +312,10 @@
     public final String toString() {
       return get();
     }
+
+    public static TypeAdapter<Key> typeAdapter(Gson gson) {
+      return new AutoValue_Change_Key.GsonTypeAdapter(gson);
+    }
   }
 
   /** Minimum database status constant for an open change. */
@@ -447,20 +456,14 @@
    */
   protected Timestamp lastUpdatedOn;
 
-  // DELETED: id = 6 (sortkey)
-
   protected Account.Id owner;
 
   /** The branch (and project) this change merges into. */
   protected BranchNameKey dest;
 
-  // DELETED: id = 9 (open)
-
   /** Current state code; see {@link Status}. */
   protected char status;
 
-  // DELETED: id = 11 (nbrPatchSets)
-
   /** The current patch set. */
   protected int currentPatchSetId;
 
@@ -470,9 +473,6 @@
   /** Topic name assigned by the user, if any. */
   @Nullable protected String topic;
 
-  // DELETED: id = 15 (lastSha1MergeTested)
-  // DELETED: id = 16 (mergeable)
-
   /**
    * First line of first patch set's commit message.
    *
@@ -544,12 +544,12 @@
     cherryPickOf = other.cherryPickOf;
   }
 
-  /** Legacy 32 bit integer identity for a change. */
+  /** 32 bit integer identity for a change. */
   public Change.Id getId() {
     return changeId;
   }
 
-  /** Legacy 32 bit integer identity for a change. */
+  /** 32 bit integer identity for a change. */
   public int getChangeId() {
     return changeId.get();
   }
diff --git a/java/com/google/gerrit/entities/CommentContext.java b/java/com/google/gerrit/entities/CommentContext.java
new file mode 100644
index 0000000..68d779c
--- /dev/null
+++ b/java/com/google/gerrit/entities/CommentContext.java
@@ -0,0 +1,44 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+
+/** An entity class representing all context lines of a comment. */
+@AutoValue
+public abstract class CommentContext {
+  private static final CommentContext EMPTY = new AutoValue_CommentContext(ImmutableMap.of(), "");
+
+  public static CommentContext create(ImmutableMap<Integer, String> lines, String contentType) {
+    return new AutoValue_CommentContext(lines, contentType);
+  }
+
+  /** Map of {line number, line text} of the context lines of a comment */
+  public abstract ImmutableMap<Integer, String> lines();
+
+  /**
+   * Content type of the source file. Useful for syntax highlighting.
+   *
+   * @return text/x-gerrit-commit-message if the file is a commit message.
+   *     <p>text/x-gerrit-merge-list if the file is a merge list.
+   *     <p>The content/mime type, e.g. text/x-c++src otherwise.
+   */
+  public abstract String contentType();
+
+  public static CommentContext empty() {
+    return EMPTY;
+  }
+}
diff --git a/java/com/google/gerrit/entities/CoreDownloadSchemes.java b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
index 37c10f1..9bcd365 100644
--- a/java/com/google/gerrit/entities/CoreDownloadSchemes.java
+++ b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
@@ -21,6 +21,7 @@
   public static final String HTTP = "http";
   public static final String SSH = "ssh";
   public static final String REPO_DOWNLOAD = "repo";
+  public static final String REPO = "repo";
 
   private CoreDownloadSchemes() {}
 }
diff --git a/java/com/google/gerrit/entities/EntitiesAdapterFactory.java b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
new file mode 100644
index 0000000..e6a06fd
--- /dev/null
+++ b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
@@ -0,0 +1,25 @@
+// 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.entities;
+
+import com.google.gson.TypeAdapterFactory;
+import com.ryanharter.auto.value.gson.GsonTypeAdapterFactory;
+
+@GsonTypeAdapterFactory
+public abstract class EntitiesAdapterFactory implements TypeAdapterFactory {
+  public static TypeAdapterFactory create() {
+    return new AutoValueGson_EntitiesAdapterFactory();
+  }
+}
diff --git a/java/com/google/gerrit/entities/ImmutableConfig.java b/java/com/google/gerrit/entities/ImmutableConfig.java
new file mode 100644
index 0000000..a5efc14
--- /dev/null
+++ b/java/com/google/gerrit/entities/ImmutableConfig.java
@@ -0,0 +1,91 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Immutable parsed representation of a {@link org.eclipse.jgit.lib.Config} that can be cached.
+ * Supports only a limited set of operations.
+ */
+public class ImmutableConfig {
+  public static final ImmutableConfig EMPTY = new ImmutableConfig("", new Config());
+
+  private final String stringCfg;
+  private final Config cfg;
+
+  private ImmutableConfig(String stringCfg, Config cfg) {
+    this.stringCfg = stringCfg;
+    this.cfg = cfg;
+  }
+
+  public static ImmutableConfig parse(String stringCfg) throws ConfigInvalidException {
+    Config cfg = new Config();
+    cfg.fromText(stringCfg);
+    return new ImmutableConfig(stringCfg, cfg);
+  }
+
+  /** Returns a mutable copy of this config. */
+  public Config mutableCopy() {
+    Config cfg = new Config();
+    try {
+      cfg.fromText(this.cfg.toText());
+    } catch (ConfigInvalidException e) {
+      // Can't happen as we used JGit to format that config.
+      throw new IllegalStateException(e);
+    }
+    return cfg;
+  }
+
+  /** @see Config#getSections() */
+  public Set<String> getSections() {
+    return cfg.getSections();
+  }
+
+  /** @see Config#getNames(String) */
+  public Set<String> getNames(String section) {
+    return cfg.getNames(section);
+  }
+
+  /** @see Config#getNames(String, String) */
+  public Set<String> getNames(String section, String subsection) {
+    return cfg.getNames(section, subsection);
+  }
+
+  /** @see Config#getStringList(String, String, String) */
+  public String[] getStringList(String section, String subsection, String name) {
+    return cfg.getStringList(section, subsection, name);
+  }
+
+  /** @see Config#getSubsections(String) */
+  public Set<String> getSubsections(String section) {
+    return cfg.getSubsections(section);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof ImmutableConfig)) {
+      return false;
+    }
+    return ((ImmutableConfig) o).stringCfg.equals(stringCfg);
+  }
+
+  @Override
+  public int hashCode() {
+    return stringCfg.hashCode();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/InternalGroup.java b/java/com/google/gerrit/entities/InternalGroup.java
similarity index 94%
rename from java/com/google/gerrit/server/group/InternalGroup.java
rename to java/com/google/gerrit/entities/InternalGroup.java
index f33adaf..ebfa36a 100644
--- a/java/com/google/gerrit/server/group/InternalGroup.java
+++ b/java/com/google/gerrit/entities/InternalGroup.java
@@ -12,13 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.group;
+package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AccountGroup;
 import java.io.Serializable;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/java/com/google/gerrit/entities/LabelId.java b/java/com/google/gerrit/entities/LabelId.java
index 1cc45c8..2426818 100644
--- a/java/com/google/gerrit/entities/LabelId.java
+++ b/java/com/google/gerrit/entities/LabelId.java
@@ -18,7 +18,9 @@
 
 @AutoValue
 public abstract class LabelId {
-  static final String LEGACY_SUBMIT_NAME = "SUBM";
+  public static final String LEGACY_SUBMIT_NAME = "SUBM";
+  public static final String CODE_REVIEW = "Code-Review";
+  public static final String VERIFIED = "Verified";
 
   public static LabelId create(String n) {
     return new AutoValue_LabelId(n);
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index a8d4da5..9649642 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -29,6 +29,7 @@
 public abstract class LabelType {
   public static final boolean DEF_ALLOW_POST_SUBMIT = true;
   public static final boolean DEF_CAN_OVERRIDE = true;
+  public static final boolean DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE = false;
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
   public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
   public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
@@ -101,6 +102,8 @@
 
   public abstract boolean isCopyMaxScore();
 
+  public abstract boolean isCopyAllScoresIfListOfFilesDidNotChange();
+
   public abstract boolean isCopyAllScoresOnMergeFirstParentUpdate();
 
   public abstract boolean isCopyAllScoresOnTrivialRebase();
@@ -143,6 +146,8 @@
         .setMaxNegative(Short.MIN_VALUE)
         .setMaxPositive(Short.MAX_VALUE)
         .setCanOverride(DEF_CAN_OVERRIDE)
+        .setCopyAllScoresIfListOfFilesDidNotChange(
+            DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
         .setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
         .setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
         .setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
@@ -238,6 +243,9 @@
 
     public abstract Builder setCopyMaxScore(boolean copyMaxScore);
 
+    public abstract Builder setCopyAllScoresIfListOfFilesDidNotChange(
+        boolean copyAllScoresIfListOfFilesDidNotChange);
+
     public abstract Builder setCopyAllScoresOnMergeFirstParentUpdate(
         boolean copyAllScoresOnMergeFirstParentUpdate);
 
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/LegacySubmitRequirement.java
similarity index 74%
rename from java/com/google/gerrit/entities/SubmitRequirement.java
rename to java/com/google/gerrit/entities/LegacySubmitRequirement.java
index f9301a4..6e3c86e 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/LegacySubmitRequirement.java
@@ -19,10 +19,10 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.CharMatcher;
 
-/** Describes a requirement to submit a change. */
+/** Describes a submit record requirement. Contains the requirement name and a description. */
 @AutoValue
 @AutoValue.CopyAnnotations
-public abstract class SubmitRequirement {
+public abstract class LegacySubmitRequirement {
   private static final CharMatcher TYPE_MATCHER =
       CharMatcher.inRange('a', 'z')
           .or(CharMatcher.inRange('A', 'Z'))
@@ -35,23 +35,25 @@
 
     public abstract Builder setFallbackText(String value);
 
-    public SubmitRequirement build() {
-      SubmitRequirement requirement = autoBuild();
+    public LegacySubmitRequirement build() {
+      LegacySubmitRequirement requirement = autoBuild();
       checkState(
           validateType(requirement.type()),
-          "SubmitRequirement's type contains non alphanumerical symbols.");
+          "LegacySubmitRequirement's type contains non alphanumerical symbols.");
       return requirement;
     }
 
-    abstract SubmitRequirement autoBuild();
+    abstract LegacySubmitRequirement autoBuild();
   }
 
+  /** Requirement description and explanation of what it does */
   public abstract String fallbackText();
 
+  /** Requirement name */
   public abstract String type();
 
   public static Builder builder() {
-    return new AutoValue_SubmitRequirement.Builder();
+    return new AutoValue_LegacySubmitRequirement.Builder();
   }
 
   private static boolean validateType(String type) {
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index e6b2167..856765b 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -160,5 +160,40 @@
     }
   }
 
+  /**
+   * Constants describing various file modes recognized by GIT. This is the Gerrit entity for {@link
+   * org.eclipse.jgit.lib.FileMode}.
+   */
+  public enum FileMode implements CodedEnum {
+    /** Mode indicating an entry is a tree (aka directory). */
+    TREE('T'),
+
+    /** Mode indicating an entry is a symbolic link. */
+    SYMLINK('S'),
+
+    /** Mode indicating an entry is a non-executable file. */
+    REGULAR_FILE('R'),
+
+    /** Mode indicating an entry is an executable file. */
+    EXECUTABLE_FILE('E'),
+
+    /** Mode indicating an entry is a submodule commit in another repository. */
+    GITLINK('G'),
+
+    /** Mode indicating an entry is missing during parallel walks. */
+    MISSING('M');
+
+    private final char code;
+
+    FileMode(char c) {
+      code = c;
+    }
+
+    @Override
+    public char getCode() {
+      return code;
+    }
+  }
+
   private Patch() {}
 }
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 3f04fa5..322c79e 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -140,6 +140,7 @@
     return true;
   }
 
+  /** The permission name, eg. {@code Permission.SUBMIT} */
   public abstract String getName();
 
   protected abstract boolean isExclusiveGroup();
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index 5595bc7..2263aba 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.entities;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.UsedAt;
 
 /** Constants and utilities for Gerrit-specific ref names. */
@@ -105,6 +106,26 @@
   /** A change starred by a user */
   public static final String REFS_STARRED_CHANGES = "refs/starred-changes/";
 
+  /**
+   * List of refs managed by Gerrit. Covers all Gerrit internal refs.
+   *
+   * <p><b>Caution</b> Any ref not in this list will be served if the user was granted a READ
+   * permission on it using Gerrit's permission model.
+   */
+  public static final ImmutableList<String> GERRIT_REFS =
+      ImmutableList.of(
+          REFS_CHANGES,
+          REFS_EXTERNAL_IDS,
+          REFS_CACHE_AUTOMERGE,
+          REFS_DRAFT_COMMENTS,
+          REFS_DELETED_GROUPS,
+          REFS_SEQUENCES,
+          REFS_GROUPS,
+          REFS_GROUPNAMES,
+          REFS_USERS,
+          REFS_STARRED_CHANGES,
+          REFS_REJECT_COMMITS);
+
   public static String fullName(String ref) {
     return (ref.startsWith(REFS) || ref.equals(HEAD)) ? ref : REFS_HEADS + ref;
   }
@@ -118,6 +139,11 @@
     return ref;
   }
 
+  /**
+   * Warning: Change refs have to manually be advertised in {@code
+   * com.google.gerrit.server.permissions.DefaultRefFilter}; this should be done when adding new
+   * change refs.
+   */
   public static String changeMetaRef(Change.Id id) {
     StringBuilder r = newStringBuilder().append(REFS_CHANGES);
     return shard(id.get(), r).append(META_SUFFIX).toString();
@@ -255,6 +281,10 @@
     return ref.startsWith(REFS_USERS);
   }
 
+  public static boolean isRefsUsersSelf(String ref, boolean isAllUsers) {
+    return isAllUsers && REFS_USERS_SELF.equals(ref);
+  }
+
   /**
    * Whether the ref is a group branch that stores NoteDb data of a group. Returns {@code true} for
    * all refs that start with {@code refs/groups/}.
@@ -271,6 +301,16 @@
     return ref.startsWith(REFS_DELETED_GROUPS);
   }
 
+  /** Returns true if the provided ref is for draft comments. */
+  public static boolean isRefsDraftsComments(String ref) {
+    return ref.startsWith(REFS_DRAFT_COMMENTS);
+  }
+
+  /** Returns true if the provided ref is for starred changes. */
+  public static boolean isRefsStarredChanges(String ref) {
+    return ref.startsWith(REFS_STARRED_CHANGES);
+  }
+
   /**
    * Whether the ref is used for storing group data in NoteDb. Returns {@code true} for all group
    * branches, refs/meta/group-names and deleted group branches.
@@ -292,21 +332,11 @@
    * <p>Any ref for which this method evaluates to true will be served to users who have the {@code
    * ACCESS_DATABASE} capability.
    *
-   * <p><b>Caution</b>Any ref not in this list will be served if the user was granted a READ
+   * <p><b>Caution</b> Any ref not in this list will be served if the user was granted a READ
    * permission on it using Gerrit's permission model.
    */
   public static boolean isGerritRef(String ref) {
-    return ref.startsWith(REFS_CHANGES)
-        || ref.startsWith(REFS_EXTERNAL_IDS)
-        || ref.startsWith(REFS_CACHE_AUTOMERGE)
-        || ref.startsWith(REFS_DRAFT_COMMENTS)
-        || ref.startsWith(REFS_DELETED_GROUPS)
-        || ref.startsWith(REFS_SEQUENCES)
-        || ref.startsWith(REFS_GROUPS)
-        || ref.startsWith(REFS_GROUPNAMES)
-        || ref.startsWith(REFS_USERS)
-        || ref.startsWith(REFS_STARRED_CHANGES)
-        || ref.startsWith(REFS_REJECT_COMMITS);
+    return GERRIT_REFS.stream().anyMatch(internalRef -> ref.startsWith(internalRef));
   }
 
   static Integer parseShardedRefPart(String name) {
diff --git a/java/com/google/gerrit/entities/SubmitRecord.java b/java/com/google/gerrit/entities/SubmitRecord.java
index 67c6007..860997f 100644
--- a/java/com/google/gerrit/entities/SubmitRecord.java
+++ b/java/com/google/gerrit/entities/SubmitRecord.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.entities;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Objects;
@@ -50,7 +53,11 @@
     FORCED,
 
     /**
-     * An internal server error occurred preventing computation.
+     * A rule error caused by user misconfiguration.
+     *
+     * <p>This status should only be used to signal that the user has misconfigured the submit rule.
+     * In case plugins encounter server exceptions while evaluating the rule, they should throw a
+     * {@link RuntimeException} such as {@link IllegalStateException}.
      *
      * <p>Additional detail may be available in {@link SubmitRecord#errorMessage}.
      */
@@ -63,7 +70,7 @@
 
   public Status status;
   public List<Label> labels;
-  public List<SubmitRequirement> requirements;
+  public List<LegacySubmitRequirement> requirements;
   public String errorMessage;
 
   public static class Label {
@@ -107,6 +114,17 @@
     public Status status;
     public Account.Id appliedBy;
 
+    /**
+     * Returns a new instance of {@link Label} that contains a new instance for each mutable field.
+     */
+    public Label deepCopy() {
+      Label copy = new Label();
+      copy.label = label;
+      copy.status = status;
+      copy.appliedBy = appliedBy;
+      return copy;
+    }
+
     @Override
     public String toString() {
       StringBuilder sb = new StringBuilder();
@@ -134,6 +152,23 @@
     }
   }
 
+  /**
+   * Returns a new instance of {@link SubmitRecord} that contains a new instance for each mutable
+   * field.
+   */
+  public SubmitRecord deepCopy() {
+    SubmitRecord copy = new SubmitRecord();
+    copy.status = status;
+    copy.errorMessage = errorMessage;
+    if (labels != null) {
+      copy.labels = labels.stream().map(Label::deepCopy).collect(toImmutableList());
+    }
+    if (requirements != null) {
+      copy.requirements = ImmutableList.copyOf(requirements);
+    }
+    return copy;
+  }
+
   @Override
   public String toString() {
     StringBuilder sb = new StringBuilder();
@@ -152,7 +187,7 @@
     sb.append("],[");
     if (requirements != null) {
       String delimiter = "";
-      for (SubmitRequirement requirement : requirements) {
+      for (LegacySubmitRequirement requirement : requirements) {
         sb.append(delimiter).append(requirement);
         delimiter = ", ";
       }
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
similarity index 62%
copy from java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
copy to java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
index 08d6ce7..452192c 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
+++ b/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.change;
+package com.google.gerrit.exceptions;
 
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.List;
+public class InternalServerWithUserMessageException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
 
-public interface PluginDefinedAttributesFactory {
-  List<PluginDefinedInfo> create(ChangeData cd);
+  public InternalServerWithUserMessageException(String msg, Throwable cause) {
+    super(msg, cause);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/BUILD b/java/com/google/gerrit/extensions/BUILD
index da5dc8b..21949f7 100644
--- a/java/com/google/gerrit/extensions/BUILD
+++ b/java/com/google/gerrit/extensions/BUILD
@@ -1,5 +1,5 @@
 load("@rules_java//java:defs.bzl", "java_binary", "java_library")
-load("//lib:guava.bzl", "GUAVA_DOC_URL")
+load("//tools:nongoogle.bzl", "GUAVA_DOC_URL")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 
 _DOC_VERS = "5.5.0.201909110433-r"
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 3364fc1..7cbfebd 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -21,6 +21,7 @@
 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.ChangeInfoDifference;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
@@ -32,6 +33,7 @@
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
@@ -260,6 +262,46 @@
         EnumSet.complementOf(EnumSet.of(ListChangesOption.CHECK, ListChangesOption.SKIP_DIFFSTAT)));
   }
 
+  default ChangeInfoDifference metaDiff(
+      @Nullable String oldMetaRevId, @Nullable String newMetaRevId) throws RestApiException {
+    return metaDiff(
+        oldMetaRevId,
+        newMetaRevId,
+        EnumSet.noneOf(ListChangesOption.class),
+        ImmutableListMultimap.of());
+  }
+
+  default ChangeInfoDifference metaDiff(
+      @Nullable String oldMetaRevId, @Nullable String newMetaRevId, ListChangesOption... options)
+      throws RestApiException {
+    return metaDiff(oldMetaRevId, newMetaRevId, Arrays.asList(options));
+  }
+
+  default ChangeInfoDifference metaDiff(
+      @Nullable String oldMetaRevId,
+      @Nullable String newMetaRevId,
+      Collection<ListChangesOption> options)
+      throws RestApiException {
+    return metaDiff(
+        oldMetaRevId,
+        newMetaRevId,
+        Sets.newEnumSet(options, ListChangesOption.class),
+        ImmutableListMultimap.of());
+  }
+
+  /**
+   * Gets the diff between a change's metadata with the two given refs.
+   *
+   * @param oldMetaRevId the SHA-1 of the 'before' metadata diffed against {@code newMetaRevId}
+   * @param newMetaRevId the SHA-1 of the 'after' metadata diffed against {@code oldMetaRevId}
+   */
+  ChangeInfoDifference metaDiff(
+      @Nullable String oldMetaRevId,
+      @Nullable String newMetaRevId,
+      EnumSet<ListChangesOption> options,
+      ImmutableListMultimap<String, String> pluginOptions)
+      throws RestApiException;
+
   /** {@link #get(ListChangesOption...)} with no options included. */
   default ChangeInfo info() throws RestApiException {
     return get(EnumSet.noneOf(ListChangesOption.class));
@@ -370,7 +412,9 @@
    *     their patch set.
    * @throws RestApiException
    */
-  Map<String, List<CommentInfo>> drafts() throws RestApiException;
+  default Map<String, List<CommentInfo>> drafts() throws RestApiException {
+    return draftsRequest().get();
+  }
 
   /**
    * Get all draft comments for the current user on a change as a list.
@@ -379,7 +423,17 @@
    *     set.
    * @throws RestApiException
    */
-  List<CommentInfo> draftsAsList() throws RestApiException;
+  default List<CommentInfo> draftsAsList() throws RestApiException {
+    return draftsRequest().getAsList();
+  }
+
+  /**
+   * Get a {@link DraftsRequest} entity that can be used to retrieve draft comments.
+   *
+   * @return A {@link DraftsRequest} entity that can be used to retrieve the draft comments using
+   *     {@link DraftsRequest#get()} or {@link DraftsRequest#getAsList()}.
+   */
+  DraftsRequest draftsRequest() throws RestApiException;
 
   ChangeInfo check() throws RestApiException;
 
@@ -413,6 +467,7 @@
 
   abstract class CommentsRequest {
     private boolean enableContext;
+    private int contextPadding;
 
     /**
      * Get all published comments on a change.
@@ -436,6 +491,11 @@
       return this;
     }
 
+    public CommentsRequest contextPadding(int contextPadding) {
+      this.contextPadding = contextPadding;
+      return this;
+    }
+
     public CommentsRequest withContext() {
       this.enableContext = true;
       return this;
@@ -444,8 +504,14 @@
     public boolean getContext() {
       return enableContext;
     }
+
+    public int getContextPadding() {
+      return contextPadding;
+    }
   }
 
+  abstract class DraftsRequest extends CommentsRequest {}
+
   abstract class SuggestedReviewersRequest {
     private String query;
     private int limit;
@@ -604,6 +670,16 @@
     }
 
     @Override
+    public ChangeInfoDifference metaDiff(
+        @Nullable String oldMetaRevId,
+        @Nullable String newMetaRevId,
+        EnumSet<ListChangesOption> options,
+        ImmutableListMultimap<String, String> pluginOptions)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void setMessage(CommitMessageInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -686,6 +762,11 @@
     }
 
     @Override
+    public DraftsRequest draftsRequest() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ChangeInfo check() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/MoveInput.java b/java/com/google/gerrit/extensions/api/changes/MoveInput.java
index 795642a..3d82990 100644
--- a/java/com/google/gerrit/extensions/api/changes/MoveInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/MoveInput.java
@@ -17,4 +17,14 @@
 public class MoveInput {
   public String message;
   public String destinationBranch;
+  /**
+   * Whether or not to keep all votes in the destination branch. Keeping the votes can be confusing
+   * in the context of the destination branch, see
+   * https://gerrit-review.googlesource.com/c/gerrit/+/129171. That is why only the users with
+   * {@link com.google.gerrit.server.permissions.GlobalPermission#ADMINISTRATE_SERVER} permissions
+   * can use this option.
+   *
+   * <p>By default, only the veto votes that are blocking the change from submission are moved.
+   */
+  public boolean keepAllVotes;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index 5f4a014..10559a3 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -16,4 +16,12 @@
 
 public class RebaseInput {
   public String base;
+
+  /**
+   * Whether the rebase should succeed if there are conflicts.
+   *
+   * <p>If there are conflicts the file contents of the rebased change contain git conflict markers
+   * to indicate the conflicts.
+   */
+  public boolean allowConflicts;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index b419c2f..73e6a4e 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -68,6 +68,8 @@
 
   ChangeApi rebase(RebaseInput in) throws RestApiException;
 
+  ChangeInfo rebaseAsInfo(RebaseInput in) throws RestApiException;
+
   boolean canRebase() throws RestApiException;
 
   RevisionReviewerApi reviewer(String id) throws RestApiException;
@@ -218,6 +220,11 @@
     }
 
     @Override
+    public ChangeInfo rebaseAsInfo(RebaseInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public boolean canRebase() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
index fab2ec4..423ac49 100644
--- a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
+++ b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
@@ -14,10 +14,15 @@
 
 package com.google.gerrit.extensions.api.config;
 
+import java.util.List;
+
 public class AccessCheckInfo {
   public String message;
   // HTTP status code
   public int status;
 
+  /** Debug logs that may help to understand why a permission is denied or allowed. */
+  public List<String> debugLogs;
+
   // for future extension, we may add inputs / results for bulk checks.
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/CommitApi.java b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
index a53fc74..b0cc9da 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommitApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
@@ -18,8 +18,10 @@
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Map;
 
 public interface CommitApi {
   CommitInfo get() throws RestApiException;
@@ -28,6 +30,9 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
+  /** List files in a specific commit against the parent commit. */
+  Map<String, FileInfo> files(int parentNum) throws RestApiException;
+
   /** A default implementation for source compatibility when adding new methods to the interface. */
   class NotImplemented implements CommitApi {
     @Override
@@ -44,5 +49,10 @@
     public IncludedInInfo includedIn() throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 30514a6..21b319e 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -27,11 +27,12 @@
 
   /** Preferred method to download a change. */
   public enum DownloadCommand {
-    REPO_DOWNLOAD,
     PULL,
     CHECKOUT,
     CHERRY_PICK,
-    FORMAT_PATCH
+    FORMAT_PATCH,
+    BRANCH,
+    RESET,
   }
 
   public enum DateFormat {
@@ -146,6 +147,7 @@
   public EmailFormat emailFormat;
   public DefaultBase defaultBaseForMerges;
   public Boolean publishCommentsOnPush;
+  public Boolean disableKeyboardShortcuts;
   public Boolean workInProgressByDefault;
   public List<MenuItem> my;
   public List<String> changeTable;
@@ -204,6 +206,7 @@
     p.emailFormat = EmailFormat.HTML_PLAINTEXT;
     p.defaultBaseForMerges = DefaultBase.FIRST_PARENT;
     p.publishCommentsOnPush = false;
+    p.disableKeyboardShortcuts = false;
     p.workInProgressByDefault = false;
     return p;
   }
diff --git a/java/com/google/gerrit/extensions/common/ActionInfo.java b/java/com/google/gerrit/extensions/common/ActionInfo.java
index 6ab80b2..2144ed5 100644
--- a/java/com/google/gerrit/extensions/common/ActionInfo.java
+++ b/java/com/google/gerrit/extensions/common/ActionInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.webui.UiAction;
+import java.util.Objects;
 
 /**
  * Representation of an action in the REST API.
@@ -55,4 +56,23 @@
     title = d.getTitle();
     enabled = d.isEnabled() ? true : null;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ActionInfo) {
+      ActionInfo actionInfo = (ActionInfo) o;
+      return Objects.equals(method, actionInfo.method)
+          && Objects.equals(label, actionInfo.label)
+          && Objects.equals(title, actionInfo.title)
+          && Objects.equals(enabled, actionInfo.enabled);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(method, label, title, enabled);
+  }
+
+  protected ActionInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index f95ddff..bf72e83 100644
--- a/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.util.Objects;
 
 /**
  * Representation of an approval in the REST API.
@@ -71,4 +72,23 @@
     this.date = date;
     this.tag = tag;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ApprovalInfo) {
+      ApprovalInfo approvalInfo = (ApprovalInfo) o;
+      return super.equals(o)
+          && Objects.equals(tag, approvalInfo.tag)
+          && Objects.equals(value, approvalInfo.value)
+          && Objects.equals(date, approvalInfo.date)
+          && Objects.equals(postSubmit, approvalInfo.postSubmit)
+          && Objects.equals(permittedVotingRange, approvalInfo.permittedVotingRange);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), tag, value, date, postSubmit, permittedVotingRange);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
index f29d32b..ba865fb 100644
--- a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
+++ b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.util.Objects;
 
 /**
  * Represents a single user included in the attention set. Used in the API. See {@link
@@ -36,4 +37,22 @@
     this.lastUpdate = lastUpdate;
     this.reason = reason;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AttentionSetInfo) {
+      AttentionSetInfo attentionSetInfo = (AttentionSetInfo) o;
+      return Objects.equals(account, attentionSetInfo.account)
+          && Objects.equals(lastUpdate, attentionSetInfo.lastUpdate)
+          && Objects.equals(reason, attentionSetInfo.reason);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(account, lastUpdate, reason);
+  }
+
+  protected AttentionSetInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/AvatarInfo.java b/java/com/google/gerrit/extensions/common/AvatarInfo.java
index 75665a8..b620ac2 100644
--- a/java/com/google/gerrit/extensions/common/AvatarInfo.java
+++ b/java/com/google/gerrit/extensions/common/AvatarInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 /**
  * Representation of an avatar in the REST API.
  *
@@ -38,4 +40,20 @@
 
   /** The width of the avatar image in pixels. */
   public Integer width;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AvatarInfo) {
+      AvatarInfo avatarInfo = (AvatarInfo) o;
+      return Objects.equals(url, avatarInfo.url)
+          && Objects.equals(height, avatarInfo.height)
+          && Objects.equals(width, avatarInfo.width);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(url, height, width);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index a441bfd..b387017 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -19,7 +19,6 @@
   public Boolean allowBlame;
   public Boolean showAssigneeInChangesTable;
   public Boolean disablePrivateChanges;
-  public int largeChange;
   public String replyLabel;
   public String replyTooltip;
   public int updateDelay;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 190a97e..9e915f5 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -70,6 +72,7 @@
   public String submissionId;
   public Integer cherryPickOfChange;
   public Integer cherryPickOfPatchSet;
+  public String metaRevId;
 
   /**
    * Whether the change contains conflicts.
@@ -83,11 +86,12 @@
    * com.google.gerrit.server.restapi.change.CreateChange}, {@link
    * com.google.gerrit.server.restapi.change.CreateMergePatchSet}, {@link
    * com.google.gerrit.server.restapi.change.CherryPick}, {@link
-   * com.google.gerrit.server.restapi.change.CherryPickCommit}
+   * com.google.gerrit.server.restapi.change.CherryPickCommit}, {@link
+   * com.google.gerrit.server.restapi.change.Rebase}
    */
   public Boolean containsGitConflicts;
 
-  public int _number;
+  public Integer _number;
 
   public AccountInfo owner;
 
@@ -107,5 +111,15 @@
   public List<ProblemInfo> problems;
   public List<PluginDefinedInfo> plugins;
   public Collection<TrackingIdInfo> trackingIds;
-  public Collection<SubmitRequirementInfo> requirements;
+  public Collection<LegacySubmitRequirementInfo> requirements;
+
+  public ChangeInfo() {}
+
+  public ChangeInfo(ChangeMessageInfo... messages) {
+    this.messages = ImmutableList.copyOf(messages);
+  }
+
+  public ChangeInfo(Map<String, RevisionInfo> revisions) {
+    this.revisions = ImmutableMap.copyOf(revisions);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
new file mode 100644
index 0000000..0fff0ba
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -0,0 +1,203 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Gets the differences between two {@link ChangeInfo}s.
+ *
+ * <p>This must be in package {@code com.google.gerrit.extensions.common} for access to protected
+ * constructors.
+ *
+ * <p>This assumes that every class reachable from {@link ChangeInfo} has a non-private constructor
+ * with zero parameters and overrides the equals method.
+ */
+public final class ChangeInfoDiffer {
+
+  /**
+   * Returns the difference between two instances of {@link ChangeInfo}.
+   *
+   * <p>The {@link ChangeInfoDifference} returned has the following properties:
+   *
+   * <p>Unrepeated fields are present in the difference returned when they differ between {@code
+   * oldChangeInfo} and {@code newChangeInfo}. When there's an unrepeated field that's not a {@link
+   * String}, primitive, or enum, its fields are only returned when they differ.
+   *
+   * <p>Entries in {@link Map} fields are returned when a key is present in {@code newChangeInfo}
+   * and not {@code oldChangeInfo}. If a key is present in both, the diff of the value is returned.
+   *
+   * <p>{@link Collection} fields in {@link ChangeInfoDifference#added()} contain only items found
+   * in {@code newChangeInfo} and not {@code oldChangeInfo}.
+   *
+   * <p>{@link Collection} fields in {@link ChangeInfoDifference#removed()} contain only items found
+   * in {@code oldChangeInfo} and not {@code newChangeInfo}.
+   *
+   * @param oldChangeInfo the previous {@link ChangeInfo} to diff against {@code newChangeInfo}
+   * @param newChangeInfo the {@link ChangeInfo} to diff against {@code oldChangeInfo}
+   * @return the difference between the given {@link ChangeInfo}s
+   */
+  public static ChangeInfoDifference getDifference(
+      ChangeInfo oldChangeInfo, ChangeInfo newChangeInfo) {
+    return ChangeInfoDifference.create(
+        /* added= */ getAdded(oldChangeInfo, newChangeInfo),
+        /* removed= */ getAdded(newChangeInfo, oldChangeInfo));
+  }
+
+  @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);
+      return (T) result;
+    }
+
+    if (newValue instanceof Map) {
+      Map result = getAddedForMap((Map<?, ?>) oldValue, (Map<?, ?>) newValue);
+      return (T) result;
+    }
+
+    T toPopulate = (T) construct(newValue.getClass());
+    if (toPopulate == null) {
+      return null;
+    }
+
+    for (Field field : newValue.getClass().getDeclaredFields()) {
+      if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) {
+        continue;
+      }
+
+      Object newFieldObj = get(field, newValue);
+      if (oldValue == null || newFieldObj == null) {
+        set(field, toPopulate, newFieldObj);
+        continue;
+      }
+
+      Object oldFieldObj = get(field, oldValue);
+      if (newFieldObj.equals(oldFieldObj)) {
+        continue;
+      }
+
+      if (isSimple(field.getType()) || oldFieldObj == null) {
+        set(field, toPopulate, newFieldObj);
+      } else if (newFieldObj instanceof Collection || newFieldObj instanceof Map) {
+        set(field, toPopulate, getAdded(oldFieldObj, newFieldObj));
+      } else {
+        // Recurse to set all fields in the non-primitive object.
+        set(field, toPopulate, getAdded(oldFieldObj, newFieldObj));
+      }
+    }
+    return toPopulate;
+  }
+
+  @VisibleForTesting
+  static boolean isSimple(Class<?> c) {
+    return c.isPrimitive()
+        || c.isEnum()
+        || String.class.isAssignableFrom(c)
+        || Number.class.isAssignableFrom(c)
+        || Boolean.class.isAssignableFrom(c)
+        || Timestamp.class.isAssignableFrom(c);
+  }
+
+  @VisibleForTesting
+  static Object construct(Class<?> c) {
+    // Only use constructors without parameters because we can't determine what values to pass.
+    return stream(c.getDeclaredConstructors())
+        .filter(constructor -> constructor.getParameterCount() == 0)
+        .findAny()
+        .map(ChangeInfoDiffer::construct)
+        .orElseThrow(
+            () ->
+                new IllegalStateException("Class " + c + " must have a zero argument constructor"));
+  }
+
+  private static Object construct(Constructor<?> constructor) {
+    try {
+      return constructor.newInstance();
+    } catch (ReflectiveOperationException e) {
+      throw new IllegalStateException("Failed to construct class " + constructor.getName(), e);
+    }
+  }
+
+  /** @return {@code null} if nothing has been added to {@code oldCollection} */
+  private static ImmutableList<?> getAddedForCollection(
+      Collection<?> oldCollection, Collection<?> newCollection) {
+    ImmutableList<?> notInOldCollection = getAdditions(oldCollection, newCollection);
+    return notInOldCollection.isEmpty() ? null : notInOldCollection;
+  }
+
+  private static ImmutableList<Object> getAdditions(
+      Collection<?> oldCollection, Collection<?> newCollection) {
+    if (oldCollection == null)
+      return newCollection != null ? ImmutableList.copyOf(newCollection) : null;
+
+    Map<Object, List<Object>> duplicatesMap = newCollection.stream().collect(groupingBy(v -> v));
+    oldCollection.forEach(
+        v -> {
+          if (duplicatesMap.containsKey(v)) {
+            duplicatesMap.get(v).remove(v);
+          }
+        });
+    return duplicatesMap.values().stream().flatMap(Collection::stream).collect(toImmutableList());
+  }
+
+  /** @return {@code null} if nothing has been added to {@code oldMap} */
+  private static ImmutableMap<Object, Object> getAddedForMap(Map<?, ?> oldMap, Map<?, ?> newMap) {
+    ImmutableMap.Builder<Object, Object> additionsBuilder = ImmutableMap.builder();
+    for (Map.Entry<?, ?> entry : newMap.entrySet()) {
+      Object added = getAdded(oldMap.get(entry.getKey()), entry.getValue());
+      if (added != null) {
+        additionsBuilder.put(entry.getKey(), added);
+      }
+    }
+    ImmutableMap<Object, Object> additions = additionsBuilder.build();
+    return additions.isEmpty() ? null : additions;
+  }
+
+  private static Object get(Field field, Object obj) {
+    try {
+      return field.get(obj);
+    } catch (IllegalAccessException e) {
+      throw new IllegalStateException(
+          String.format("Access denied getting field %s in %s", field.getName(), obj.getClass()),
+          e);
+    }
+  }
+
+  private static void set(Field field, Object obj, Object value) {
+    try {
+      field.set(obj, value);
+    } catch (IllegalAccessException e) {
+      throw new IllegalStateException(
+          String.format(
+              "Access denied setting field %s in %s", field.getName(), obj.getClass().getName()),
+          e);
+    }
+  }
+
+  private ChangeInfoDiffer() {}
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java b/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
new file mode 100644
index 0000000..269c673
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.auto.value.AutoValue;
+
+/** The difference between two {@link ChangeInfo}s returned by {@link ChangeInfoDiffer}. */
+@AutoValue
+public abstract class ChangeInfoDifference {
+
+  public abstract ChangeInfo added();
+
+  public abstract ChangeInfo removed();
+
+  public static ChangeInfoDifference create(ChangeInfo added, ChangeInfo removed) {
+    return new AutoValue_ChangeInfoDifference(added, removed);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index 07ad71b..10456ff 100644
--- a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -26,6 +26,12 @@
   public String message;
   public Integer _revisionNumber;
 
+  public ChangeMessageInfo() {}
+
+  public ChangeMessageInfo(String message) {
+    this.message = message;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ChangeMessageInfo) {
diff --git a/java/com/google/gerrit/extensions/common/CommentInfo.java b/java/com/google/gerrit/extensions/common/CommentInfo.java
index fcce2b3..35587a0 100644
--- a/java/com/google/gerrit/extensions/common/CommentInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommentInfo.java
@@ -30,6 +30,9 @@
    */
   public List<ContextLineInfo> contextLines;
 
+  /** Mime type of the underlying source file. Only available if context lines are requested. */
+  public String sourceContentType;
+
   @Override
   public boolean equals(Object o) {
     if (super.equals(o)) {
diff --git a/java/com/google/gerrit/extensions/common/FetchInfo.java b/java/com/google/gerrit/extensions/common/FetchInfo.java
index eda84b1..4b1e941 100644
--- a/java/com/google/gerrit/extensions/common/FetchInfo.java
+++ b/java/com/google/gerrit/extensions/common/FetchInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.Map;
+import java.util.Objects;
 
 public class FetchInfo {
   public String url;
@@ -25,4 +26,22 @@
     this.url = url;
     this.ref = ref;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof FetchInfo) {
+      FetchInfo fetchInfo = (FetchInfo) o;
+      return Objects.equals(url, fetchInfo.url)
+          && Objects.equals(ref, fetchInfo.ref)
+          && Objects.equals(commands, fetchInfo.commands);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(url, ref, commands);
+  }
+
+  protected FetchInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index 32c5bd5..510c2ad 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -34,8 +34,8 @@
           && Objects.equals(oldPath, fileInfo.oldPath)
           && Objects.equals(linesInserted, fileInfo.linesInserted)
           && Objects.equals(linesDeleted, fileInfo.linesDeleted)
-          && Objects.equals(sizeDelta, fileInfo.sizeDelta)
-          && Objects.equals(size, fileInfo.size);
+          && sizeDelta == fileInfo.sizeDelta
+          && size == fileInfo.size;
     }
     return false;
   }
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index 2ae6703..3265a00 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -23,4 +23,5 @@
   public Boolean editGpgKeys;
   public String reportBugUrl;
   public String primaryWeblinkName;
+  public String instanceId;
 }
diff --git a/java/com/google/gerrit/extensions/common/GpgKeyInfo.java b/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
index 7a5c15b..d656f22 100644
--- a/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
+++ b/java/com/google/gerrit/extensions/common/GpgKeyInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 public class GpgKeyInfo {
   /**
@@ -43,4 +44,22 @@
 
   public Status status;
   public List<String> problems;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof GpgKeyInfo) {
+      GpgKeyInfo gpgKeyInfo = (GpgKeyInfo) o;
+      return Objects.equals(id, gpgKeyInfo.id)
+          && Objects.equals(fingerprint, gpgKeyInfo.fingerprint)
+          && Objects.equals(userIds, gpgKeyInfo.userIds)
+          && Objects.equals(status, gpgKeyInfo.status)
+          && Objects.equals(problems, gpgKeyInfo.problems);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(id, fingerprint, userIds, status, problems);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
index f552566..9a6d086 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -28,6 +28,7 @@
   public Boolean copyAnyScore;
   public Boolean copyMinScore;
   public Boolean copyMaxScore;
+  public Boolean copyAllScoresIfListOfFilesDidNotChange;
   public Boolean copyAllScoresIfNoChange;
   public Boolean copyAllScoresIfNoCodeChange;
   public Boolean copyAllScoresOnTrivialRebase;
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 23d5df1..87cae86 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -27,6 +27,7 @@
   public Boolean copyAnyScore;
   public Boolean copyMinScore;
   public Boolean copyMaxScore;
+  public Boolean copyAllScoresIfListOfFilesDidNotChange;
   public Boolean copyAllScoresIfNoChange;
   public Boolean copyAllScoresIfNoCodeChange;
   public Boolean copyAllScoresOnTrivialRebase;
diff --git a/java/com/google/gerrit/extensions/common/LabelInfo.java b/java/com/google/gerrit/extensions/common/LabelInfo.java
index 76dd93dd..44bcdaf 100644
--- a/java/com/google/gerrit/extensions/common/LabelInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelInfo.java
@@ -16,6 +16,7 @@
 
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 public class LabelInfo {
   public AccountInfo approved;
@@ -30,4 +31,37 @@
   public Short defaultValue;
   public Boolean optional;
   public Boolean blocking;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof LabelInfo) {
+      LabelInfo labelInfo = (LabelInfo) o;
+      return Objects.equals(approved, labelInfo.approved)
+          && Objects.equals(rejected, labelInfo.rejected)
+          && Objects.equals(recommended, labelInfo.recommended)
+          && Objects.equals(disliked, labelInfo.disliked)
+          && Objects.equals(all, labelInfo.all)
+          && Objects.equals(values, labelInfo.values)
+          && Objects.equals(value, labelInfo.value)
+          && Objects.equals(defaultValue, labelInfo.defaultValue)
+          && Objects.equals(optional, labelInfo.optional)
+          && Objects.equals(blocking, labelInfo.blocking);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        approved,
+        rejected,
+        recommended,
+        disliked,
+        all,
+        values,
+        value,
+        defaultValue,
+        optional,
+        blocking);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/LegacySubmitRequirementInfo.java
similarity index 78%
rename from java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
rename to java/com/google/gerrit/extensions/common/LegacySubmitRequirementInfo.java
index 3483de5..adf01e1 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
+++ b/java/com/google/gerrit/extensions/common/LegacySubmitRequirementInfo.java
@@ -17,12 +17,12 @@
 import com.google.common.base.MoreObjects;
 import java.util.Objects;
 
-public class SubmitRequirementInfo {
-  public final String status;
-  public final String fallbackText;
-  public final String type;
+public class LegacySubmitRequirementInfo {
+  public String status;
+  public String fallbackText;
+  public String type;
 
-  public SubmitRequirementInfo(String status, String fallbackText, String type) {
+  public LegacySubmitRequirementInfo(String status, String fallbackText, String type) {
     this.status = status;
     this.fallbackText = fallbackText;
     this.type = type;
@@ -33,10 +33,10 @@
     if (this == o) {
       return true;
     }
-    if (!(o instanceof SubmitRequirementInfo)) {
+    if (!(o instanceof LegacySubmitRequirementInfo)) {
       return false;
     }
-    SubmitRequirementInfo that = (SubmitRequirementInfo) o;
+    LegacySubmitRequirementInfo that = (LegacySubmitRequirementInfo) o;
     return Objects.equals(status, that.status)
         && Objects.equals(fallbackText, that.fallbackText)
         && Objects.equals(type, that.type);
@@ -55,4 +55,6 @@
         .add("type", type)
         .toString();
   }
+
+  protected LegacySubmitRequirementInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/PluginConfigInfo.java b/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
index 13fc9ec..2d1d840 100644
--- a/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
@@ -19,5 +19,4 @@
 public class PluginConfigInfo {
   public Boolean hasAvatars;
   public List<String> jsResourcePaths;
-  public List<String> htmlResourcePaths;
 }
diff --git a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
index 69bfa2c..e2b1c36 100644
--- a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
+++ b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
@@ -14,7 +14,24 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class PluginDefinedInfo {
   public String name;
   public String message;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof PluginDefinedInfo) {
+      PluginDefinedInfo pluginDefinedInfo = (PluginDefinedInfo) o;
+      return Objects.equals(name, pluginDefinedInfo.name)
+          && Objects.equals(message, pluginDefinedInfo.message);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, message);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/PushCertificateInfo.java b/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
index 9eed808..199dbd1 100644
--- a/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
+++ b/java/com/google/gerrit/extensions/common/PushCertificateInfo.java
@@ -14,7 +14,24 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class PushCertificateInfo {
   public String certificate;
   public GpgKeyInfo key;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof PushCertificateInfo) {
+      PushCertificateInfo pushCertificateInfo = (PushCertificateInfo) o;
+      return Objects.equals(certificate, pushCertificateInfo.certificate)
+          && Objects.equals(key, pushCertificateInfo.key);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(certificate, key);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
index eccdc64..37e1ceb 100644
--- a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
+++ b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
@@ -16,10 +16,28 @@
 
 import com.google.gerrit.extensions.client.ReviewerState;
 import java.sql.Timestamp;
+import java.util.Objects;
 
 public class ReviewerUpdateInfo {
   public Timestamp updated;
   public AccountInfo updatedBy;
   public AccountInfo reviewer;
   public ReviewerState state;
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ReviewerUpdateInfo) {
+      ReviewerUpdateInfo reviewerUpdateInfo = (ReviewerUpdateInfo) o;
+      return Objects.equals(updated, reviewerUpdateInfo.updated)
+          && Objects.equals(updatedBy, reviewerUpdateInfo.updatedBy)
+          && Objects.equals(reviewer, reviewerUpdateInfo.reviewer)
+          && Objects.equals(state, reviewerUpdateInfo.state);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(updated, updatedBy, reviewer, state);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index f262901..f710ab7 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.ChangeKind;
 import java.sql.Timestamp;
 import java.util.Map;
+import java.util.Objects;
 
 public class RevisionInfo {
   // ActionJson#copy(List, RevisionInfo) must be adapted if new fields are added that are not
@@ -34,4 +35,58 @@
   public String commitWithFooters;
   public PushCertificateInfo pushCertificate;
   public String description;
+
+  public RevisionInfo() {}
+
+  public RevisionInfo(String ref) {
+    this.ref = ref;
+  }
+
+  public RevisionInfo(String ref, int number) {
+    this.ref = ref;
+    _number = number;
+  }
+
+  public RevisionInfo(AccountInfo uploader) {
+    this.uploader = uploader;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof RevisionInfo) {
+      RevisionInfo revisionInfo = (RevisionInfo) o;
+      return isCurrent == revisionInfo.isCurrent
+          && Objects.equals(kind, revisionInfo.kind)
+          && _number == revisionInfo._number
+          && Objects.equals(created, revisionInfo.created)
+          && Objects.equals(uploader, revisionInfo.uploader)
+          && Objects.equals(ref, revisionInfo.ref)
+          && Objects.equals(fetch, revisionInfo.fetch)
+          && Objects.equals(commit, revisionInfo.commit)
+          && Objects.equals(files, revisionInfo.files)
+          && Objects.equals(actions, revisionInfo.actions)
+          && Objects.equals(commitWithFooters, revisionInfo.commitWithFooters)
+          && Objects.equals(pushCertificate, revisionInfo.pushCertificate)
+          && Objects.equals(description, revisionInfo.description);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        isCurrent,
+        kind,
+        _number,
+        created,
+        uploader,
+        ref,
+        fetch,
+        commit,
+        files,
+        actions,
+        commitWithFooters,
+        pushCertificate,
+        description);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/TrackingIdInfo.java b/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
index 0c5ed68..3d35e08 100644
--- a/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
+++ b/java/com/google/gerrit/extensions/common/TrackingIdInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class TrackingIdInfo {
   public String system;
   public String id;
@@ -22,4 +24,20 @@
     this.system = system;
     this.id = id;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof TrackingIdInfo) {
+      TrackingIdInfo trackingIdInfo = (TrackingIdInfo) o;
+      return Objects.equals(system, trackingIdInfo.system) && Objects.equals(id, trackingIdInfo.id);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(system, id);
+  }
+
+  protected TrackingIdInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/VotingRangeInfo.java b/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
index 5c35a49..2f7e9e4 100644
--- a/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/VotingRangeInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class VotingRangeInfo {
   public int min;
   public int max;
@@ -22,4 +24,18 @@
     this.min = min;
     this.max = max;
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof VotingRangeInfo) {
+      VotingRangeInfo votingRangeInfo = (VotingRangeInfo) o;
+      return min == votingRangeInfo.min && max == votingRangeInfo.max;
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(min, max);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/WebLinkInfo.java b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
index 84fd970..ba12be0 100644
--- a/java/com/google/gerrit/extensions/common/WebLinkInfo.java
+++ b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
@@ -64,4 +64,6 @@
         + target
         + "}";
   }
+
+  protected WebLinkInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/restapi/NeedsParams.java b/java/com/google/gerrit/extensions/restapi/NeedsParams.java
index c6e2151..bb4294f 100644
--- a/java/com/google/gerrit/extensions/restapi/NeedsParams.java
+++ b/java/com/google/gerrit/extensions/restapi/NeedsParams.java
@@ -19,7 +19,9 @@
 /**
  * Optional interface for {@link RestCollection}.
  *
- * <p>Collections that implement this interface can get to know about the request parameters.
+ * <p>Collections that implement this interface can get to know about the request parameters. The
+ * request parameters are passed only if the collection is the endpoint, e.g. {@code
+ * /changes/?q=abc} would trigger, but {@code /changes/100/?q=abc} does not.
  */
 public interface NeedsParams {
   /**
diff --git a/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
index b8c9d38..a3f156b 100644
--- a/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
+++ b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
@@ -22,4 +22,13 @@
   public PreconditionFailedException(String msg) {
     super(msg);
   }
+
+  /**
+   * @param msg message to return to the client describing the error.
+   * @cause original cause of the failed precondition.
+   */
+  public PreconditionFailedException(String msg, Throwable cause) {
+    super(msg);
+    initCause(cause);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/webui/FileWebLink.java b/java/com/google/gerrit/extensions/webui/FileWebLink.java
index c03d606..dc386b3 100644
--- a/java/com/google/gerrit/extensions/webui/FileWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/FileWebLink.java
@@ -32,8 +32,9 @@
    *
    * @param projectName Name of the project
    * @param revision Name of the revision (e.g. branch or commit ID)
+   * @param hash SHA-1 of the commit
    * @param fileName Name of the file
    * @return WebLinkInfo that links to project in external service, null if there should be no link.
    */
-  WebLinkInfo getFileWebLink(String projectName, String revision, String fileName);
+  WebLinkInfo getFileWebLink(String projectName, String revision, String hash, String fileName);
 }
diff --git a/java/com/google/gerrit/extensions/webui/ParentWebLink.java b/java/com/google/gerrit/extensions/webui/ParentWebLink.java
index dfc970d..9f2bc6e 100644
--- a/java/com/google/gerrit/extensions/webui/ParentWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/ParentWebLink.java
@@ -30,10 +30,13 @@
    *
    * <p>
    *
-   * @param projectName Name of the project
-   * @param commit Commit sha1 of the parent revision
+   * @param projectName name of the project
+   * @param commit commit sha1 of the parent revision
+   * @param commitMessage the commit messsage of the change
+   * @param branchName target branch of the change
    * @return WebLinkInfo that links to parent commit in external service, null if there should be no
    *     link.
    */
-  WebLinkInfo getParentWebLink(String projectName, String commit);
+  WebLinkInfo getParentWebLink(
+      String projectName, String commit, String commitMessage, String branchName);
 }
diff --git a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
index 93fe8e1..0e8e28e 100644
--- a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
@@ -30,10 +30,13 @@
    *
    * <p>
    *
-   * @param projectName Name of the project
-   * @param commit Commit of the patch set
+   * @param projectName name of the project
+   * @param commit commit of the patch set
+   * @param commitMessage the commit message of the change
+   * @param branchName target branch of the change
    * @return WebLinkInfo that links to patch set in external service, null if there should be no
    *     link.
    */
-  WebLinkInfo getPatchSetWebLink(String projectName, String commit);
+  WebLinkInfo getPatchSetWebLink(
+      String projectName, String commit, String commitMessage, String branchName);
 }
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index ee99702..cd3ebb9 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -32,7 +32,6 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:jgit-servlet",
-        "//lib:jsch",
         "//lib:servlet-api",
         "//lib:soy",
         "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 3ea73c3..f302095 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PropertyMap;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -162,15 +163,11 @@
   }
 
   @Override
-  public ExternalId.Key getLastLoginExternalId() {
-    return val != null ? val.getExternalId() : null;
-  }
-
-  @Override
   public CurrentUser getUser() {
     if (user == null) {
       if (isSignedIn()) {
-        user = identified.create(val.getAccountId());
+
+        user = identified.create(val.getAccountId(), getUserProperties(val));
       } else {
         user = anonymousProvider.get();
       }
@@ -178,6 +175,15 @@
     return user;
   }
 
+  private static PropertyMap getUserProperties(@Nullable WebSessionManager.Val val) {
+    if (val == null || val.getExternalId() == null) {
+      return PropertyMap.EMPTY;
+    }
+    return PropertyMap.builder()
+        .put(CurrentUser.LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY, val.getExternalId())
+        .build();
+  }
+
   @Override
   public void login(AuthResult res, boolean rememberMe) {
     Account.Id id = res.getAccountId();
@@ -195,7 +201,7 @@
     key = manager.createKey(id);
     val = manager.createVal(key, id, rememberMe, identity, null, null);
     saveCookie();
-    user = identified.create(val.getAccountId());
+    user = identified.create(val.getAccountId(), getUserProperties(val));
   }
 
   /** Set the user account for this current request only. */
@@ -203,7 +209,7 @@
   public void setUserAccountId(Account.Id id) {
     key = new Key("id:" + id);
     val = new Val(id, 0, false, null, 0, null, null);
-    user = identified.runAs(id, user);
+    user = identified.runAs(id, user, PropertyMap.EMPTY);
   }
 
   @Override
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 3e2d3ef..06f96c5 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -327,7 +327,7 @@
           if (user instanceof AnonymousUser) {
             throw new ServiceNotAuthorizedException();
           }
-          throw new ServiceNotEnabledException(e.getMessage());
+          throw new RepositoryNotFoundException(nameKey.get(), e);
         }
 
         return manager.openRepository(nameKey);
diff --git a/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java b/java/com/google/gerrit/httpd/GuiceRequestScopePropagator.java
similarity index 80%
rename from java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
rename to java/com/google/gerrit/httpd/GuiceRequestScopePropagator.java
index 99dd8bf..7c8094a 100644
--- a/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
+++ b/java/com/google/gerrit/httpd/GuiceRequestScopePropagator.java
@@ -12,11 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.util;
+package com.google.gerrit.httpd;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -29,21 +31,25 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.Callable;
+import javax.servlet.http.HttpServletRequest;
 
 /** Propagator for Guice's built-in servlet scope. */
 public class GuiceRequestScopePropagator extends RequestScopePropagator {
 
   private final String url;
   private final SocketAddress peer;
+  private final Provider<HttpServletRequest> request;
 
   @Inject
   GuiceRequestScopePropagator(
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
       @RemotePeer Provider<SocketAddress> remotePeerProvider,
-      ThreadLocalRequestContext local) {
+      ThreadLocalRequestContext local,
+      Provider<HttpServletRequest> request) {
     super(ServletScopes.REQUEST, local);
     this.url = urlProvider != null ? urlProvider.get() : null;
     this.peer = remotePeerProvider.get();
+    this.request = request;
   }
 
   /** @see RequestScopePropagator#wrap(Callable) */
@@ -64,6 +70,11 @@
     seedMap.put(Key.get(typeOfProvider(SocketAddress.class), RemotePeer.class), Providers.of(peer));
     seedMap.put(Key.get(SocketAddress.class, RemotePeer.class), peer);
 
+    Key<?> webSessionAttrKey = Key.get(WebSession.class);
+    Object webSessionAttrValue = request.get().getAttribute(webSessionAttrKey.toString());
+    seedMap.put(webSessionAttrKey, webSessionAttrValue);
+    seedMap.put(Key.get(typeOfProvider(WebSession.class)), Providers.of(webSessionAttrValue));
+
     return ServletScopes.continueRequest(callable, seedMap);
   }
 
diff --git a/java/com/google/gerrit/httpd/HttpLogoutServlet.java b/java/com/google/gerrit/httpd/HttpLogoutServlet.java
index 1eaaba3..b56f973 100644
--- a/java/com/google/gerrit/httpd/HttpLogoutServlet.java
+++ b/java/com/google/gerrit/httpd/HttpLogoutServlet.java
@@ -73,11 +73,10 @@
 
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-
-    final String sid = webSession.get().getSessionId();
-    final CurrentUser currentUser = webSession.get().getUser();
-    final String what = "sign out";
-    final long when = TimeUtil.nowMs();
+    String sid = webSession.get().getSessionId();
+    CurrentUser currentUser = webSession.get().getUser();
+    String what = "sign out";
+    long when = TimeUtil.nowMs();
 
     try {
       doLogout(req, rsp);
diff --git a/java/com/google/gerrit/httpd/RequestMetricsFilter.java b/java/com/google/gerrit/httpd/RequestMetricsFilter.java
index c7a7540..c97b9ad 100644
--- a/java/com/google/gerrit/httpd/RequestMetricsFilter.java
+++ b/java/com/google/gerrit/httpd/RequestMetricsFilter.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.httpd;
 
+import com.google.gerrit.metrics.proc.ThreadMXBeanFactory;
+import com.google.gerrit.metrics.proc.ThreadMXBeanInterface;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -30,6 +32,8 @@
 
 @Singleton
 public class RequestMetricsFilter implements Filter {
+  public static final String METRICS_CONTEXT = "metrics-context";
+
   public static Module module() {
     return new ServletModule() {
       @Override
@@ -39,6 +43,36 @@
     };
   }
 
+  public static class Context {
+    private static final ThreadMXBeanInterface threadMxBean = ThreadMXBeanFactory.create();
+    private final long startedTotalCpu;
+    private final long startedUserCpu;
+    private final long startedMemory;
+
+    Context() {
+      startedTotalCpu = threadMxBean.getCurrentThreadCpuTime();
+      startedUserCpu = threadMxBean.getCurrentThreadUserTime();
+      startedMemory = threadMxBean.getCurrentThreadAllocatedBytes();
+    }
+
+    /** @return total CPU time in milliseconds for executing request */
+    public long getTotalCpuTime() {
+      return (threadMxBean.getCurrentThreadCpuTime() - startedTotalCpu) / 1_000_000;
+    }
+
+    /** @return CPU time in user mode in milliseconds for executing request */
+    public long getUserCpuTime() {
+      return (threadMxBean.getCurrentThreadUserTime() - startedUserCpu) / 1_000_000;
+    }
+
+    /** @return memory allocated in bytes for executing request */
+    public long getAllocatedMemory() {
+      return startedMemory == -1
+          ? -1
+          : threadMxBean.getCurrentThreadAllocatedBytes() - startedMemory;
+    }
+  }
+
   private final RequestMetrics metrics;
 
   @Inject
@@ -52,6 +86,7 @@
   @Override
   public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
       throws IOException, ServletException {
+    request.setAttribute(METRICS_CONTEXT, new Context());
     Response rsp = new Response((HttpServletResponse) response, metrics);
 
     chain.doFilter(request, rsp);
diff --git a/java/com/google/gerrit/httpd/WebModule.java b/java/com/google/gerrit/httpd/WebModule.java
index e416075..5e3c76a 100644
--- a/java/com/google/gerrit/httpd/WebModule.java
+++ b/java/com/google/gerrit/httpd/WebModule.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
-import com.google.gerrit.server.util.GuiceRequestScopePropagator;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
diff --git a/java/com/google/gerrit/httpd/WebSession.java b/java/com/google/gerrit/httpd/WebSession.java
index e8b54fe..daf30ff 100644
--- a/java/com/google/gerrit/httpd/WebSession.java
+++ b/java/com/google/gerrit/httpd/WebSession.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.externalids.ExternalId;
 
 public interface WebSession {
   boolean isSignedIn();
@@ -29,8 +28,6 @@
 
   boolean isValidXGerritAuth(String keyIn);
 
-  ExternalId.Key getLastLoginExternalId();
-
   CurrentUser getUser();
 
   void login(AuthResult res, boolean rememberMe);
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 509a9f1..e20c9b9 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -35,6 +35,7 @@
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.Locale;
+import java.util.Optional;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -124,8 +125,8 @@
   }
 
   private static boolean correctUser(String user, WebSession session) {
-    ExternalId.Key id = session.getLastLoginExternalId();
-    return id != null && id.equals(ExternalId.Key.create(SCHEME_GERRIT, user));
+    Optional<ExternalId.Key> id = session.getUser().getLastLoginExternalIdKey();
+    return id.map(i -> i.equals(ExternalId.Key.create(SCHEME_GERRIT, user))).orElse(false);
   }
 
   String getRemoteUser(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/httpd/auth/oauth/BUILD b/java/com/google/gerrit/httpd/auth/oauth/BUILD
index 11c9295..2bc65de4 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/BUILD
+++ b/java/com/google/gerrit/httpd/auth/oauth/BUILD
@@ -7,6 +7,7 @@
     resources = ["//resources/com/google/gerrit/httpd/auth/oauth"],
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index ea0c148..70ed79b 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
@@ -35,7 +36,6 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.servlet.SessionScoped;
diff --git a/java/com/google/gerrit/httpd/auth/restapi/BUILD b/java/com/google/gerrit/httpd/auth/restapi/BUILD
new file mode 100644
index 0000000..d499768
--- /dev/null
+++ b/java/com/google/gerrit/httpd/auth/restapi/BUILD
@@ -0,0 +1,14 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "restapi",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/auth",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
+        "//lib/flogger:api",
+        "//lib/guice",
+    ],
+)
diff --git a/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
similarity index 96%
rename from java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
rename to java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
index 24682c0..3594c7c 100644
--- a/java/com/google/gerrit/server/restapi/account/GetOAuthToken.java
+++ b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
@@ -12,9 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.restapi.account;
+package com.google.gerrit.httpd.auth.restapi;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -22,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/java/com/google/gerrit/httpd/auth/restapi/OAuthRestModule.java b/java/com/google/gerrit/httpd/auth/restapi/OAuthRestModule.java
new file mode 100644
index 0000000..508ad89
--- /dev/null
+++ b/java/com/google/gerrit/httpd/auth/restapi/OAuthRestModule.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.auth.restapi;
+
+import static com.google.gerrit.server.account.AccountResource.ACCOUNT_KIND;
+
+import com.google.gerrit.extensions.restapi.RestApiModule;
+
+public class OAuthRestModule extends RestApiModule {
+  @Override
+  protected void configure() {
+    get(ACCOUNT_KIND, "oauthtoken").to(GetOAuthToken.class);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index 222041a..adfbdcc 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -5,12 +5,14 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/httpd/auth/oauth",
         "//java/com/google/gerrit/httpd/auth/openid",
+        "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/lucene",
@@ -28,6 +30,7 @@
         "//java/com/google/gerrit/sshd",
         "//lib:guava",
         "//lib:jgit",
+        "//lib:jgit-ssh-apache",
         "//lib:servlet-api",
         "//lib/flogger:api",
         "//lib/guice",
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index e4a86f5..2ed342f 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Splitter;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.auth.AuthModule;
 import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
@@ -36,6 +37,7 @@
 import com.google.gerrit.httpd.WebSshGlueModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
+import com.google.gerrit.httpd.auth.restapi.OAuthRestModule;
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
 import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.index.IndexType;
@@ -102,6 +104,7 @@
 import com.google.gerrit.sshd.SshHostKeyModule;
 import com.google.gerrit.sshd.SshKeyCacheImpl;
 import com.google.gerrit.sshd.SshModule;
+import com.google.gerrit.sshd.SshSessionFactoryInitializer;
 import com.google.gerrit.sshd.commands.DefaultCommandModule;
 import com.google.gerrit.sshd.commands.IndexCommandsModule;
 import com.google.gerrit.sshd.commands.SequenceCommandsModule;
@@ -323,7 +326,7 @@
     if (VersionManager.getOnlineUpgrade(config)) {
       modules.add(new OnlineUpgrader.Module());
     }
-
+    modules.add(new OAuthRestModule());
     modules.add(new RestApiModule());
     modules.add(new SubscriptionGraph.Module());
     modules.add(new SuperprojectUpdateSubmissionListener.Module());
@@ -338,6 +341,7 @@
         });
     modules.add(new DefaultUrlFormatter.Module());
 
+    SshSessionFactoryInitializer.init(config);
     modules.add(SshKeyCacheImpl.module());
     modules.add(
         new AbstractModule() {
@@ -409,6 +413,8 @@
     } else if (authConfig.getAuthType() == AuthType.OAUTH) {
       modules.add(new OAuthModule());
     }
+    modules.add(new AuthModule(authConfig));
+
     modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
 
     // StaticModule contains a "/*" wildcard, place it last.
diff --git a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
index d92da18..e3e96df 100644
--- a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
+++ b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
@@ -43,8 +43,8 @@
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
     CacheHeaders.setNotCacheable(res);
-    res.setContentLength(0);
     if (user.get().isIdentifiedUser()) {
+      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 46dde41..8d52f5a 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -20,7 +20,6 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
@@ -31,6 +30,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gson.Gson;
 import com.google.template.soy.data.SanitizedContent;
 import java.net.URI;
@@ -38,21 +38,15 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
-import org.eclipse.jgit.lib.Config;
 
 /** Helper for generating parts of {@code index.html}. */
 @UsedAt(Project.GOOGLE)
 public class IndexHtmlUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  static final ImmutableSet<String> DEFAULT_EXPERIMENTS =
-      ImmutableSet.of(
-          "UiFeature__patchset_comments", "UiFeature__patchset_choice_for_comment_links");
-
   private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
   /**
    * Returns both static and dynamic parameters of {@code index.html}. The result is to be used when
@@ -60,7 +54,7 @@
    */
   public static ImmutableMap<String, Object> templateData(
       GerritApi gerritApi,
-      Config gerritServerConfig,
+      ExperimentFeatures experimentFeatures,
       String canonicalURL,
       String cdnPath,
       String faviconPath,
@@ -73,14 +67,8 @@
             staticTemplateData(
                 canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
         .putAll(dynamicTemplateData(gerritApi, requestedURL));
+    Set<String> enabledExperiments = experimentFeatures.getEnabledExperimentFeatures();
 
-    Set<String> enabledExperiments = new HashSet<>();
-    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "enabled"))
-        .forEach(enabledExperiments::add);
-    DEFAULT_EXPERIMENTS.forEach(enabledExperiments::add);
-    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "disabled"))
-        .forEach(enabledExperiments::remove);
-    experimentData(urlParameterMap).forEach(enabledExperiments::add);
     if (!enabledExperiments.isEmpty()) {
       data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
     }
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index b2bdf7c..3f2c202 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.template.soy.SoyFileSet;
 import com.google.template.soy.data.SanitizedContent;
 import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
@@ -34,7 +35,6 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.lib.Config;
 
 public class IndexServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
@@ -43,7 +43,7 @@
   @Nullable private final String cdnPath;
   @Nullable private final String faviconPath;
   private final GerritApi gerritApi;
-  private final Config gerritServerConfig;
+  private final ExperimentFeatures experimentFeatures;
   private final SoySauce soySauce;
   private final Function<String, SanitizedContent> urlOrdainer;
 
@@ -52,12 +52,12 @@
       @Nullable String cdnPath,
       @Nullable String faviconPath,
       GerritApi gerritApi,
-      Config gerritServerConfig) {
+      ExperimentFeatures experimentFeatures) {
     this.canonicalUrl = canonicalUrl;
     this.cdnPath = cdnPath;
     this.faviconPath = faviconPath;
     this.gerritApi = gerritApi;
-    this.gerritServerConfig = gerritServerConfig;
+    this.experimentFeatures = experimentFeatures;
     this.soySauce =
         SoyFileSet.builder()
             .add(Resources.getResource("com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy"))
@@ -79,7 +79,7 @@
       ImmutableMap<String, Object> templateData =
           IndexHtmlUtil.templateData(
               gerritApi,
-              gerritServerConfig,
+              experimentFeatures,
               canonicalUrl,
               cdnPath,
               faviconPath,
diff --git a/java/com/google/gerrit/httpd/raw/SshInfoServlet.java b/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
index 1605360..ec67b8b 100644
--- a/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
+++ b/java/com/google/gerrit/httpd/raw/SshInfoServlet.java
@@ -16,11 +16,11 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.server.ssh.HostKey;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import com.jcraft.jsch.HostKey;
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.util.List;
@@ -59,14 +59,14 @@
 
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    final List<HostKey> hostKeys = sshd.getHostKeys();
-    final String out;
+    List<HostKey> hostKeys = sshd.getHostKeys();
+    String out;
     if (!hostKeys.isEmpty()) {
       String host = hostKeys.get(0).getHost();
       String port = "22";
 
       if (host.contains(":")) {
-        final int p = host.lastIndexOf(':');
+        int p = host.lastIndexOf(':');
         port = host.substring(p + 1);
         host = host.substring(0, p);
       }
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 13327ca1..bb1eb92 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.inject.Inject;
 import com.google.inject.Key;
 import com.google.inject.Provides;
@@ -222,11 +223,12 @@
     HttpServlet getPolyGerritUiIndexServlet(
         @CanonicalWebUrl @Nullable String canonicalUrl,
         @GerritServerConfig Config cfg,
-        GerritApi gerritApi) {
+        GerritApi gerritApi,
+        ExperimentFeatures experimentFeatures) {
       String cdnPath =
           options.useDevCdn() ? options.devCdn() : cfg.getString("gerrit", null, "cdnPath");
       String faviconPath = cfg.getString("gerrit", null, "faviconPath");
-      return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, cfg);
+      return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, experimentFeatures);
     }
 
     @Provides
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 172321d..3ab409e 100644
--- a/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -32,7 +32,6 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.Url;
@@ -44,7 +43,7 @@
 import com.google.gson.JsonObject;
 import com.google.gson.JsonPrimitive;
 import com.google.inject.Inject;
-import com.google.inject.Injector;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.StringWriter;
 import java.util.HashSet;
@@ -149,24 +148,32 @@
   }
 
   private final CmdLineParser.Factory parserFactory;
-  private final Injector injector;
-  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
-  ParameterParser(
-      CmdLineParser.Factory pf,
-      Injector injector,
-      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+  ParameterParser(CmdLineParser.Factory pf) {
     this.parserFactory = pf;
-    this.injector = injector;
-    this.dynamicBeans = dynamicBeans;
   }
 
+  /**
+   * Parses query parameters ({@code in}) into annotated option fields of {@code param}.
+   *
+   * @return true if parsing was successful. Requesting help is considered failure and returns
+   *     false.
+   */
   <T> boolean parse(
-      T param, ListMultimap<String, String> in, HttpServletRequest req, HttpServletResponse res)
+      T param,
+      DynamicOptions pluginOptions,
+      ListMultimap<String, String> in,
+      HttpServletRequest req,
+      HttpServletResponse res)
       throws IOException {
+    if (param.getClass().getAnnotation(Singleton.class) != null) {
+      // Command-line parsing mutates the object, so we can't have options on @Singleton.
+      return true;
+    }
     CmdLineParser clp = parserFactory.create(param);
-    DynamicOptions pluginOptions = new DynamicOptions(param, injector, dynamicBeans);
+    pluginOptions.setBean(param);
+    pluginOptions.startLifecycleListeners();
     pluginOptions.parseDynamicBeans(clp);
     pluginOptions.setDynamicBeans();
     pluginOptions.onBeanParseStart();
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index d427caa..9b86a4f 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -102,6 +102,7 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.OptionUtil;
 import com.google.gerrit.server.RequestInfo;
@@ -144,6 +145,7 @@
 import com.google.gson.stream.JsonWriter;
 import com.google.gson.stream.MalformedJsonException;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
@@ -248,6 +250,8 @@
     final ChangeFinder changeFinder;
     final RetryHelper retryHelper;
     final PluginSetContext<ExceptionHook> exceptionHooks;
+    final Injector injector;
+    final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
     @Inject
     Globals(
@@ -263,7 +267,9 @@
         DynamicSet<PerformanceLogger> performanceLoggers,
         ChangeFinder changeFinder,
         RetryHelper retryHelper,
-        PluginSetContext<ExceptionHook> exceptionHooks) {
+        PluginSetContext<ExceptionHook> exceptionHooks,
+        Injector injector,
+        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
@@ -278,6 +284,8 @@
       this.retryHelper = retryHelper;
       this.exceptionHooks = exceptionHooks;
       allowOrigin = makeAllowOrigin(config);
+      this.injector = injector;
+      this.dynamicBeans = dynamicBeans;
     }
 
     private static Pattern makeAllowOrigin(Config cfg) {
@@ -496,105 +504,116 @@
             return;
           }
 
-          if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
-            return;
+          try (DynamicOptions pluginOptions =
+              new DynamicOptions(globals.injector, globals.dynamicBeans)) {
+            if (!globals
+                .paramParser
+                .get()
+                .parse(viewData.view, pluginOptions, qp.params(), req, res)) {
+              return;
+            }
+
+            if (viewData.view instanceof RestReadView<?> && isRead(req)) {
+              response =
+                  invokeRestReadViewWithRetry(
+                      req,
+                      traceContext,
+                      viewData,
+                      (RestReadView<RestResource>) viewData.view,
+                      rsrc);
+            } else if (viewData.view instanceof RestModifyView<?, ?>) {
+              @SuppressWarnings("unchecked")
+              RestModifyView<RestResource, Object> m =
+                  (RestModifyView<RestResource, Object>) viewData.view;
+
+              Type type = inputType(m);
+              inputRequestBody = parseRequest(req, type);
+              response =
+                  invokeRestModifyViewWithRetry(
+                      req, traceContext, viewData, m, rsrc, inputRequestBody);
+
+              if (inputRequestBody instanceof RawInput) {
+                try (InputStream is = req.getInputStream()) {
+                  ServletUtils.consumeRequestBody(is);
+                }
+              }
+            } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
+              @SuppressWarnings("unchecked")
+              RestCollectionCreateView<RestResource, RestResource, Object> m =
+                  (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
+
+              Type type = inputType(m);
+              inputRequestBody = parseRequest(req, type);
+              response =
+                  invokeRestCollectionCreateViewWithRetry(
+                      req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
+              if (inputRequestBody instanceof RawInput) {
+                try (InputStream is = req.getInputStream()) {
+                  ServletUtils.consumeRequestBody(is);
+                }
+              }
+            } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
+              @SuppressWarnings("unchecked")
+              RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
+                  (RestCollectionDeleteMissingView<RestResource, RestResource, Object>)
+                      viewData.view;
+
+              Type type = inputType(m);
+              inputRequestBody = parseRequest(req, type);
+              response =
+                  invokeRestCollectionDeleteMissingViewWithRetry(
+                      req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
+              if (inputRequestBody instanceof RawInput) {
+                try (InputStream is = req.getInputStream()) {
+                  ServletUtils.consumeRequestBody(is);
+                }
+              }
+            } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
+              @SuppressWarnings("unchecked")
+              RestCollectionModifyView<RestResource, RestResource, Object> m =
+                  (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
+
+              Type type = inputType(m);
+              inputRequestBody = parseRequest(req, type);
+              response =
+                  invokeRestCollectionModifyViewWithRetry(
+                      req, traceContext, viewData, m, rsrc, inputRequestBody);
+              if (inputRequestBody instanceof RawInput) {
+                try (InputStream is = req.getInputStream()) {
+                  ServletUtils.consumeRequestBody(is);
+                }
+              }
+            } else {
+              throw new ResourceNotFoundException();
+            }
+
+            if (response instanceof Response.Redirect) {
+              CacheHeaders.setNotCacheable(res);
+              String location = ((Response.Redirect) response).location();
+              res.sendRedirect(location);
+              logger.atFinest().log("REST call redirected to: %s", location);
+              return;
+            } else if (response instanceof Response.Accepted) {
+              CacheHeaders.setNotCacheable(res);
+              res.setStatus(response.statusCode());
+              res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) response).location());
+              logger.atFinest().log("REST call succeeded: %d", response.statusCode());
+              return;
+            }
+
+            statusCode = response.statusCode();
+            configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
+            res.setStatus(statusCode);
+            logger.atFinest().log("REST call succeeded: %d", statusCode);
           }
 
-          if (viewData.view instanceof RestReadView<?> && isRead(req)) {
-            response =
-                invokeRestReadViewWithRetry(
-                    req, traceContext, viewData, (RestReadView<RestResource>) viewData.view, rsrc);
-          } else if (viewData.view instanceof RestModifyView<?, ?>) {
-            @SuppressWarnings("unchecked")
-            RestModifyView<RestResource, Object> m =
-                (RestModifyView<RestResource, Object>) viewData.view;
-
-            Type type = inputType(m);
-            inputRequestBody = parseRequest(req, type);
-            response =
-                invokeRestModifyViewWithRetry(
-                    req, traceContext, viewData, m, rsrc, inputRequestBody);
-
-            if (inputRequestBody instanceof RawInput) {
-              try (InputStream is = req.getInputStream()) {
-                ServletUtils.consumeRequestBody(is);
-              }
+          if (response != Response.none()) {
+            Object value = Response.unwrap(response);
+            if (value instanceof BinaryResult) {
+              responseBytes = replyBinaryResult(req, res, (BinaryResult) value);
+            } else {
+              responseBytes = replyJson(req, res, false, qp.config(), value);
             }
-          } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
-            @SuppressWarnings("unchecked")
-            RestCollectionCreateView<RestResource, RestResource, Object> m =
-                (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
-
-            Type type = inputType(m);
-            inputRequestBody = parseRequest(req, type);
-            response =
-                invokeRestCollectionCreateViewWithRetry(
-                    req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
-            if (inputRequestBody instanceof RawInput) {
-              try (InputStream is = req.getInputStream()) {
-                ServletUtils.consumeRequestBody(is);
-              }
-            }
-          } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
-            @SuppressWarnings("unchecked")
-            RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
-                (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
-
-            Type type = inputType(m);
-            inputRequestBody = parseRequest(req, type);
-            response =
-                invokeRestCollectionDeleteMissingViewWithRetry(
-                    req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
-            if (inputRequestBody instanceof RawInput) {
-              try (InputStream is = req.getInputStream()) {
-                ServletUtils.consumeRequestBody(is);
-              }
-            }
-          } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
-            @SuppressWarnings("unchecked")
-            RestCollectionModifyView<RestResource, RestResource, Object> m =
-                (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
-
-            Type type = inputType(m);
-            inputRequestBody = parseRequest(req, type);
-            response =
-                invokeRestCollectionModifyViewWithRetry(
-                    req, traceContext, viewData, m, rsrc, inputRequestBody);
-            if (inputRequestBody instanceof RawInput) {
-              try (InputStream is = req.getInputStream()) {
-                ServletUtils.consumeRequestBody(is);
-              }
-            }
-          } else {
-            throw new ResourceNotFoundException();
-          }
-
-          if (response instanceof Response.Redirect) {
-            CacheHeaders.setNotCacheable(res);
-            String location = ((Response.Redirect) response).location();
-            res.sendRedirect(location);
-            logger.atFinest().log("REST call redirected to: %s", location);
-            return;
-          } else if (response instanceof Response.Accepted) {
-            CacheHeaders.setNotCacheable(res);
-            res.setStatus(response.statusCode());
-            res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) response).location());
-            logger.atFinest().log("REST call succeeded: %d", response.statusCode());
-            return;
-          }
-
-          statusCode = response.statusCode();
-          configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
-          res.setStatus(statusCode);
-          logger.atFinest().log("REST call succeeded: %d", statusCode);
-        }
-
-        if (response != Response.none()) {
-          Object value = Response.unwrap(response);
-          if (value instanceof BinaryResult) {
-            responseBytes = replyBinaryResult(req, res, (BinaryResult) value);
-          } else {
-            responseBytes = replyJson(req, res, false, qp.config(), value);
           }
         }
       } catch (MalformedJsonException | JsonParseException e) {
@@ -1633,9 +1652,6 @@
           "Invalid authentication method. In order to authenticate, "
               + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
     }
-    if (user.isIdentifiedUser()) {
-      user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
-    }
   }
 
   private List<String> getParameterNames(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/index/RefState.java b/java/com/google/gerrit/index/RefState.java
index 956dcab..ed38de9 100644
--- a/java/com/google/gerrit/index/RefState.java
+++ b/java/com/google/gerrit/index/RefState.java
@@ -20,8 +20,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.git.ObjectIds;
@@ -33,10 +32,10 @@
 
 @AutoValue
 public abstract class RefState {
-  public static SetMultimap<Project.NameKey, RefState> parseStates(Iterable<byte[]> states) {
+  public static ImmutableSetMultimap<Project.NameKey, RefState> parseStates(
+      Iterable<byte[]> states) {
     RefState.check(states != null, null);
-    SetMultimap<Project.NameKey, RefState> result =
-        MultimapBuilder.hashKeys().hashSetValues().build();
+    ImmutableSetMultimap.Builder<Project.NameKey, RefState> result = ImmutableSetMultimap.builder();
     for (byte[] b : states) {
       RefState.check(b != null, null);
       String s = new String(b, UTF_8);
@@ -44,7 +43,7 @@
       RefState.check(parts.size() == 3 && !parts.get(0).isEmpty() && !parts.get(1).isEmpty(), s);
       result.put(Project.nameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
     }
-    return result;
+    return result.build();
   }
 
   public static RefState create(String ref, String sha) {
diff --git a/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
index 38b2b73..42f8aa8 100644
--- a/java/com/google/gerrit/index/query/TimestampRangePredicate.java
+++ b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
@@ -34,6 +34,10 @@
     super(def, name, value);
   }
 
+  protected Timestamp getValueTimestamp(I object) {
+    return (Timestamp) this.getField().get(object);
+  }
+
   public abstract Date getMinTimestamp();
 
   public abstract Date getMaxTimestamp();
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 360331a..475dac4 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.gerrit.lucene.LuceneChangeIndex.ID2_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.ID_SORT_FIELD;
+import static com.google.gerrit.lucene.LuceneChangeIndex.MERGED_ON_SORT_FIELD;
 import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
 import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
 
@@ -128,6 +129,9 @@
     } else if (f == ChangeField.UPDATED) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
+    } else if (f == ChangeField.MERGED_ON) {
+      long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
+      doc.add(new NumericDocValuesField(MERGED_ON_SORT_FIELD, t));
     }
     super.add(doc, values);
   }
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index c162c77..b1141d9 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
@@ -73,6 +74,7 @@
 import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Iterator;
@@ -111,6 +113,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
+  static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON);
   static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
   static final String ID2_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
 
@@ -141,6 +144,7 @@
   private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
       ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
   private static final String ATTENTION_SET_FULL_FIELD = ChangeField.ATTENTION_SET_FULL.getName();
+  private static final String MERGED_ON_FIELD = ChangeField.MERGED_ON.getName();
 
   @FunctionalInterface
   static interface IdTerm {
@@ -342,6 +346,7 @@
   private Sort getSort() {
     return new Sort(
         new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true),
+        new SortField(MERGED_ON_SORT_FIELD, SortField.Type.LONG, true),
         new SortField(idSortFieldName, SortField.Type.LONG, true));
   }
 
@@ -585,6 +590,9 @@
     if (fields.contains(REF_STATE_PATTERN_FIELD)) {
       decodeRefStatePatterns(doc, cd);
     }
+    if (fields.contains(MERGED_ON_FIELD)) {
+      decodeMergedOn(doc, cd);
+    }
 
     decodeUnresolvedCommentCount(doc, cd);
     decodeTotalCommentCount(doc, cd);
@@ -717,7 +725,7 @@
   }
 
   private void decodeRefStates(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setRefStates(copyAsBytes(doc.get(REF_STATE_FIELD)));
+    cd.setRefStates(RefState.parseStates(copyAsBytes(doc.get(REF_STATE_FIELD))));
   }
 
   private void decodeRefStatePatterns(ListMultimap<String, IndexableField> doc, ChangeData cd) {
@@ -741,6 +749,16 @@
     }
   }
 
+  private void decodeMergedOn(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    IndexableField mergedOnField =
+        Iterables.getFirst(doc.get(MERGED_ON_FIELD), /* defaultValue= */ null);
+    Timestamp mergedOn = null;
+    if (mergedOnField != null && mergedOnField.numericValue() != null) {
+      mergedOn = new Timestamp(mergedOnField.numericValue().longValue());
+    }
+    cd.setMergedOn(mergedOn);
+  }
+
   private static <T> List<T> decodeProtos(
       ListMultimap<String, IndexableField> doc, String fieldName, ProtoConverter<?, T> converter) {
     return doc.get(fieldName).stream()
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index 9f8b62e..f7a2248 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
@@ -30,7 +31,6 @@
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.options.AutoFlush;
diff --git a/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
index 20ac8fa..09e40c1 100644
--- a/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
+++ b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -27,6 +27,7 @@
 import java.lang.management.GarbageCollectorMXBean;
 import java.lang.management.ManagementFactory;
 import java.lang.management.MemoryMXBean;
+import java.lang.management.MemoryPoolMXBean;
 import java.lang.management.MemoryUsage;
 import java.lang.management.OperatingSystemMXBean;
 import java.lang.management.ThreadMXBean;
@@ -41,6 +42,7 @@
     procCpuLoad(metrics);
     procJvmGc(metrics);
     procJvmMemory(metrics);
+    procJvmMemoryPool(metrics);
     procJvmThread(metrics);
   }
 
@@ -167,6 +169,50 @@
         });
   }
 
+  private void procJvmMemoryPool(MetricMaker metrics) {
+    Field<String> poolName =
+        Field.ofString("pool_name", Metadata.Builder::memoryPoolName)
+            .description("The name of the memory pool")
+            .build();
+
+    CallbackMetric1<String, Long> committed =
+        metrics.newCallbackMetric(
+            "proc/jvm/memory/pool/committed",
+            Long.class,
+            new Description("Committed pool size").setUnit(Units.BYTES),
+            poolName);
+
+    CallbackMetric1<String, Long> max =
+        metrics.newCallbackMetric(
+            "proc/jvm/memory/pool/max",
+            Long.class,
+            new Description("Max pool size").setUnit(Units.BYTES),
+            poolName);
+
+    CallbackMetric1<String, Long> used =
+        metrics.newCallbackMetric(
+            "proc/jvm/memory/pool/used",
+            Long.class,
+            new Description("Used pool size").setUnit(Units.BYTES),
+            poolName);
+
+    metrics.newTrigger(
+        committed,
+        max,
+        used,
+        () -> {
+          for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) {
+            if (!pool.isValid()) {
+              continue;
+            }
+            MemoryUsage u = pool.getUsage();
+            committed.set(pool.getName(), u.getCommitted());
+            max.set(pool.getName(), u.getMax());
+            used.set(pool.getName(), u.getUsed());
+          }
+        });
+  }
+
   private void procJvmGc(MetricMaker metrics) {
     Field<String> gcNameField =
         Field.ofString("gc_name", Metadata.Builder::garbageCollectorName)
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanFactory.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanFactory.java
new file mode 100644
index 0000000..1e0c4f0
--- /dev/null
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanFactory.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.proc;
+
+import java.lang.management.ManagementFactory;
+import java.lang.management.ThreadMXBean;
+
+public class ThreadMXBeanFactory {
+
+  private ThreadMXBeanFactory() {}
+
+  public static ThreadMXBeanInterface create() {
+    ThreadMXBean sys = ManagementFactory.getThreadMXBean();
+    if (sys instanceof com.sun.management.ThreadMXBean) {
+      return new ThreadMXBeanSun(sys);
+    }
+    return new ThreadMXBeanJava(sys);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java
similarity index 62%
rename from java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
rename to java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java
index 08d6ce7..546924f 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -11,13 +11,12 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+package com.google.gerrit.metrics.proc;
 
-package com.google.gerrit.server.change;
+public interface ThreadMXBeanInterface {
+  long getCurrentThreadCpuTime();
 
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.List;
+  long getCurrentThreadUserTime();
 
-public interface PluginDefinedAttributesFactory {
-  List<PluginDefinedInfo> create(ChangeData cd);
+  long getCurrentThreadAllocatedBytes();
 }
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java
new file mode 100644
index 0000000..29dd42a
--- /dev/null
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.proc;
+
+import java.lang.management.ThreadMXBean;
+
+class ThreadMXBeanJava implements ThreadMXBeanInterface {
+  private final ThreadMXBean sys;
+
+  ThreadMXBeanJava(ThreadMXBean sys) {
+    this.sys = sys;
+  }
+
+  @Override
+  public long getCurrentThreadCpuTime() {
+    return sys.getCurrentThreadCpuTime();
+  }
+
+  @Override
+  public long getCurrentThreadUserTime() {
+    return sys.getCurrentThreadUserTime();
+  }
+
+  @Override
+  public long getCurrentThreadAllocatedBytes() {
+    return -1;
+  }
+}
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
new file mode 100644
index 0000000..9e43a05
--- /dev/null
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.proc;
+
+import com.sun.management.ThreadMXBean;
+
+class ThreadMXBeanSun implements ThreadMXBeanInterface {
+  private final ThreadMXBean sys;
+
+  ThreadMXBeanSun(java.lang.management.ThreadMXBean sys) {
+    this.sys = (ThreadMXBean) sys;
+  }
+
+  @Override
+  public long getCurrentThreadCpuTime() {
+    return sys.getCurrentThreadCpuTime();
+  }
+
+  @Override
+  public long getCurrentThreadUserTime() {
+    return sys.getCurrentThreadUserTime();
+  }
+
+  @Override
+  public long getCurrentThreadAllocatedBytes() {
+    // TODO(ms): call getCurrentThreadAllocatedBytes as soon as this is available in the patched
+    // Java version used by bazel
+    return sys.getThreadAllocatedBytes(Thread.currentThread().getId());
+  }
+}
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 2b1b83d..387ff2d 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -7,6 +7,7 @@
     resources = ["//resources/com/google/gerrit/pgm"],
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/elasticsearch",
@@ -17,6 +18,7 @@
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/httpd/auth/oauth",
         "//java/com/google/gerrit/httpd/auth/openid",
+        "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/launcher",
@@ -42,6 +44,7 @@
         "//lib:args4j",
         "//lib:guava",
         "//lib:jgit",
+        "//lib:jgit-ssh-apache",
         "//lib:protobuf",
         "//lib:servlet-api-without-neverlink",
         "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 5f675bcc..266eb6f 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -21,6 +21,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.auth.AuthModule;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
@@ -40,6 +41,7 @@
 import com.google.gerrit.httpd.WebSshGlueModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
+import com.google.gerrit.httpd.auth.restapi.OAuthRestModule;
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
 import com.google.gerrit.httpd.raw.StaticModule;
 import com.google.gerrit.index.IndexType;
@@ -113,6 +115,7 @@
 import com.google.gerrit.sshd.SshHostKeyModule;
 import com.google.gerrit.sshd.SshKeyCacheImpl;
 import com.google.gerrit.sshd.SshModule;
+import com.google.gerrit.sshd.SshSessionFactoryInitializer;
 import com.google.gerrit.sshd.commands.DefaultCommandModule;
 import com.google.gerrit.sshd.commands.IndexCommandsModule;
 import com.google.gerrit.sshd.commands.SequenceCommandsModule;
@@ -457,6 +460,7 @@
     if (VersionManager.getOnlineUpgrade(config)) {
       modules.add(new OnlineUpgrader.Module());
     }
+    modules.add(new OAuthRestModule());
     modules.add(new RestApiModule());
     modules.add(new GpgModule(config));
     modules.add(new StartupChecks.Module());
@@ -480,6 +484,7 @@
           });
     }
     modules.add(new DefaultUrlFormatter.Module());
+    SshSessionFactoryInitializer.init(config);
     if (sshd) {
       modules.add(SshKeyCacheImpl.module());
     } else {
@@ -511,6 +516,9 @@
     List<Module> libModules = LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE);
     libModules.addAll(testSysModules);
 
+    AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
+    modules.add(new AuthModule(authConfig));
+
     return cfgInjector.createChildInjector(ModuleOverloader.override(modules, libModules));
   }
 
@@ -595,6 +603,7 @@
     } else if (authConfig.getAuthType() == AuthType.OAUTH) {
       modules.add(new OAuthModule());
     }
+
     modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
 
     // StaticModule contains a "/*" wildcard, place it last.
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index 19d19d4..4c7b47b 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -92,6 +92,9 @@
   @Option(name = "--skip-download", usage = "Don't download given library")
   private List<String> skippedDownloads;
 
+  @Option(name = "--reindex-threads", usage = "Number of threads to use for reindex after init")
+  private int reindexThreads = 1;
+
   @Inject Browser browser;
 
   private GerritIndexStatus indexStatus;
@@ -283,7 +286,7 @@
     }
     List<String> reindexArgs =
         Lists.newArrayList(
-            "--site-path", getSitePath().toString(), "--threads", Integer.toString(1));
+            "--site-path", getSitePath().toString(), "--threads", Integer.toString(reindexThreads));
     for (String index : indices) {
       reindexArgs.add("--index");
       reindexArgs.add(index);
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLog.java b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
index 27a53c2..e88bb88 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.httpd.GetUserFilter;
+import com.google.gerrit.httpd.RequestMetricsFilter;
 import com.google.gerrit.httpd.restapi.LogRedactUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.util.SystemLog;
@@ -53,6 +54,9 @@
   protected static final String P_LATENCY = "Latency";
   protected static final String P_REFERER = "Referer";
   protected static final String P_USER_AGENT = "User-Agent";
+  protected static final String P_CPU_TOTAL = "Cpu-Total";
+  protected static final String P_CPU_USER = "Cpu-User";
+  protected static final String P_MEMORY = "Memory";
   protected static final String P_COMMAND_STATUS = "Command-Status";
 
   private final AsyncAppender async;
@@ -118,6 +122,14 @@
     set(event, P_USER_AGENT, req.getHeader("User-Agent"));
     set(event, P_COMMAND_STATUS, rsp.getHeader(GIT_COMMAND_STATUS_HEADER));
 
+    RequestMetricsFilter.Context ctx =
+        (RequestMetricsFilter.Context) req.getAttribute(RequestMetricsFilter.METRICS_CONTEXT);
+    if (ctx != null) {
+      set(event, P_CPU_TOTAL, ctx.getTotalCpuTime());
+      set(event, P_CPU_USER, ctx.getUserCpuTime());
+      set(event, P_MEMORY, ctx.getAllocatedMemory());
+    }
+
     async.append(event);
   }
 
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
index 807b311..54c587b 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
@@ -16,8 +16,11 @@
 
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_COMMAND_STATUS;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_CONTENT_LENGTH;
+import static com.google.gerrit.pgm.http.jetty.HttpLog.P_CPU_TOTAL;
+import static com.google.gerrit.pgm.http.jetty.HttpLog.P_CPU_USER;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_HOST;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_LATENCY;
+import static com.google.gerrit.pgm.http.jetty.HttpLog.P_MEMORY;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_METHOD;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_PROTOCOL;
 import static com.google.gerrit.pgm.http.jetty.HttpLog.P_REFERER;
@@ -49,6 +52,9 @@
     public String status;
     public String contentLength;
     public String latency;
+    public String cpuTotal;
+    public String cpuUser;
+    public String memory;
     public String referer;
     public String userAgent;
     public String commandStatus;
@@ -64,6 +70,9 @@
       this.status = getMdcString(event, P_STATUS);
       this.contentLength = getMdcString(event, P_CONTENT_LENGTH);
       this.latency = getMdcString(event, P_LATENCY);
+      this.cpuTotal = getMdcString(event, P_CPU_TOTAL);
+      this.cpuUser = getMdcString(event, P_CPU_USER);
+      this.memory = getMdcString(event, P_MEMORY);
       this.referer = getMdcString(event, P_REFERER);
       this.userAgent = getMdcString(event, P_USER_AGENT);
       this.commandStatus = getMdcString(event, P_COMMAND_STATUS);
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
index b83fcc5..ddc1b5e 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
@@ -72,6 +72,15 @@
     dq_opt(buf, event, HttpLog.P_USER_AGENT);
 
     buf.append(' ');
+    opt(buf, event, HttpLog.P_CPU_TOTAL);
+
+    buf.append(' ');
+    opt(buf, event, HttpLog.P_CPU_USER);
+
+    buf.append(' ');
+    opt(buf, event, HttpLog.P_MEMORY);
+
+    buf.append(' ');
     dq_opt(buf, event, HttpLog.P_COMMAND_STATUS);
 
     buf.append('\n');
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index ca28255..95572b6 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
@@ -31,7 +32,6 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.AuditLogFormatter;
 import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.GroupNameNotes;
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index effb4c6..2e32066 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
@@ -28,7 +29,6 @@
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndex;
@@ -88,6 +88,9 @@
 
   @Override
   public void postRun() throws Exception {
+    if (!accounts.hasAnyAccount()) {
+      welcome();
+    }
     AuthType authType = flags.cfg.getEnum(AuthType.values(), "auth", null, "type", null);
     if (authType != AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT) {
       return;
@@ -146,6 +149,15 @@
     }
   }
 
+  private void welcome() {
+    ui.message(
+        "============================================================================\n"
+            + "Welcome to the Gerrit community\n\n"
+            + "Find more information on the homepage: https://www.gerritcodereview.com\n"
+            + "Discuss Gerrit on the mailing list: https://groups.google.com/g/repo-discuss\n"
+            + "============================================================================\n");
+  }
+
   private String readEmail(AccountSshKey sshKey) {
     String defaultEmail = "admin@example.com";
     if (sshKey != null && sshKey.comment() != null) {
diff --git a/java/com/google/gerrit/pgm/init/InitJGitConfig.java b/java/com/google/gerrit/pgm/init/InitJGitConfig.java
index 6e37f7f..bad55b4 100644
--- a/java/com/google/gerrit/pgm/init/InitJGitConfig.java
+++ b/java/com/google/gerrit/pgm/init/InitJGitConfig.java
@@ -64,22 +64,9 @@
                 + "gc should be configured in gc config section or run as a separate process.");
       }
 
-      if (!jgitConfig
+      if (jgitConfig
           .getNames(ConfigConstants.CONFIG_PROTOCOL_SECTION)
           .contains(ConfigConstants.CONFIG_KEY_VERSION)) {
-        jgitConfig.setString(
-            ConfigConstants.CONFIG_PROTOCOL_SECTION,
-            null,
-            ConfigConstants.CONFIG_KEY_VERSION,
-            TransferConfig.ProtocolVersion.V2.version());
-        jgitConfig.save();
-        ui.error(
-            String.format(
-                "Auto-configured \"%s.%s = %s\" to activate git wire protocol version 2.",
-                ConfigConstants.CONFIG_PROTOCOL_SECTION,
-                ConfigConstants.CONFIG_KEY_VERSION,
-                TransferConfig.ProtocolVersion.V2.version()));
-      } else {
         String version =
             jgitConfig.getString(
                 ConfigConstants.CONFIG_PROTOCOL_SECTION, null, ConfigConstants.CONFIG_KEY_VERSION);
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index af01343..71aec5b 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -43,7 +43,6 @@
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
@@ -54,8 +53,8 @@
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DefaultPreferencesCacheImpl;
 import com.google.gerrit.server.config.DefaultUrlFormatter;
-import com.google.gerrit.server.config.EnableReverseDnsLookup;
-import com.google.gerrit.server.config.EnableReverseDnsLookupProvider;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecordProvider;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
 import com.google.gerrit.server.config.SysExecutorModule;
@@ -131,16 +130,14 @@
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
         .toProvider(CommentLinkProvider.class)
         .in(SINGLETON);
-    bind(new TypeLiteral<DynamicSet<ChangeAttributeFactory>>() {})
-        .toInstance(DynamicSet.emptySet());
     bind(new TypeLiteral<DynamicMap<RestView<CommitResource>>>() {})
         .toInstance(DynamicMap.emptyMap());
     bind(String.class)
         .annotatedWith(CanonicalWebUrl.class)
         .toProvider(CanonicalWebUrlProvider.class);
     bind(Boolean.class)
-        .annotatedWith(EnableReverseDnsLookup.class)
-        .toProvider(EnableReverseDnsLookupProvider.class)
+        .annotatedWith(EnablePeerIPInReflogRecord.class)
+        .toProvider(EnablePeerIPInReflogRecordProvider.class)
         .in(SINGLETON);
     bind(Realm.class).to(FakeRealm.class);
     bind(IdentifiedUser.class).toProvider(Providers.of(null));
diff --git a/java/com/google/gerrit/pgm/util/ErrorLogFile.java b/java/com/google/gerrit/pgm/util/ErrorLogFile.java
index 634e56b..3d8ac8d 100644
--- a/java/com/google/gerrit/pgm/util/ErrorLogFile.java
+++ b/java/com/google/gerrit/pgm/util/ErrorLogFile.java
@@ -95,14 +95,9 @@
     }
 
     if (json) {
-      Boolean enableReverseDnsLookup =
-          config.getBoolean("gerrit", null, "enableReverseDnsLookup", false);
       root.addAppender(
           SystemLog.createAppender(
-              logdir,
-              LOG_NAME + JSON_SUFFIX,
-              new ErrorLogJsonLayout(enableReverseDnsLookup),
-              rotate));
+              logdir, LOG_NAME + JSON_SUFFIX, new ErrorLogJsonLayout(), rotate));
     }
   }
 }
diff --git a/java/com/google/gerrit/pgm/util/ErrorLogJsonLayout.java b/java/com/google/gerrit/pgm/util/ErrorLogJsonLayout.java
index 85378a4..7d4abfc 100644
--- a/java/com/google/gerrit/pgm/util/ErrorLogJsonLayout.java
+++ b/java/com/google/gerrit/pgm/util/ErrorLogJsonLayout.java
@@ -26,13 +26,6 @@
 
 /** Layout for formatting error log events in the JSON format. */
 public class ErrorLogJsonLayout extends JsonLayout {
-  private final Boolean enableReverseDnsLookup;
-
-  public ErrorLogJsonLayout(Boolean enableDnsReverseLookup) {
-    super();
-    this.enableReverseDnsLookup = enableDnsReverseLookup;
-  }
-
   @Override
   public JsonLogEntry toJsonLogEntry(LoggingEvent event) {
     return new ErrorJsonLogEntry(event);
@@ -86,7 +79,7 @@
 
     public ErrorJsonLogEntry(LoggingEvent event) {
       this.timestamp = timestampFormatter.format(event.getTimeStamp());
-      this.sourceHost = getSourceHost(enableReverseDnsLookup);
+      this.sourceHost = getSourceHost();
       this.message = event.getRenderedMessage();
       this.file = event.getLocationInformation().getFileName();
       this.lineNumber = event.getLocationInformation().getLineNumber();
@@ -102,14 +95,9 @@
       }
     }
 
-    private String getSourceHost(Boolean enableReverseDnsLookup) {
-      InetAddress in;
+    private String getSourceHost() {
       try {
-        in = InetAddress.getLocalHost();
-        if (Boolean.TRUE.equals(enableReverseDnsLookup)) {
-          return in.getCanonicalHostName();
-        }
-        return in.getHostAddress();
+        return InetAddress.getLocalHost().getHostAddress();
       } catch (UnknownHostException e) {
         return "unknown-host";
       }
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index 98558fb..c3be0a4 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.config.GerritRuntime;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
 import com.google.gerrit.server.git.SystemReaderInstaller;
 import com.google.gerrit.server.schema.SchemaModule;
@@ -128,6 +129,9 @@
 
     modules.add(new SchemaModule());
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
+    // The only implementation of experiments is available in all programs that can use
+    // gerrit.config
+    modules.add(new ConfigExperimentFeatures.Module());
 
     try {
       return Guice.createInjector(
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index aa3ef89..04d874c 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -25,23 +25,36 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.LabelNormalizer;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchList;
+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.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Map;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -59,13 +72,21 @@
   private final ProjectCache projectCache;
   private final ChangeKindCache changeKindCache;
   private final LabelNormalizer labelNormalizer;
+  private final PatchListCache patchListCache;
+  private final GitRepositoryManager repositoryManager;
 
   @Inject
   ApprovalInference(
-      ProjectCache projectCache, ChangeKindCache changeKindCache, LabelNormalizer labelNormalizer) {
+      ProjectCache projectCache,
+      ChangeKindCache changeKindCache,
+      LabelNormalizer labelNormalizer,
+      PatchListCache patchListCache,
+      GitRepositoryManager repositoryManager) {
     this.projectCache = projectCache;
     this.changeKindCache = changeKindCache;
     this.labelNormalizer = labelNormalizer;
+    this.patchListCache = patchListCache;
+    this.repositoryManager = repositoryManager;
   }
 
   /**
@@ -93,10 +114,16 @@
   }
 
   private static boolean canCopy(
-      ProjectState project, PatchSetApproval psa, PatchSet.Id psId, ChangeKind kind) {
+      ProjectState project,
+      PatchSetApproval psa,
+      PatchSet.Id psId,
+      ChangeKind kind,
+      LabelType type,
+      @Nullable PatchList patchListCurrentPatchset,
+      @Nullable PatchList patchListPriorPatchset) {
     int n = psa.key().patchSetId().get();
     checkArgument(n != psId.get());
-    LabelType type = project.getLabelTypes().byLabel(psa.labelId());
+
     if (type == null) {
       logger.atFine().log(
           "approval %d on label %s of patch set %d of change %d cannot be copied"
@@ -153,6 +180,21 @@
           psa.value(),
           project.getName());
       return true;
+    } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
+        && didListOfFilesNotChange(patchListCurrentPatchset, patchListPriorPatchset)) {
+      logger.atFine().log(
+          "approval %d on label %s of patch set %d of change %d can be copied"
+              + " to patch set %d because the label has set "
+              + "copyAllScoresIfListOfFilesDidNotChange = true on "
+              + "project %s and list of files did not change (maybe except a rename, which is "
+              + "still the same file).",
+          psa.value(),
+          psa.label(),
+          n,
+          psa.key().patchSetId().changeId().get(),
+          psId.get(),
+          project.getName());
+      return true;
     }
     switch (kind) {
       case MERGE_FIRST_PARENT_UPDATE:
@@ -271,6 +313,20 @@
     }
   }
 
+  private static boolean didListOfFilesNotChange(PatchList oldPatchList, PatchList newPatchList) {
+    Map<String, ChangeType> fileToChangeTypePs1 = getFileToChangeType(oldPatchList);
+    Map<String, ChangeType> fileToChangeTypePs2 = getFileToChangeType(newPatchList);
+    return fileToChangeTypePs1.equals(fileToChangeTypePs2);
+  }
+
+  private static Map<String, ChangeType> getFileToChangeType(PatchList ps) {
+    return ps.getPatches().stream()
+        .collect(
+            Collectors.toMap(
+                f -> f.getNewName() != null ? f.getNewName() : f.getOldName(),
+                f -> f.getChangeType()));
+  }
+
   private Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
       ChangeNotes notes,
       ProjectState project,
@@ -331,15 +387,60 @@
     logger.atFine().log(
         "change kind for patch set %d of change %d against prior patch set %s is %s",
         ps.id().get(), ps.id().changeId().get(), priorPatchSet.getValue().id().changeId(), kind);
+    PatchList patchListCurrentPatchset = null;
+    PatchList patchListPriorPatchset = null;
+    LabelTypes labelTypes = project.getLabelTypes();
     for (PatchSetApproval psa : priorApprovals) {
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
       }
-      if (!canCopy(project, psa, ps.id(), kind)) {
+      LabelType type = labelTypes.byLabel(psa.labelId());
+      // Only compute patchList if there is a relevant label, since this is expensive.
+      if (patchListCurrentPatchset == null
+          && type != null
+          && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
+        patchListCurrentPatchset = getPatchList(project, ps);
+        patchListPriorPatchset = getPatchList(project, priorPatchSet.getValue());
+      }
+      if (!canCopy(
+          project, psa, ps.id(), kind, type, patchListCurrentPatchset, patchListPriorPatchset)) {
         continue;
       }
       resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
     }
     return resultByUser.values();
   }
+
+  /**
+   * Gets the {@link PatchList} between a patch-set and the base. Can be used to compute difference
+   * in files between two patch-sets by using both {@link PatchList}s of those 2 patch-sets.
+   */
+  private PatchList getPatchList(ProjectState project, PatchSet ps) {
+    // Compare against the base:
+    // * For merge commits the comparison is done against the 1st parent, which is the destination
+    //   branch.
+    // * For non-merge commits the comparison is done against the only parent, or an empty base if
+    //   no parent exists.
+    PatchListKey key =
+        PatchListKey.againstBase(
+            ps.commitId(), getParentCount(project.getNameKey(), ps.commitId()));
+    try {
+      return patchListCache.get(key, project.getNameKey());
+    } catch (PatchListNotAvailableException ex) {
+      throw new StorageException(
+          "failed to compute difference in files, so won't copy"
+              + " votes on labels even if list of files is the same and "
+              + "copyAllIfListOfFilesDidNotChange",
+          ex);
+    }
+  }
+
+  private int getParentCount(Project.NameKey project, ObjectId objectId) {
+    try (Repository repo = repositoryManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      return revWalk.parseCommit(objectId).getParentCount();
+    } catch (IOException ex) {
+      throw new StorageException(ex);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 069006b..404906d 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -62,7 +62,6 @@
         "//java/com/google/gerrit/server/util/git",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
-        "//java/com/google/gerrit/util/ssl",
         "//java/org/apache/commons/net",
         "//lib:args4j",
         "//lib:autolink",
@@ -98,7 +97,6 @@
         "//lib:guava-retrying",
         "//lib:jgit",
         "//lib:jgit-archive",
-        "//lib:jsch",
         "//lib:juniversalchardet",
         "//lib:mime-util",
         "//lib:protobuf",
@@ -113,6 +111,7 @@
         "//lib/commons:compress",
         "//lib/commons:dbcp",
         "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/commons:net",
         "//lib/commons:validator",
         "//lib/errorprone:annotations",
diff --git a/java/com/google/gerrit/server/CommentContextLoader.java b/java/com/google/gerrit/server/CommentContextLoader.java
deleted file mode 100644
index bbc7cf3..0000000
--- a/java/com/google/gerrit/server/CommentContextLoader.java
+++ /dev/null
@@ -1,165 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import static java.util.stream.Collectors.groupingBy;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.ContextLineInfo;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.Text;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.TreeWalk;
-
-/**
- * Computes the list of {@link ContextLineInfo} for a given comment, that is, the lines of the
- * source file surrounding and including the area where the comment was written.
- */
-public class CommentContextLoader {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final GitRepositoryManager repoManager;
-  private final Project.NameKey project;
-  private Map<ContextData, List<ContextLineInfo>> candidates;
-
-  public interface Factory {
-    CommentContextLoader create(Project.NameKey project);
-  }
-
-  @Inject
-  CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
-    this.repoManager = repoManager;
-    this.project = project;
-    this.candidates = new HashMap<>();
-  }
-
-  /**
-   * Returns an empty list of {@link ContextLineInfo}. Clients are expected to call this method one
-   * or more times. Each call returns a reference to an empty {@link List
-   * List&lt;ContextLineInfo&gt;}.
-   *
-   * <p>A single call to {@link #fill()} will cause all list references returned from this method to
-   * be populated. If a client calls this method again with a comment that was passed before calling
-   * {@link #fill()}, the new populated list will be returned.
-   *
-   * @param comment the comment entity for which we want to load the context
-   * @return a list of {@link ContextLineInfo}
-   */
-  public List<ContextLineInfo> getContext(CommentInfo comment) {
-    ContextData key =
-        ContextData.create(
-            comment.id,
-            ObjectId.fromString(comment.commitId),
-            comment.path,
-            getStartAndEndLines(comment));
-    List<ContextLineInfo> context = candidates.get(key);
-    if (context == null) {
-      context = new ArrayList<>();
-      candidates.put(key, context);
-    }
-    return context;
-  }
-
-  /**
-   * A call to this method loads the context for all comments stored in {@link
-   * CommentContextLoader#candidates}. This is useful so that the repository is opened once for all
-   * comments.
-   */
-  public void fill() {
-    // Group comments by commit ID so that each commit is parsed only once
-    Map<ObjectId, List<ContextData>> commentsByCommitId =
-        candidates.keySet().stream().collect(groupingBy(ContextData::commitId));
-
-    try (Repository repo = repoManager.openRepository(project);
-        RevWalk rw = new RevWalk(repo)) {
-      for (ObjectId commitId : commentsByCommitId.keySet()) {
-        RevCommit commit = rw.parseCommit(commitId);
-        for (ContextData k : commentsByCommitId.get(commitId)) {
-          if (!k.range().isPresent()) {
-            continue;
-          }
-          try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), k.path(), commit.getTree())) {
-            if (tw == null) {
-              logger.atWarning().log(
-                  "Failed to find path %s in the git tree of ID %s.",
-                  k.path(), commit.getTree().getId());
-              continue;
-            }
-            ObjectId id = tw.getObjectId(0);
-            Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
-            List<ContextLineInfo> contextLines = candidates.get(k);
-            Range r = k.range().get();
-            for (int i = r.start(); i <= r.end(); i++) {
-              contextLines.add(new ContextLineInfo(i, src.getString(i - 1)));
-            }
-          }
-        }
-      }
-    } catch (IOException e) {
-      throw new StorageException("Failed to load the comment context", e);
-    }
-  }
-
-  private static Optional<Range> getStartAndEndLines(CommentInfo comment) {
-    if (comment.range != null) {
-      return Optional.of(Range.create(comment.range.startLine, comment.range.endLine));
-    } else if (comment.line != null) {
-      return Optional.of(Range.create(comment.line, comment.line));
-    }
-    return Optional.empty();
-  }
-
-  @AutoValue
-  abstract static class Range {
-    static Range create(int start, int end) {
-      return new AutoValue_CommentContextLoader_Range(start, end);
-    }
-
-    abstract int start();
-
-    abstract int end();
-  }
-
-  @AutoValue
-  abstract static class ContextData {
-    static ContextData create(String id, ObjectId commitId, String path, Optional<Range> range) {
-      return new AutoValue_CommentContextLoader_ContextData(id, commitId, path, range);
-    }
-
-    abstract String id();
-
-    abstract ObjectId commitId();
-
-    abstract String path();
-
-    abstract Optional<Range> range();
-  }
-}
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 75afc04..7012944 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.common.Nullable;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -31,17 +31,19 @@
  * @see IdentifiedUser
  */
 public abstract class CurrentUser {
-  /** Unique key for plugin/extension specific data on a CurrentUser. */
-  public static final class PropertyKey<T> {
-    public static <T> PropertyKey<T> create() {
-      return new PropertyKey<>();
-    }
+  public static final PropertyMap.Key<ExternalId.Key> LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY =
+      PropertyMap.key();
 
-    private PropertyKey() {}
+  private final PropertyMap properties;
+  private AccessPath accessPath = AccessPath.UNKNOWN;
+
+  protected CurrentUser() {
+    this.properties = PropertyMap.EMPTY;
   }
 
-  private AccessPath accessPath = AccessPath.UNKNOWN;
-  private PropertyKey<ExternalId.Key> lastLoginExternalIdPropertyKey = PropertyKey.create();
+  protected CurrentUser(PropertyMap properties) {
+    this.properties = properties;
+  }
 
   /** How this user is accessing the Gerrit Code Review application. */
   public final AccessPath getAccessPath() {
@@ -127,35 +129,41 @@
         getClass().getSimpleName() + " is not an IdentifiedUser");
   }
 
+  /**
+   * Returns all email addresses associated with this user. For {@link AnonymousUser} and other
+   * users that don't represent a person user or service account, this set will be empty.
+   */
+  public ImmutableSet<String> getEmailAddresses() {
+    return ImmutableSet.of();
+  }
+
+  /**
+   * Returns all {@link com.google.gerrit.server.account.externalids.ExternalId.Key}s associated
+   * with this user. For {@link AnonymousUser} and other users that don't represent a person user or
+   * service account, this set will be empty.
+   */
+  public ImmutableSet<ExternalId.Key> getExternalIdKeys() {
+    return ImmutableSet.of();
+  }
+
   /** Check if the CurrentUser is an InternalUser. */
   public boolean isInternalUser() {
     return false;
   }
 
   /**
-   * Lookup a previously stored property.
+   * Lookup a stored property.
    *
-   * @param key unique property key.
-   * @return previously stored value, or {@code Optional#empty()}.
+   * @param key unique property key. This key has to be the same instance that was used to store the
+   *     value when constructing the {@link PropertyMap}
+   * @return stored value, or {@code Optional#empty()}.
    */
-  public <T> Optional<T> get(PropertyKey<T> key) {
-    return Optional.empty();
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  public <T> void put(PropertyKey<T> key, @Nullable T value) {}
-
-  public void setLastLoginExternalIdKey(ExternalId.Key externalIdKey) {
-    put(lastLoginExternalIdPropertyKey, externalIdKey);
+  public <T> Optional<T> get(PropertyMap.Key<T> key) {
+    return properties.get(key);
   }
 
   public Optional<ExternalId.Key> getLastLoginExternalIdKey() {
-    return get(lastLoginExternalIdPropertyKey);
+    return get(LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
index ebf4ec6..7f9fbd2 100644
--- a/java/com/google/gerrit/server/DynamicOptions.java
+++ b/java/com/google/gerrit/server/DynamicOptions.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.server.plugins.DelegatingClassLoader;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Injector;
@@ -29,7 +30,7 @@
 import java.util.WeakHashMap;
 
 /** Helper class to define and parse options from plugins on ssh and RestAPI commands. */
-public class DynamicOptions {
+public class DynamicOptions implements AutoCloseable {
   /**
    * To provide additional options, bind a DynamicBean. For example:
    *
@@ -98,7 +99,9 @@
    *
    * <p>Do this by binding to the name of the command you are going to bind to and providing an
    * Iterable of Module names to instantiate and add to the Injector used to instantiate the
-   * DynamicBean in the other classLoader. For example:
+   * DynamicBean in the other classLoader. This interface supports running LifecycleListeners which
+   * are defined by the Modules being provided. The duration of the lifecycle starts when a ssh or
+   * http request starts and ends when the request completes. For example:
    *
    * <pre>
    *   bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
@@ -106,7 +109,7 @@
    *           "com.google.gerrit.plugins.otherplugin.command"))
    *       .to(MyOptionsModulesClassNamesProvider.class);
    *
-   *   static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ClassNameProvider {
+   *   static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ModulesClassNamesProvider {
    *     {@literal @}Override
    *     public String getClassName() {
    *       return "com.googlesource.gerrit.plugins.myplugin.CommandOptions";
@@ -190,13 +193,17 @@
   protected Object bean;
   protected Map<String, DynamicBean> beansByPlugin;
   protected Injector injector;
+  protected DynamicMap<DynamicBean> dynamicBeans;
+  protected LifecycleManager lifecycleManager;
 
   /**
    * Internal: For Gerrit to include options from DynamicBeans, setup a DynamicMap and instantiate
    * this class so the following methods can be called if desired:
    *
    * <pre>
-   *    DynamicOptions pluginOptions = new DynamicOptions(bean, injector, dynamicBeans);
+   *    DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans);
+   *    pluginOptions.setBean(bean);
+   *    pluginOptions.startLifecycleListeners();
    *    pluginOptions.parseDynamicBeans(clp);
    *    pluginOptions.setDynamicBeans();
    *    pluginOptions.onBeanParseStart();
@@ -206,10 +213,15 @@
    *    pluginOptions.onBeanParseEnd();
    * </pre>
    */
-  public DynamicOptions(Object bean, Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
-    this.bean = bean;
+  public DynamicOptions(Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
     this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
+    lifecycleManager = new LifecycleManager();
     beansByPlugin = new HashMap<>();
+  }
+
+  public void setBean(Object bean) {
+    this.bean = bean;
     Class<?> beanClass =
         (bean instanceof BeanReceiver)
             ? ((BeanReceiver) bean).getExportedBeanReceiver()
@@ -255,9 +267,10 @@
             modules.add(modulesInjector.getInstance(mClass));
           }
         }
-        return modulesInjector
-            .createChildInjector(modules)
-            .getInstance((Class<DynamicOptions.DynamicBean>) loader.loadClass(className));
+        Injector childModulesInjector = modulesInjector.createChildInjector(modules);
+        lifecycleManager.add(childModulesInjector);
+        return childModulesInjector.getInstance(
+            (Class<DynamicOptions.DynamicBean>) loader.loadClass(className));
       } catch (ClassNotFoundException e) {
         throw new RuntimeException(e);
       }
@@ -300,6 +313,14 @@
     }
   }
 
+  public void startLifecycleListeners() {
+    lifecycleManager.start();
+  }
+
+  public void stopLifecycleListeners() {
+    lifecycleManager.stop();
+  }
+
   public void onBeanParseStart() {
     for (Map.Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
       DynamicBean instance = e.getValue();
@@ -319,4 +340,9 @@
       }
     }
   }
+
+  @Override
+  public void close() {
+    stopLifecycleListeners();
+  }
 }
diff --git a/java/com/google/gerrit/server/ExceptionHookImpl.java b/java/com/google/gerrit/server/ExceptionHookImpl.java
index 8884991..3986842 100644
--- a/java/com/google/gerrit/server/ExceptionHookImpl.java
+++ b/java/com/google/gerrit/server/ExceptionHookImpl.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.project.ProjectConfig;
 import java.util.Optional;
@@ -73,6 +74,9 @@
               + "\n"
               + CONTACT_PROJECT_OWNER_USER_MESSAGE);
     }
+    if (throwable instanceof InternalServerWithUserMessageException) {
+      return ImmutableList.of(throwable.getMessage());
+    }
     return ImmutableList.of();
   }
 
diff --git a/java/com/google/gerrit/server/ExternalUser.java b/java/com/google/gerrit/server/ExternalUser.java
new file mode 100644
index 0000000..9680f3e
--- /dev/null
+++ b/java/com/google/gerrit/server/ExternalUser.java
@@ -0,0 +1,90 @@
+// 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;
+
+import static com.google.common.flogger.LazyArgs.lazy;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+
+/**
+ * Represents a user that does not have a Gerrit account.
+ *
+ * <p>This user is limited in what they can do on Gerrit. For now, we only guarantee that permission
+ * checking - including ref filtering works.
+ *
+ * <p>This class is thread-safe.
+ */
+public class ExternalUser extends CurrentUser {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    ExternalUser create(
+        Collection<String> emailAddresses,
+        Collection<ExternalId.Key> externalIdKeys,
+        PropertyMap propertyMap);
+  }
+
+  private final GroupBackend groupBackend;
+  private final ImmutableSet<String> emailAddresses;
+  private final ImmutableSet<ExternalId.Key> externalIdKeys;
+
+  private GroupMembership effectiveGroups;
+
+  @Inject
+  public ExternalUser(
+      GroupBackend groupBackend,
+      @Assisted Collection<String> emailAddresses,
+      @Assisted Collection<ExternalId.Key> externalIdKeys,
+      @Assisted PropertyMap propertyMap) {
+    super(propertyMap);
+    this.groupBackend = groupBackend;
+    this.emailAddresses = ImmutableSet.copyOf(emailAddresses);
+    this.externalIdKeys = ImmutableSet.copyOf(externalIdKeys);
+  }
+
+  @Override
+  public ImmutableSet<String> getEmailAddresses() {
+    return emailAddresses;
+  }
+
+  @Override
+  public ImmutableSet<ExternalId.Key> getExternalIdKeys() {
+    return externalIdKeys;
+  }
+
+  @Override
+  public GroupMembership getEffectiveGroups() {
+    synchronized (this) {
+      if (effectiveGroups == null) {
+        effectiveGroups = groupBackend.membershipsOf(this);
+        logger.atFinest().log(
+            "Known groups of %s: %s", getLoggableName(), lazy(effectiveGroups::getKnownGroups));
+      }
+    }
+    return effectiveGroups;
+  }
+
+  @Override
+  public Object getCacheKey() {
+    return this; // Caching is tied to this exact instance.
+  }
+}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 7cafdc0..24ea9d2 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -15,13 +15,16 @@
 package com.google.gerrit.server;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.flogger.LazyArgs.lazy;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
@@ -29,10 +32,11 @@
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.EnableReverseDnsLookup;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import com.google.inject.OutOfScopeException;
@@ -46,8 +50,6 @@
 import java.net.SocketAddress;
 import java.net.URL;
 import java.util.Date;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.TimeZone;
@@ -67,7 +69,7 @@
     private final Provider<String> canonicalUrl;
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
-    private final Boolean enableReverseDnsLookup;
+    private final Boolean enablePeerIPInReflogRecord;
 
     @Inject
     public GenericFactory(
@@ -75,7 +77,7 @@
         Realm realm,
         @AnonymousCowardName String anonymousCowardName,
         @CanonicalWebUrl Provider<String> canonicalUrl,
-        @EnableReverseDnsLookup Boolean enableReverseDnsLookup,
+        @EnablePeerIPInReflogRecord Boolean enablePeerIPInReflogRecord,
         AccountCache accountCache,
         GroupBackend groupBackend) {
       this.authConfig = authConfig;
@@ -84,7 +86,7 @@
       this.canonicalUrl = canonicalUrl;
       this.accountCache = accountCache;
       this.groupBackend = groupBackend;
-      this.enableReverseDnsLookup = enableReverseDnsLookup;
+      this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
     }
 
     public IdentifiedUser create(AccountState state) {
@@ -95,7 +97,7 @@
           canonicalUrl,
           accountCache,
           groupBackend,
-          enableReverseDnsLookup,
+          enablePeerIPInReflogRecord,
           Providers.of(null),
           state,
           null);
@@ -105,12 +107,26 @@
       return create(null, id);
     }
 
+    @VisibleForTesting
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
+      return runAs(null, id, null, properties);
+    }
+
     public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
       return runAs(remotePeer, id, null);
     }
 
     public IdentifiedUser runAs(
         SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+      return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
+    }
+
+    private IdentifiedUser runAs(
+        SocketAddress remotePeer,
+        Account.Id id,
+        @Nullable CurrentUser caller,
+        PropertyMap properties) {
       return new IdentifiedUser(
           authConfig,
           realm,
@@ -118,10 +134,11 @@
           canonicalUrl,
           accountCache,
           groupBackend,
-          enableReverseDnsLookup,
+          enablePeerIPInReflogRecord,
           Providers.of(remotePeer),
           id,
-          caller);
+          caller,
+          properties);
     }
   }
 
@@ -139,7 +156,7 @@
     private final Provider<String> canonicalUrl;
     private final AccountCache accountCache;
     private final GroupBackend groupBackend;
-    private final Boolean enableReverseDnsLookup;
+    private final Boolean enablePeerIPInReflogRecord;
     private final Provider<SocketAddress> remotePeerProvider;
 
     @Inject
@@ -150,7 +167,7 @@
         @CanonicalWebUrl Provider<String> canonicalUrl,
         AccountCache accountCache,
         GroupBackend groupBackend,
-        @EnableReverseDnsLookup Boolean enableReverseDnsLookup,
+        @EnablePeerIPInReflogRecord Boolean enablePeerIPInReflogRecord,
         @RemotePeer Provider<SocketAddress> remotePeerProvider) {
       this.authConfig = authConfig;
       this.realm = realm;
@@ -158,25 +175,15 @@
       this.canonicalUrl = canonicalUrl;
       this.accountCache = accountCache;
       this.groupBackend = groupBackend;
-      this.enableReverseDnsLookup = enableReverseDnsLookup;
+      this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
       this.remotePeerProvider = remotePeerProvider;
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return new IdentifiedUser(
-          authConfig,
-          realm,
-          anonymousCowardName,
-          canonicalUrl,
-          accountCache,
-          groupBackend,
-          enableReverseDnsLookup,
-          remotePeerProvider,
-          id,
-          null);
+      return create(id, PropertyMap.EMPTY);
     }
 
-    public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
+    public <T> IdentifiedUser create(Account.Id id, PropertyMap properties) {
       return new IdentifiedUser(
           authConfig,
           realm,
@@ -184,10 +191,26 @@
           canonicalUrl,
           accountCache,
           groupBackend,
-          enableReverseDnsLookup,
+          enablePeerIPInReflogRecord,
           remotePeerProvider,
           id,
-          caller);
+          null,
+          properties);
+    }
+
+    public IdentifiedUser runAs(Account.Id id, CurrentUser caller, PropertyMap properties) {
+      return new IdentifiedUser(
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          enablePeerIPInReflogRecord,
+          remotePeerProvider,
+          id,
+          caller,
+          properties);
     }
   }
 
@@ -201,7 +224,7 @@
   private final Realm realm;
   private final GroupBackend groupBackend;
   private final String anonymousCowardName;
-  private final Boolean enableReverseDnsLookup;
+  private final Boolean enablePeerIPInReflogRecord;
   private final Set<String> validEmails = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
   private final CurrentUser realUser; // Must be final since cached properties depend on it.
 
@@ -212,7 +235,6 @@
   private boolean loadedAllEmails;
   private Set<String> invalidEmails;
   private GroupMembership effectiveGroups;
-  private Map<PropertyKey<Object>, Object> properties;
 
   private IdentifiedUser(
       AuthConfig authConfig,
@@ -221,7 +243,7 @@
       Provider<String> canonicalUrl,
       AccountCache accountCache,
       GroupBackend groupBackend,
-      Boolean enableReverseDnsLookup,
+      Boolean enablePeerIPInReflogRecord,
       @Nullable Provider<SocketAddress> remotePeerProvider,
       AccountState state,
       @Nullable CurrentUser realUser) {
@@ -232,10 +254,11 @@
         canonicalUrl,
         accountCache,
         groupBackend,
-        enableReverseDnsLookup,
+        enablePeerIPInReflogRecord,
         remotePeerProvider,
         state.account().id(),
-        realUser);
+        realUser,
+        PropertyMap.EMPTY);
     this.state = state;
   }
 
@@ -246,17 +269,19 @@
       Provider<String> canonicalUrl,
       AccountCache accountCache,
       GroupBackend groupBackend,
-      Boolean enableReverseDnsLookup,
+      Boolean enablePeerIPInReflogRecord,
       @Nullable Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
-      @Nullable CurrentUser realUser) {
+      @Nullable CurrentUser realUser,
+      PropertyMap properties) {
+    super(properties);
     this.canonicalUrl = canonicalUrl;
     this.accountCache = accountCache;
     this.groupBackend = groupBackend;
     this.authConfig = authConfig;
     this.realm = realm;
     this.anonymousCowardName = anonymousCowardName;
-    this.enableReverseDnsLookup = enableReverseDnsLookup;
+    this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
     this.remotePeerProvider = remotePeerProvider;
     this.accountId = id;
     this.realUser = realUser != null ? realUser : this;
@@ -357,6 +382,7 @@
     return false;
   }
 
+  @Override
   public ImmutableSet<String> getEmailAddresses() {
     if (!loadedAllEmails) {
       validEmails.addAll(realm.getEmailAddresses(this));
@@ -365,6 +391,11 @@
     return ImmutableSet.copyOf(validEmails);
   }
 
+  @Override
+  public ImmutableSet<ExternalId.Key> getExternalIdKeys() {
+    return state().externalIds().stream().map(ExternalId::key).collect(toImmutableSet());
+  }
+
   public String getName() {
     return getAccount().getName();
   }
@@ -410,8 +441,20 @@
       name = anonymousCowardName;
     }
 
-    String user = getUserName().orElse("") + "|account-" + ua.id().toString();
-    return new PersonIdent(name, user + "@" + guessHost(), when, tz);
+    String user;
+    if (enablePeerIPInReflogRecord) {
+      user = constructMailAddress(ua, guessHost());
+    } else {
+      user =
+          Strings.isNullOrEmpty(ua.preferredEmail())
+              ? constructMailAddress(ua, "unknown")
+              : ua.preferredEmail();
+    }
+    return new PersonIdent(name, user, when, tz);
+  }
+
+  private String constructMailAddress(Account ua, String host) {
+    return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
   }
 
   public PersonIdent newCommitterIdent(Date when, TimeZone tz) {
@@ -463,40 +506,6 @@
     return true;
   }
 
-  @Override
-  public synchronized <T> Optional<T> get(PropertyKey<T> key) {
-    if (properties != null) {
-      @SuppressWarnings("unchecked")
-      T value = (T) properties.get(key);
-      return Optional.ofNullable(value);
-    }
-    return Optional.empty();
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  @Override
-  public synchronized <T> void put(PropertyKey<T> key, @Nullable T value) {
-    if (properties == null) {
-      if (value == null) {
-        return;
-      }
-      properties = new HashMap<>();
-    }
-
-    @SuppressWarnings("unchecked")
-    PropertyKey<Object> k = (PropertyKey<Object>) key;
-    if (value != null) {
-      properties.put(k, value);
-    } else {
-      properties.remove(k);
-    }
-  }
-
   /**
    * Returns a materialized copy of the user with all dependencies.
    *
@@ -522,7 +531,7 @@
         Providers.of(canonicalUrl.get()),
         accountCache,
         groupBackend,
-        enableReverseDnsLookup,
+        enablePeerIPInReflogRecord,
         remotePeer,
         state,
         realUser);
@@ -544,18 +553,11 @@
     if (remotePeer instanceof InetSocketAddress) {
       InetSocketAddress sa = (InetSocketAddress) remotePeer;
       InetAddress in = sa.getAddress();
-      host = in != null ? getHost(in) : sa.getHostName();
+      host = in != null ? in.getHostAddress() : sa.getHostName();
     }
     if (Strings.isNullOrEmpty(host)) {
       return "unknown";
     }
     return host;
   }
-
-  private String getHost(InetAddress in) {
-    if (Boolean.TRUE.equals(enableReverseDnsLookup)) {
-      return in.getCanonicalHostName();
-    }
-    return in.getHostAddress();
-  }
 }
diff --git a/java/com/google/gerrit/server/PropertyMap.java b/java/com/google/gerrit/server/PropertyMap.java
new file mode 100644
index 0000000..da3a2495
--- /dev/null
+++ b/java/com/google/gerrit/server/PropertyMap.java
@@ -0,0 +1,84 @@
+// 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;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Optional;
+
+/**
+ * Immutable map that holds a collection of random objects allowing for a type-safe retrieval.
+ *
+ * <p>Intended to be used in {@link CurrentUser} when the object is constructed during login and
+ * holds per-request state. This functionality allows plugins/extensions to contribute specific data
+ * to {@link CurrentUser} that is unknown to Gerrit core.
+ */
+public class PropertyMap {
+  /** Empty instance to be referenced once per JVM. */
+  public static final PropertyMap EMPTY = builder().build();
+
+  /**
+   * Typed key for {@link PropertyMap}. This class intentionally does not implement {@link
+   * Object#equals(Object)} and {@link Object#hashCode()} so that the same instance has to be used
+   * to retrieve a stored value.
+   *
+   * <p>We require the exact same key instance because {@link PropertyMap} is implemented in a
+   * type-safe fashion by using Java generics to guarantee the return type. The generic type can't
+   * be recovered at runtime, so there is no way to just use the type's full name as key - we'd have
+   * to pass additional arguments. At the same time, this is in-line with how we'd want callers to
+   * use {@link PropertyMap}: Instantiate a static, per-JVM key that is reused when setting and
+   * getting values.
+   */
+  public static class Key<T> {}
+
+  public static <T> Key<T> key() {
+    return new Key<>();
+  }
+
+  public static class Builder {
+    private ImmutableMap.Builder<Object, Object> mutableMap;
+
+    private Builder() {
+      this.mutableMap = ImmutableMap.builder();
+    }
+
+    /** Adds the provided {@code value} to the {@link PropertyMap} that is being built. */
+    public <T> Builder put(Key<T> key, T value) {
+      mutableMap.put(key, value);
+      return this;
+    }
+
+    /** Builds and returns an immutable {@link PropertyMap}. */
+    public PropertyMap build() {
+      return new PropertyMap(mutableMap.build());
+    }
+  }
+
+  private final ImmutableMap<Object, Object> map;
+
+  private PropertyMap(ImmutableMap<Object, Object> map) {
+    this.map = map;
+  }
+
+  /** Returns a new {@link Builder} instance. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /** Returns the requested value wrapped as {@link Optional}. */
+  @SuppressWarnings("unchecked")
+  public <T> Optional<T> get(Key<T> key) {
+    return Optional.ofNullable((T) map.get(key));
+  }
+}
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 88b0b21..785cd1c 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -86,31 +86,43 @@
   /**
    * @param project Project name.
    * @param commit SHA1 of commit.
+   * @param commitMessage the commit message of the commit.
+   * @param branchName branch of the commit.
    * @return Links for patch sets.
    */
-  public ImmutableList<WebLinkInfo> getPatchSetLinks(Project.NameKey project, String commit) {
-    return filterLinks(patchSetLinks, webLink -> webLink.getPatchSetWebLink(project.get(), commit));
+  public ImmutableList<WebLinkInfo> getPatchSetLinks(
+      Project.NameKey project, String commit, String commitMessage, String branchName) {
+    return filterLinks(
+        patchSetLinks,
+        webLink -> webLink.getPatchSetWebLink(project.get(), commit, commitMessage, branchName));
   }
 
   /**
    * @param project Project name.
    * @param revision SHA1 of the parent revision.
+   * @param commitMessage the commit message of the parent revision.
+   * @param branchName branch of the revision (and parent revision).
    * @return Links for patch sets.
    */
-  public ImmutableList<WebLinkInfo> getParentLinks(Project.NameKey project, String revision) {
-    return filterLinks(parentLinks, webLink -> webLink.getParentWebLink(project.get(), revision));
+  public ImmutableList<WebLinkInfo> getParentLinks(
+      Project.NameKey project, String revision, String commitMessage, String branchName) {
+    return filterLinks(
+        parentLinks,
+        webLink -> webLink.getParentWebLink(project.get(), revision, commitMessage, branchName));
   }
 
   /**
    * @param project Project name.
-   * @param revision SHA1 of revision.
+   * @param revision Name of the revision (e.g. branch or commit ID)
+   * @param hash SHA1 of revision.
    * @param file File name.
    * @return Links for files.
    */
-  public ImmutableList<WebLinkInfo> getFileLinks(String project, String revision, String file) {
+  public ImmutableList<WebLinkInfo> getFileLinks(
+      String project, String revision, String hash, String file) {
     return Patch.isMagic(file)
         ? ImmutableList.of()
-        : filterLinks(fileLinks, webLink -> webLink.getFileWebLink(project, revision, file));
+        : filterLinks(fileLinks, webLink -> webLink.getFileWebLink(project, revision, hash, file));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index f68a1c7..93e04880 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -19,6 +19,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
@@ -113,20 +114,21 @@
                 ? defaultPreferenceCache.get(ref.getObjectId())
                 : DefaultPreferencesCache.EMPTY;
 
-        ImmutableMap.Builder<Account.Id, AccountState> result = ImmutableMap.builder();
+        Set<CachedAccountDetails.Key> keys =
+            Sets.newLinkedHashSetWithExpectedSize(accountIds.size());
         for (Account.Id id : accountIds) {
           Ref userRef = allUsers.exactRef(RefNames.refsUsers(id));
           if (userRef == null) {
             continue;
           }
-
+          keys.add(CachedAccountDetails.Key.create(id, userRef.getObjectId()));
+        }
+        ImmutableMap.Builder<Account.Id, AccountState> result = ImmutableMap.builder();
+        for (Map.Entry<CachedAccountDetails.Key, CachedAccountDetails> account :
+            accountDetailsCache.getAll(keys).entrySet()) {
           result.put(
-              id,
-              AccountState.forCachedAccount(
-                  accountDetailsCache.get(
-                      CachedAccountDetails.Key.create(id, userRef.getObjectId())),
-                  defaultPreferences,
-                  externalIds));
+              account.getKey().accountId(),
+              AccountState.forCachedAccount(account.getValue(), defaultPreferences, externalIds));
         }
         return result.build();
       }
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 4dfeab5..2665b9a 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -495,7 +495,7 @@
   }
 
   /**
-   * Resolves all accounts matching the input string.
+   * Resolves all accounts matching the input string, visible to the current user.
    *
    * <p>The following input formats are recognized:
    *
diff --git a/java/com/google/gerrit/server/account/GroupBackend.java b/java/com/google/gerrit/server/account/GroupBackend.java
index 545da6e..d6360c5 100644
--- a/java/com/google/gerrit/server/account/GroupBackend.java
+++ b/java/com/google/gerrit/server/account/GroupBackend.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.ProjectState;
 import java.util.Collection;
 
@@ -42,7 +42,7 @@
   Collection<GroupReference> suggest(String name, @Nullable ProjectState project);
 
   /** @return the group membership checker for the backend. */
-  GroupMembership membershipsOf(IdentifiedUser user);
+  GroupMembership membershipsOf(CurrentUser user);
 
   /** @return {@code true} if the group with the given UUID is visible to all registered users. */
   boolean isVisibleToAll(AccountGroup.UUID uuid);
diff --git a/java/com/google/gerrit/server/account/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
index 90d3aa9..aaae95a 100644
--- a/java/com/google/gerrit/server/account/GroupCache.java
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.entities.InternalGroup;
+import java.util.Collection;
+import java.util.Map;
 import java.util.Optional;
 
 /** Tracks group objects in memory for efficient access. */
@@ -48,6 +50,18 @@
   Optional<InternalGroup> get(AccountGroup.UUID groupUuid);
 
   /**
+   * Returns a {@code Map} of {@code AccountGroup.UUID} to {@code InternalGroup} for the given
+   * groups UUIDs. If not cached yet the groups are loaded. If a group can't be loaded (e.g. because
+   * it is missing), the entry will be missing from the result.
+   *
+   * @param groupUuids UUIDs of the groups that should be retrieved
+   * @return {@code Map} of {@code AccountGroup.UUID} to {@code InternalGroup} instances for the
+   *     given group UUIDs, if a group can't be loaded (e.g. because it is missing), the entry will
+   *     be missing from the result.
+   */
+  Map<AccountGroup.UUID, InternalGroup> get(Collection<AccountGroup.UUID> groupUuids);
+
+  /**
    * Removes the association of the given ID with a group.
    *
    * <p>The next call to {@link #get(AccountGroup.Id)} won't provide a cached value.
@@ -88,4 +102,7 @@
    * @param groupUuid the UUID of a possibly associated group
    */
   void evict(AccountGroup.UUID groupUuid);
+
+  /** @see #evict(AccountGroup.UUID); */
+  void evict(Collection<AccountGroup.UUID> groupUuid);
 }
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index fe22028..eaec9ba 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -14,12 +14,27 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+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.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
+import com.google.gerrit.server.cache.serialize.entities.InternalGroupSerializer;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
@@ -31,8 +46,19 @@
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+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;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 
 /** Tracks group objects in memory for efficient access. */
 @Singleton
@@ -42,6 +68,7 @@
   private static final String BYID_NAME = "groups";
   private static final String BYNAME_NAME = "groups_byname";
   private static final String BYUUID_NAME = "groups_byuuid";
+  private static final String BYUUID_NAME_PERSISTED = "groups_byuuid_persisted";
 
   public static Module module() {
     return new CacheModule() {
@@ -55,9 +82,35 @@
             .maximumWeight(Long.MAX_VALUE)
             .loader(ByNameLoader.class);
 
+        // We split the group cache into two parts for performance reasons:
+        // 1) An in-memory part that has only the group ref uuid as key.
+        // 2) A persisted part that has the group ref uuid and sha1 of the ref as key.
+        //
+        // When loading dashboards or returning change query results we potentially
+        // need to access many groups.
+        // We want the persisted cache to be immutable and we want it to be impossible that a
+        // value for a given key is out of date. We therefore require the sha-1 in the key. That
+        // is in line with the rest of the caches in Gerrit.
+        //
+        // Splitting the cache into two chunks internally in this class allows us to retain
+        // the existing performance guarantees of not requiring reads for the repo for values
+        // cached in-memory but also to persist the cache which leads to a much improved
+        // cold-start behavior and in-memory miss latency.
+
         cache(BYUUID_NAME, String.class, new TypeLiteral<Optional<InternalGroup>>() {})
             .maximumWeight(Long.MAX_VALUE)
-            .loader(ByUUIDLoader.class);
+            .loader(ByUUIDInMemoryLoader.class);
+
+        persist(
+                BYUUID_NAME_PERSISTED,
+                Cache.GroupKeyProto.class,
+                new TypeLiteral<InternalGroup>() {})
+            .loader(PersistedByUUIDLoader.class)
+            .keySerializer(new ProtobufSerializer<>(Cache.GroupKeyProto.parser()))
+            .valueSerializer(PersistedInternalGroupSerializer.INSTANCE)
+            .diskLimit(1 << 30) // 1 GiB
+            .version(1)
+            .maximumWeight(0);
 
         bind(GroupCacheImpl.class);
         bind(GroupCache.class).to(GroupCacheImpl.class);
@@ -117,6 +170,20 @@
   }
 
   @Override
+  public Map<AccountGroup.UUID, InternalGroup> get(Collection<AccountGroup.UUID> groupUuids) {
+    try {
+      Set<String> groupUuidsStringSet =
+          groupUuids.stream().map(u -> u.get()).collect(toImmutableSet());
+      return byUUID.getAll(groupUuidsStringSet).entrySet().stream()
+          .filter(g -> g.getValue().isPresent())
+          .collect(toImmutableMap(g -> AccountGroup.uuid(g.getKey()), g -> g.getValue().get()));
+    } catch (ExecutionException e) {
+      logger.atWarning().withCause(e).log("Cannot look up groups %s by uuids", groupUuids);
+      return ImmutableMap.of();
+    }
+  }
+
+  @Override
   public void evict(AccountGroup.Id groupId) {
     if (groupId != null) {
       logger.atFine().log("Evict group %s by ID", groupId.get());
@@ -140,6 +207,14 @@
     }
   }
 
+  @Override
+  public void evict(Collection<AccountGroup.UUID> groupUuids) {
+    if (groupUuids != null && !groupUuids.isEmpty()) {
+      logger.atFine().log("Evict groups %s by UUID", groupUuids);
+      byUUID.invalidateAll(groupUuids);
+    }
+  }
+
   static class ByIdLoader extends CacheLoader<AccountGroup.Id, Optional<InternalGroup>> {
     private final Provider<InternalGroupQuery> groupQueryProvider;
 
@@ -150,7 +225,7 @@
 
     @Override
     public Optional<InternalGroup> load(AccountGroup.Id key) throws Exception {
-      try (TraceTimer timer =
+      try (TraceTimer ignored =
           TraceContext.newTimer(
               "Loading group by ID", Metadata.builder().groupId(key.get()).build())) {
         return groupQueryProvider.get().byId(key);
@@ -168,7 +243,7 @@
 
     @Override
     public Optional<InternalGroup> load(String name) throws Exception {
-      try (TraceTimer timer =
+      try (TraceTimer ignored =
           TraceContext.newTimer(
               "Loading group by name", Metadata.builder().groupName(name).build())) {
         return groupQueryProvider.get().byName(AccountGroup.nameKey(name));
@@ -176,21 +251,108 @@
     }
   }
 
-  static class ByUUIDLoader extends CacheLoader<String, Optional<InternalGroup>> {
-    private final Groups groups;
+  static class ByUUIDInMemoryLoader extends CacheLoader<String, Optional<InternalGroup>> {
+    private final LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedCache;
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
 
     @Inject
-    ByUUIDLoader(Groups groups) {
-      this.groups = groups;
+    ByUUIDInMemoryLoader(
+        @Named(BYUUID_NAME_PERSISTED)
+            LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedCache,
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName) {
+      this.persistedCache = persistedCache;
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
     }
 
     @Override
     public Optional<InternalGroup> load(String uuid) throws Exception {
-      try (TraceTimer timer =
-          TraceContext.newTimer(
-              "Loading group by UUID", Metadata.builder().groupUuid(uuid).build())) {
-        return groups.getGroup(AccountGroup.uuid(uuid));
+      return loadAll(ImmutableSet.of(uuid)).get(uuid);
+    }
+
+    @Override
+    public Map<String, Optional<InternalGroup>> loadAll(Iterable<? extends String> uuids)
+        throws Exception {
+      Map<String, Optional<InternalGroup>> toReturn = new HashMap<>();
+      if (Iterables.isEmpty(uuids)) {
+        return toReturn;
       }
+      Iterator<? extends String> uuidIterator = uuids.iterator();
+      List<Cache.GroupKeyProto> keyList = new ArrayList<>();
+      try (TraceTimer ignored =
+              TraceContext.newTimer(
+                  "Loading group from serialized cache",
+                  Metadata.builder().cacheName(BYUUID_NAME_PERSISTED).build());
+          Repository allUsers = repoManager.openRepository(allUsersName)) {
+        while (uuidIterator.hasNext()) {
+          String currentUuid = uuidIterator.next();
+          String ref = RefNames.refsGroups(AccountGroup.uuid(currentUuid));
+          Ref sha1 = allUsers.exactRef(ref);
+          if (sha1 == null) {
+            toReturn.put(currentUuid, Optional.empty());
+            continue;
+          }
+          Cache.GroupKeyProto key =
+              Cache.GroupKeyProto.newBuilder()
+                  .setUuid(currentUuid)
+                  .setRevision(ObjectIdConverter.create().toByteString(sha1.getObjectId()))
+                  .build();
+          keyList.add(key);
+        }
+      }
+      persistedCache.getAll(keyList).entrySet().stream()
+          .forEach(g -> toReturn.put(g.getKey().getUuid(), Optional.of(g.getValue())));
+      return toReturn;
+    }
+  }
+
+  static class PersistedByUUIDLoader extends CacheLoader<Cache.GroupKeyProto, InternalGroup> {
+    private final Groups groups;
+
+    @Inject
+    PersistedByUUIDLoader(Groups groups) {
+      this.groups = groups;
+    }
+
+    @Override
+    public InternalGroup load(Cache.GroupKeyProto key) throws Exception {
+      try (TraceTimer ignored =
+          TraceContext.newTimer(
+              "Loading group by UUID", Metadata.builder().groupUuid(key.getUuid()).build())) {
+        ObjectId sha1 = ObjectIdConverter.create().fromByteString(key.getRevision());
+        Optional<InternalGroup> loadedGroup =
+            groups.getGroup(AccountGroup.uuid(key.getUuid()), sha1);
+        if (!loadedGroup.isPresent()) {
+          throw new IllegalStateException(
+              String.format(
+                  "group %s should have the sha-1 %s, but " + "it was not found",
+                  key.getUuid(), sha1.getName()));
+        }
+        return loadedGroup.get();
+      }
+    }
+  }
+
+  private enum PersistedInternalGroupSerializer implements CacheSerializer<InternalGroup> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(InternalGroup value) {
+      if (value == null) {
+        return new byte[0];
+      }
+      return Protos.toByteArray(InternalGroupSerializer.serialize(value));
+    }
+
+    @Override
+    public InternalGroup deserialize(byte[] in) {
+      if (Strings.fromByteArray(in).isEmpty()) {
+        return null;
+      }
+      return InternalGroupSerializer.deserialize(
+          Protos.parseUnchecked(Cache.InternalGroupProto.parser(), in));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index 073ff84..f203240 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -25,13 +25,13 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalGroupsProto;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalGroupsProto.ExternalGroupProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
diff --git a/java/com/google/gerrit/server/account/GroupMembers.java b/java/com/google/gerrit/server/account/GroupMembers.java
index c03ffd0..3ed82a1 100644
--- a/java/com/google/gerrit/server/account/GroupMembers.java
+++ b/java/com/google/gerrit/server/account/GroupMembers.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.NoSuchProjectException;
diff --git a/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
index 6dc7976..8cec8bf 100644
--- a/java/com/google/gerrit/server/account/IncludingGroupMembership.java
+++ b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
@@ -14,19 +14,19 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.server.CurrentUser;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -40,18 +40,18 @@
  */
 public class IncludingGroupMembership implements GroupMembership {
   public interface Factory {
-    IncludingGroupMembership create(IdentifiedUser user);
+    IncludingGroupMembership create(CurrentUser user);
   }
 
   private final GroupCache groupCache;
   private final GroupIncludeCache includeCache;
-  private final IdentifiedUser user;
+  private final CurrentUser user;
   private final Map<AccountGroup.UUID, Boolean> memberOf;
   private Set<AccountGroup.UUID> knownGroups;
 
   @Inject
   IncludingGroupMembership(
-      GroupCache groupCache, GroupIncludeCache includeCache, @Assisted IdentifiedUser user) {
+      GroupCache groupCache, GroupIncludeCache includeCache, @Assisted CurrentUser user) {
     this.groupCache = groupCache;
     this.includeCache = includeCache;
     this.user = user;
@@ -82,6 +82,9 @@
     }
 
     if (tryExpanding) {
+      Set<AccountGroup.UUID> queryIdsSet = new HashSet<>();
+      queryIds.forEach(i -> queryIdsSet.add(i));
+      Map<AccountGroup.UUID, InternalGroup> groups = groupCache.get(queryIdsSet);
       for (AccountGroup.UUID id : queryIds) {
         if (memberOf.containsKey(id)) {
           // Membership was earlier proven to be false.
@@ -89,15 +92,15 @@
         }
 
         memberOf.put(id, false);
-        Optional<InternalGroup> group = groupCache.get(id);
-        if (!group.isPresent()) {
+        InternalGroup group = groups.get(id);
+        if (group == null) {
           continue;
         }
-        if (group.get().getMembers().contains(user.getAccountId())) {
+        if (user.isIdentifiedUser() && group.getMembers().contains(user.getAccountId())) {
           memberOf.put(id, true);
           return true;
         }
-        if (search(group.get().getSubgroups())) {
+        if (search(group.getSubgroups())) {
           memberOf.put(id, true);
           return true;
         }
@@ -124,7 +127,10 @@
 
   private ImmutableSet<AccountGroup.UUID> computeKnownGroups() {
     GroupMembership membership = user.getEffectiveGroups();
-    Collection<AccountGroup.UUID> direct = includeCache.getGroupsWithMember(user.getAccountId());
+    Collection<AccountGroup.UUID> direct =
+        user.isIdentifiedUser()
+            ? includeCache.getGroupsWithMember(user.getAccountId())
+            : ImmutableList.of();
     direct.forEach(groupUuid -> memberOf.put(groupUuid, true));
     Set<AccountGroup.UUID> r = Sets.newHashSet(direct);
     r.remove(null);
diff --git a/java/com/google/gerrit/server/account/InternalGroupBackend.java b/java/com/google/gerrit/server/account/InternalGroupBackend.java
index c520c96..91fe701 100644
--- a/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -20,8 +20,8 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsNoteDbConsistencyChecker;
@@ -97,7 +97,7 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
+  public GroupMembership membershipsOf(CurrentUser user) {
     return groupMembershipFactory.create(user);
   }
 
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifier.java b/java/com/google/gerrit/server/account/ServiceUserClassifier.java
index c8314c8..2d2a646 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifier.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifier.java
@@ -17,6 +17,11 @@
 import com.google.gerrit.entities.Account;
 
 public interface ServiceUserClassifier {
+  /**
+   * Name of the Service Users group used by this class to determine whether an account is a service
+   * user; if an account is a part of this group, that account is considered a service user.
+   */
+  public static final String SERVICE_USERS = "Service Users";
   /** Returns {@code true} if the given user is considered a {@code Service User} user. */
   boolean isServiceUser(Account.Id user);
 
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
index 255467c..27ac9f4 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
@@ -17,8 +17,8 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
 import com.google.inject.Scopes;
@@ -63,7 +63,7 @@
 
   @Override
   public boolean isServiceUser(Account.Id user) {
-    Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey("Service Users"));
+    Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey(SERVICE_USERS));
     if (!maybeGroup.isPresent()) {
       return false;
     }
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index a35b0ac..5bd9bea 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -94,14 +94,14 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
+  public GroupMembership membershipsOf(CurrentUser user) {
     return new UniversalGroupMembership(user);
   }
 
   private class UniversalGroupMembership implements GroupMembership {
     private final Map<GroupBackend, GroupMembership> memberships;
 
-    private UniversalGroupMembership(IdentifiedUser user) {
+    private UniversalGroupMembership(CurrentUser user) {
       ImmutableMap.Builder<GroupBackend, GroupMembership> builder = ImmutableMap.builder();
       backends.runEach(g -> builder.put(g, g.membershipsOf(user)));
       this.memberships = builder.build();
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 235537c..30021e6 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -61,6 +61,8 @@
  * <p>Other comment lines are ignored on read, and are not written back when the file is modified.
  */
 public class VersionedAuthorizedKeys extends VersionedMetaData {
+
+  /** Read/write SSH keys by user ID. */
   @Singleton
   public static class Accessor {
     private final GitRepositoryManager repoManager;
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 4f85412..1eee10f 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -365,8 +365,7 @@
   @Override
   public void starChange(String changeId) throws RestApiException {
     try {
-      starredChangesCreate.apply(
-          account, IdString.fromUrl(changeId), new StarredChanges.EmptyInput());
+      starredChangesCreate.apply(account, IdString.fromUrl(changeId), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot star change", e);
     }
@@ -378,7 +377,7 @@
       ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
       AccountResource.StarredChange starredChange =
           new AccountResource.StarredChange(account.getUser(), rsrc);
-      starredChangesDelete.apply(starredChange, new StarredChanges.EmptyInput());
+      starredChangesDelete.apply(starredChange, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot unstar change", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 0992bcd..0b340b8 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+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.CommitMessageInput;
@@ -80,6 +81,7 @@
 import com.google.gerrit.server.restapi.change.GetAssignee;
 import com.google.gerrit.server.restapi.change.GetChange;
 import com.google.gerrit.server.restapi.change.GetHashtags;
+import com.google.gerrit.server.restapi.change.GetMetaDiff;
 import com.google.gerrit.server.restapi.change.GetPastAssignees;
 import com.google.gerrit.server.restapi.change.GetPureRevert;
 import com.google.gerrit.server.restapi.change.GetTopic;
@@ -149,6 +151,7 @@
   private final ChangeIncludedIn includedIn;
   private final PostReviewers postReviewers;
   private final Provider<GetChange> getChangeProvider;
+  private final Provider<GetMetaDiff> getMetaDiffProvider;
   private final PostHashtags postHashtags;
   private final GetHashtags getHashtags;
   private final AttentionSet attentionSet;
@@ -160,7 +163,7 @@
   private final DeleteAssignee deleteAssignee;
   private final Provider<ListChangeComments> listCommentsProvider;
   private final ListChangeRobotComments listChangeRobotComments;
-  private final ListChangeDrafts listDrafts;
+  private final Provider<ListChangeDrafts> listDraftsProvider;
   private final ChangeEditApiImpl.Factory changeEditApi;
   private final Check check;
   private final Index index;
@@ -177,6 +180,8 @@
   private final Provider<GetPureRevert> getPureRevertProvider;
   private final StarredChangesUtil stars;
   private final DynamicOptionParser dynamicOptionParser;
+  private final Injector injector;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
   ChangeApiImpl(
@@ -202,6 +207,7 @@
       ChangeIncludedIn includedIn,
       PostReviewers postReviewers,
       Provider<GetChange> getChangeProvider,
+      Provider<GetMetaDiff> getMetaDiffProvider,
       PostHashtags postHashtags,
       GetHashtags getHashtags,
       AttentionSet attentionSet,
@@ -213,7 +219,7 @@
       DeleteAssignee deleteAssignee,
       Provider<ListChangeComments> listCommentsProvider,
       ListChangeRobotComments listChangeRobotComments,
-      ListChangeDrafts listDrafts,
+      Provider<ListChangeDrafts> listDraftsProvider,
       ChangeEditApiImpl.Factory changeEditApi,
       Check check,
       Index index,
@@ -230,7 +236,9 @@
       Provider<GetPureRevert> getPureRevertProvider,
       StarredChangesUtil stars,
       DynamicOptionParser dynamicOptionParser,
-      @Assisted ChangeResource change) {
+      @Assisted ChangeResource change,
+      Injector injector,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.changeApi = changeApi;
     this.revert = revert;
     this.revertSubmission = revertSubmission;
@@ -253,6 +261,7 @@
     this.includedIn = includedIn;
     this.postReviewers = postReviewers;
     this.getChangeProvider = getChangeProvider;
+    this.getMetaDiffProvider = getMetaDiffProvider;
     this.postHashtags = postHashtags;
     this.getHashtags = getHashtags;
     this.attentionSet = attentionSet;
@@ -264,7 +273,7 @@
     this.deleteAssignee = deleteAssignee;
     this.listCommentsProvider = listCommentsProvider;
     this.listChangeRobotComments = listChangeRobotComments;
-    this.listDrafts = listDrafts;
+    this.listDraftsProvider = listDraftsProvider;
     this.changeEditApi = changeEditApi;
     this.check = check;
     this.index = index;
@@ -282,6 +291,8 @@
     this.stars = stars;
     this.dynamicOptionParser = dynamicOptionParser;
     this.change = change;
+    this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
   }
 
   @Override
@@ -500,10 +511,10 @@
   public ChangeInfo get(
       EnumSet<ListChangesOption> options, ImmutableListMultimap<String, String> pluginOptions)
       throws RestApiException {
-    try {
+    try (DynamicOptions dynamicOptions = new DynamicOptions(injector, dynamicBeans)) {
       GetChange getChange = getChangeProvider.get();
       options.forEach(getChange::addOption);
-      dynamicOptionParser.parseDynamicOptions(getChange, pluginOptions);
+      dynamicOptionParser.parseDynamicOptions(getChange, pluginOptions, dynamicOptions);
       return getChange.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve change", e);
@@ -511,6 +522,25 @@
   }
 
   @Override
+  public ChangeInfoDifference metaDiff(
+      @Nullable String oldMetaRevId,
+      @Nullable String newMetaRevId,
+      EnumSet<ListChangesOption> options,
+      ImmutableListMultimap<String, String> pluginOptions)
+      throws RestApiException {
+    try (DynamicOptions dynamicOptions = new DynamicOptions(injector, dynamicBeans)) {
+      GetMetaDiff metaDiff = getMetaDiffProvider.get();
+      metaDiff.setOldMetaRevId(oldMetaRevId);
+      metaDiff.setNewMetaRevId(newMetaRevId);
+      options.forEach(metaDiff::addOption);
+      dynamicOptionParser.parseDynamicOptions(metaDiff, pluginOptions, dynamicOptions);
+      return metaDiff.apply(change).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve metaDiff", e);
+    }
+  }
+
+  @Override
   public ChangeEditApi edit() throws RestApiException {
     return changeEditApi.create(change);
   }
@@ -599,13 +629,14 @@
   }
 
   @Override
-  public CommentsRequest commentsRequest() throws RestApiException {
+  public CommentsRequest commentsRequest() {
     return new CommentsRequest() {
       @Override
       public Map<String, List<CommentInfo>> get() throws RestApiException {
         try {
           ListChangeComments listComments = listCommentsProvider.get();
           listComments.setContext(this.getContext());
+          listComments.setContextPadding(this.getContextPadding());
           return listComments.apply(change).value();
         } catch (Exception e) {
           throw asRestApiException("Cannot get comments", e);
@@ -617,6 +648,7 @@
         try {
           ListChangeComments listComments = listCommentsProvider.get();
           listComments.setContext(this.getContext());
+          listComments.setContextPadding(this.getContextPadding());
           return listComments.getComments(change);
         } catch (Exception e) {
           throw asRestApiException("Cannot get comments", e);
@@ -635,21 +667,32 @@
   }
 
   @Override
-  public Map<String, List<CommentInfo>> drafts() throws RestApiException {
-    try {
-      return listDrafts.apply(change).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get drafts", e);
-    }
-  }
+  public DraftsRequest draftsRequest() {
+    return new DraftsRequest() {
+      @Override
+      public Map<String, List<CommentInfo>> get() throws RestApiException {
+        try {
+          ListChangeDrafts listDrafts = listDraftsProvider.get();
+          listDrafts.setContext(this.getContext());
+          listDrafts.setContextPadding(this.getContextPadding());
+          return listDrafts.apply(change).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get drafts", e);
+        }
+      }
 
-  @Override
-  public List<CommentInfo> draftsAsList() throws RestApiException {
-    try {
-      return listDrafts.getComments(change);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get drafts", e);
-    }
+      @Override
+      public List<CommentInfo> getAsList() throws RestApiException {
+        try {
+          ListChangeDrafts listDrafts = listDraftsProvider.get();
+          listDrafts.setContext(this.getContext());
+          listDrafts.setContextPadding(this.getContextPadding());
+          return listDrafts.getComments(change);
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get drafts", e);
+        }
+      }
+    };
   }
 
   @Override
@@ -759,23 +802,18 @@
   @Singleton
   static class DynamicOptionParser {
     private final CmdLineParser.Factory cmdLineParserFactory;
-    private final Injector injector;
-    private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
     @Inject
-    DynamicOptionParser(
-        CmdLineParser.Factory cmdLineParserFactory,
-        Injector injector,
-        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+    DynamicOptionParser(CmdLineParser.Factory cmdLineParserFactory) {
       this.cmdLineParserFactory = cmdLineParserFactory;
-      this.injector = injector;
-      this.dynamicBeans = dynamicBeans;
     }
 
-    void parseDynamicOptions(Object bean, ListMultimap<String, String> pluginOptions)
+    void parseDynamicOptions(
+        Object bean, ListMultimap<String, String> pluginOptions, DynamicOptions dynamicOptions)
         throws BadRequestException {
       CmdLineParser clp = cmdLineParserFactory.create(bean);
-      DynamicOptions dynamicOptions = new DynamicOptions(bean, injector, dynamicBeans);
+      dynamicOptions.setBean(bean);
+      dynamicOptions.startLifecycleListeners();
       dynamicOptions.parseDynamicBeans(clp);
       dynamicOptions.setDynamicBeans();
       dynamicOptions.onBeanParseStart();
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index d6ef61c..0596524 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -26,15 +26,18 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.api.changes.ChangeApiImpl.DynamicOptionParser;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.CreateChange;
 import com.google.gerrit.server.restapi.change.QueryChanges;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.List;
@@ -46,6 +49,8 @@
   private final CreateChange createChange;
   private final DynamicOptionParser dynamicOptionParser;
   private final Provider<QueryChanges> queryProvider;
+  private final Injector injector;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
   ChangesImpl(
@@ -53,12 +58,16 @@
       ChangeApiImpl.Factory api,
       CreateChange createChange,
       DynamicOptionParser dynamicOptionParser,
-      Provider<QueryChanges> queryProvider) {
+      Provider<QueryChanges> queryProvider,
+      Injector injector,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.changes = changes;
     this.api = api;
     this.createChange = createChange;
     this.dynamicOptionParser = dynamicOptionParser;
     this.queryProvider = queryProvider;
+    this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
   }
 
   @Override
@@ -123,34 +132,36 @@
   }
 
   private List<ChangeInfo> get(QueryRequest q) throws RestApiException {
-    QueryChanges qc = queryProvider.get();
-    if (q.getQuery() != null) {
-      qc.addQuery(q.getQuery());
-    }
-    qc.setLimit(q.getLimit());
-    qc.setStart(q.getStart());
-    qc.setNoLimit(q.getNoLimit());
-    for (ListChangesOption option : q.getOptions()) {
-      qc.addOption(option);
-    }
-    dynamicOptionParser.parseDynamicOptions(qc, q.getPluginOptions());
-
-    try {
-      List<?> result = qc.apply(TopLevelResource.INSTANCE).value();
-      if (result.isEmpty()) {
-        return ImmutableList.of();
+    try (DynamicOptions dynamicOptions = new DynamicOptions(injector, dynamicBeans)) {
+      QueryChanges qc = queryProvider.get();
+      if (q.getQuery() != null) {
+        qc.addQuery(q.getQuery());
       }
+      qc.setLimit(q.getLimit());
+      qc.setStart(q.getStart());
+      qc.setNoLimit(q.getNoLimit());
+      for (ListChangesOption option : q.getOptions()) {
+        qc.addOption(option);
+      }
+      dynamicOptionParser.parseDynamicOptions(qc, q.getPluginOptions(), dynamicOptions);
 
-      // Check type safety of result; the extension API should be safer than the
-      // REST API in this case, since it's intended to be used in Java.
-      Object first = requireNonNull(result.iterator().next());
-      checkState(first instanceof ChangeInfo);
-      @SuppressWarnings("unchecked")
-      List<ChangeInfo> infos = (List<ChangeInfo>) result;
+      try {
+        List<?> result = qc.apply(TopLevelResource.INSTANCE).value();
+        if (result.isEmpty()) {
+          return ImmutableList.of();
+        }
 
-      return ImmutableList.copyOf(infos);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot query changes", e);
+        // Check type safety of result; the extension API should be safer than the
+        // REST API in this case, since it's intended to be used in Java.
+        Object first = requireNonNull(result.iterator().next());
+        checkState(first instanceof ChangeInfo);
+        @SuppressWarnings("unchecked")
+        List<ChangeInfo> infos = (List<ChangeInfo>) result;
+
+        return ImmutableList.copyOf(infos);
+      } catch (Exception e) {
+        throw asRestApiException("Cannot query changes", e);
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 04d2e8ae..573f2f5 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -106,7 +106,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-class RevisionApiImpl implements RevisionApi {
+class RevisionApiImpl extends RevisionApi.NotImplemented {
   interface Factory {
     RevisionApiImpl create(RevisionResource r);
   }
@@ -282,7 +282,16 @@
   @Override
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
-      return changes.id(rebase.apply(revision, in).value()._number);
+      return changes.id(rebaseAsInfo(in)._number);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase ps", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo rebaseAsInfo(RebaseInput in) throws RestApiException {
+    try {
+      return rebase.apply(revision, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot rebase ps", e);
     }
@@ -678,11 +687,6 @@
   }
 
   @Override
-  public String etag() throws RestApiException {
-    return revisionActions.getETag(revision);
-  }
-
-  @Override
   public BinaryResult getArchive(ArchiveFormat format) throws RestApiException {
     GetArchive getArchive = getArchiveProvider.get();
     getArchive.setFormat(format != null ? format.name().toLowerCase(Locale.US) : null);
diff --git a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index c7cca6f..78f5c5f 100644
--- a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -126,6 +127,10 @@
 
   private BranchResource resource()
       throws RestApiException, IOException, PermissionBackendException {
-    return branches.parse(project, IdString.fromDecoded(ref));
+    String refName = ref;
+    if (RefNames.isRefsUsersSelf(ref, project.getProjectState().isAllUsers())) {
+      refName = RefNames.refsUsers(project.getUser().getAccountId());
+    }
+    return branches.parse(project, IdString.fromDecoded(refName));
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
index 5c7921a..e055a00 100644
--- a/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
@@ -22,13 +22,16 @@
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.CommitResource;
 import com.google.gerrit.server.restapi.change.CherryPickCommit;
 import com.google.gerrit.server.restapi.project.CommitIncludedIn;
+import com.google.gerrit.server.restapi.project.FilesInCommitCollection;
 import com.google.gerrit.server.restapi.project.GetCommit;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
 
 public class CommitApiImpl implements CommitApi {
   public interface Factory {
@@ -40,6 +43,7 @@
   private final CherryPickCommit cherryPickCommit;
   private final CommitIncludedIn includedIn;
   private final CommitResource commitResource;
+  private final FilesInCommitCollection.ListFiles listFiles;
 
   @Inject
   CommitApiImpl(
@@ -47,11 +51,13 @@
       GetCommit getCommit,
       CherryPickCommit cherryPickCommit,
       CommitIncludedIn includedIn,
+      FilesInCommitCollection.ListFiles listFiles,
       @Assisted CommitResource commitResource) {
     this.changes = changes;
     this.getCommit = getCommit;
     this.cherryPickCommit = cherryPickCommit;
     this.includedIn = includedIn;
+    this.listFiles = listFiles;
     this.commitResource = commitResource;
   }
 
@@ -81,4 +87,13 @@
       throw asRestApiException("Could not extract IncludedIn data", e);
     }
   }
+
+  @Override
+  public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+    try {
+      return listFiles.setParent(parentNum).apply(commitResource).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
index 66d0224..0cedf19 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
@@ -17,8 +17,8 @@
 import static com.google.gerrit.util.cli.Localizable.localizable;
 
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
index e88f6df..d3ed3e4 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
@@ -19,10 +19,10 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
index 0870786..3faa259 100644
--- a/java/com/google/gerrit/server/audit/BUILD
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -46,7 +46,6 @@
         "//lib:guava-retrying",
         "//lib:jgit",
         "//lib:jgit-archive",
-        "//lib:jsch",
         "//lib:juniversalchardet",
         "//lib:mime-util",
         "//lib:protobuf",
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
new file mode 100644
index 0000000..9553acc
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Base class for persistent cache factory. If the cache.directory property is unset, or disk limit
+ * is zero or negative, it will fall back to in-memory only caches.
+ */
+public abstract class PersistentCacheBaseFactory implements PersistentCacheFactory {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  protected final MemoryCacheFactory memCacheFactory;
+  protected final Path cacheDir;
+  protected boolean diskEnabled;
+  protected final Config config;
+
+  public PersistentCacheBaseFactory(
+      MemoryCacheFactory memCacheFactory, @GerritServerConfig Config config, SitePaths site) {
+    this.cacheDir = getCacheDir(site, config.getString("cache", null, "directory"));
+    this.diskEnabled = cacheDir != null;
+    this.memCacheFactory = memCacheFactory;
+    this.config = config;
+  }
+
+  protected abstract <K, V> Cache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, long diskLimit, CacheBackend backend);
+
+  protected abstract <K, V> LoadingCache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long diskLimit, CacheBackend backend);
+
+  @Override
+  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in, CacheBackend backend) {
+    long limit = getDiskLimit(in);
+
+    if (isInMemoryCache(limit)) {
+      return memCacheFactory.build(in, backend);
+    }
+
+    return buildImpl(in, limit, backend);
+  }
+
+  @Override
+  public <K, V> LoadingCache<K, V> build(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, CacheBackend backend) {
+    long limit = getDiskLimit(in);
+
+    if (isInMemoryCache(limit)) {
+      return memCacheFactory.build(in, loader, backend);
+    }
+
+    return buildImpl(in, loader, limit, backend);
+  }
+
+  private <K, V> long getDiskLimit(PersistentCacheDef<K, V> in) {
+    return config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
+  }
+
+  private <K, V> boolean isInMemoryCache(long diskLimit) {
+    return !diskEnabled || diskLimit <= 0;
+  }
+
+  private static Path getCacheDir(SitePaths site, String name) {
+    if (name == null) {
+      return null;
+    }
+    Path loc = site.resolve(name);
+    if (!Files.exists(loc)) {
+      try {
+        Files.createDirectories(loc);
+      } catch (IOException e) {
+        logger.atWarning().log("Can't create disk cache: %s", loc.toAbsolutePath());
+        return null;
+      }
+    }
+    if (!Files.isWritable(loc)) {
+      logger.atWarning().log("Can't write to disk cache: %s", loc.toAbsolutePath());
+      return null;
+    }
+    logger.atInfo().log("Enabling disk cache %s", loc.toAbsolutePath());
+    return loc;
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 82615a4..16d62b3 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
+import com.google.gerrit.server.cache.PersistentCacheBaseFactory;
 import com.google.gerrit.server.cache.PersistentCacheDef;
-import com.google.gerrit.server.cache.PersistentCacheFactory;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -34,9 +34,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
@@ -52,12 +49,9 @@
  * is unset, it will fall back to in-memory caches.
  */
 @Singleton
-class H2CacheFactory implements PersistentCacheFactory, LifecycleListener {
+class H2CacheFactory extends PersistentCacheBaseFactory implements LifecycleListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final MemoryCacheFactory memCacheFactory;
-  private final Config config;
-  private final Path cacheDir;
   private final List<H2CacheImpl<?, ?>> caches;
   private final DynamicMap<Cache<?, ?>> cacheMap;
   private final ExecutorService executor;
@@ -71,15 +65,13 @@
       @GerritServerConfig Config cfg,
       SitePaths site,
       DynamicMap<Cache<?, ?>> cacheMap) {
-    this.memCacheFactory = memCacheFactory;
-    config = cfg;
-    cacheDir = getCacheDir(site, cfg.getString("cache", null, "directory"));
+    super(memCacheFactory, cfg, site);
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
     h2AutoServer = cfg.getBoolean("cache", null, "h2AutoServer", false);
     caches = new LinkedList<>();
     this.cacheMap = cacheMap;
 
-    if (cacheDir != null) {
+    if (diskEnabled) {
       executor =
           new LoggingContextAwareExecutorService(
               Executors.newFixedThreadPool(
@@ -98,27 +90,6 @@
     }
   }
 
-  private static Path getCacheDir(SitePaths site, String name) {
-    if (name == null) {
-      return null;
-    }
-    Path loc = site.resolve(name);
-    if (!Files.exists(loc)) {
-      try {
-        Files.createDirectories(loc);
-      } catch (IOException e) {
-        logger.atWarning().log("Can't create disk cache: %s", loc.toAbsolutePath());
-        return null;
-      }
-    }
-    if (!Files.isWritable(loc)) {
-      logger.atWarning().log("Can't write to disk cache: %s", loc.toAbsolutePath());
-      return null;
-    }
-    logger.atInfo().log("Enabling disk cache %s", loc.toAbsolutePath());
-    return loc;
-  }
-
   @Override
   public void start() {
     if (executor != null) {
@@ -161,13 +132,8 @@
 
   @SuppressWarnings({"unchecked"})
   @Override
-  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in, CacheBackend backend) {
-    long limit = config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
-
-    if (cacheDir == null || limit <= 0) {
-      return memCacheFactory.build(in, backend);
-    }
-
+  public <K, V> Cache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, long limit, CacheBackend backend) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     H2CacheImpl<K, V> cache =
@@ -184,14 +150,8 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public <K, V> LoadingCache<K, V> build(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, CacheBackend backend) {
-    long limit = config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
-
-    if (cacheDir == null || limit <= 0) {
-      return memCacheFactory.build(in, loader, backend);
-    }
-
+  public <K, V> LoadingCache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long limit, CacheBackend backend) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     Cache<K, ValueHolder<V>> mem =
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/BUILD b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
index cb8c4ae..55080e8 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -5,12 +5,8 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/git",
-        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server/cache/serialize",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
new file mode 100644
index 0000000..7449917
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import java.sql.Timestamp;
+
+/** Helper to (de)serialize values for caches. */
+public class InternalGroupSerializer {
+  public static InternalGroup deserialize(Cache.InternalGroupProto proto) {
+    InternalGroup.Builder builder =
+        InternalGroup.builder()
+            .setId(AccountGroup.id(proto.getId()))
+            .setNameKey(AccountGroup.nameKey(proto.getName()))
+            .setOwnerGroupUUID(AccountGroup.uuid(proto.getOwnerGroupUuid()))
+            .setVisibleToAll(proto.getIsVisibleToAll())
+            .setGroupUUID(AccountGroup.uuid(proto.getGroupUuid()))
+            .setCreatedOn(new Timestamp(proto.getCreatedOn()))
+            .setMembers(
+                proto.getMembersIdsList().stream()
+                    .map(a -> Account.id(a))
+                    .collect(toImmutableSet()))
+            .setSubgroups(
+                proto.getSubgroupUuidsList().stream()
+                    .map(s -> AccountGroup.uuid(s))
+                    .collect(toImmutableSet()));
+
+    if (!proto.getDescription().isEmpty()) {
+      builder.setDescription(proto.getDescription());
+    }
+
+    if (!proto.getRefState().isEmpty()) {
+      builder.setRefState(ObjectIdConverter.create().fromByteString(proto.getRefState()));
+    }
+
+    return builder.build();
+  }
+
+  public static Cache.InternalGroupProto serialize(InternalGroup autoValue) {
+    Cache.InternalGroupProto.Builder builder =
+        Cache.InternalGroupProto.newBuilder()
+            .setId(autoValue.getId().get())
+            .setName(autoValue.getName())
+            .setOwnerGroupUuid(autoValue.getOwnerGroupUUID().get())
+            .setIsVisibleToAll(autoValue.isVisibleToAll())
+            .setGroupUuid(autoValue.getGroupUUID().get())
+            .setCreatedOn(autoValue.getCreatedOn().getTime());
+
+    autoValue.getMembers().stream().forEach(m -> builder.addMembersIds(m.get()));
+    autoValue.getSubgroups().stream().forEach(s -> builder.addSubgroupUuids(s.get()));
+
+    if (autoValue.getDescription() != null) {
+      builder.setDescription(autoValue.getDescription());
+    }
+
+    if (autoValue.getRefState() != null) {
+      builder.setRefState(ObjectIdConverter.create().toByteString(autoValue.getRefState()));
+    }
+
+    return builder.build();
+  }
+
+  private InternalGroupSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
index 291db4a..4627cdb 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -42,6 +42,8 @@
         .setCopyAnyScore(proto.getCopyAnyScore())
         .setCopyMinScore(proto.getCopyMinScore())
         .setCopyMaxScore(proto.getCopyMaxScore())
+        .setCopyAllScoresIfListOfFilesDidNotChange(
+            proto.getCopyAllScoresIfListOfFilesDidNotChange())
         .setCopyAllScoresOnMergeFirstParentUpdate(proto.getCopyAllScoresOnMergeFirstParentUpdate())
         .setCopyAllScoresOnTrivialRebase(proto.getCopyAllScoresOnTrivialRebase())
         .setCopyAllScoresIfNoCodeChange(proto.getCopyAllScoresIfNoCodeChange())
@@ -68,6 +70,8 @@
         .setCopyAnyScore(autoValue.isCopyAnyScore())
         .setCopyMinScore(autoValue.isCopyMinScore())
         .setCopyMaxScore(autoValue.isCopyMaxScore())
+        .setCopyAllScoresIfListOfFilesDidNotChange(
+            autoValue.isCopyAllScoresIfListOfFilesDidNotChange())
         .setCopyAllScoresOnMergeFirstParentUpdate(
             autoValue.isCopyAllScoresOnMergeFirstParentUpdate())
         .setCopyAllScoresOnTrivialRebase(autoValue.isCopyAllScoresOnTrivialRebase())
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index 6f28dad..54ebf40 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -31,7 +31,7 @@
 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.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -89,9 +89,9 @@
     return Lists.newArrayList(visitorSet);
   }
 
-  void addChangeActions(ChangeInfo to, ChangeNotes notes) {
+  void addChangeActions(ChangeInfo to, ChangeData changeData) {
     List<ActionVisitor> visitors = visitors();
-    to.actions = toActionMap(notes, visitors, copy(visitors, to));
+    to.actions = toActionMap(changeData, visitors, copy(visitors, to));
   }
 
   void addRevisionActions(@Nullable ChangeInfo changeInfo, RevisionInfo to, RevisionResource rsrc) {
@@ -167,7 +167,7 @@
   }
 
   private Map<String, ActionInfo> toActionMap(
-      ChangeNotes notes, List<ActionVisitor> visitors, ChangeInfo changeInfo) {
+      ChangeData changeData, List<ActionVisitor> visitors, ChangeInfo changeInfo) {
     CurrentUser user = userProvider.get();
     Map<String, ActionInfo> out = new LinkedHashMap<>();
     if (!user.isIdentifiedUser()) {
@@ -175,12 +175,12 @@
     }
 
     Iterable<UiAction.Description> descs =
-        uiActions.from(changeViews, changeResourceFactory.create(notes, user));
+        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 (!notes.getChange().isAbandoned()) {
+    if (!changeData.change().isAbandoned()) {
       UiAction.Description descr = new UiAction.Description();
       PrivateInternals_UiActionDescription.setId(descr, "followup");
       PrivateInternals_UiActionDescription.setMethod(descr, "POST");
diff --git a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
deleted file mode 100644
index 663d7aa..0000000
--- a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.DynamicOptions.BeanProvider;
-import com.google.gerrit.server.query.change.ChangeData;
-
-/**
- * Interface for plugins to provide additional fields in {@link
- * com.google.gerrit.extensions.common.ChangeInfo ChangeInfo}.
- *
- * <p>Register a {@code ChangeAttributeFactory} in a plugin {@code Module} like this:
- *
- * <pre>
- * DynamicSet.bind(binder(), ChangeAttributeFactory.class).to(YourClass.class);
- * </pre>
- *
- * <p>See the <a
- * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">plugin
- * developer documentation for more details and examples.
- */
-@Deprecated
-public interface ChangeAttributeFactory {
-
-  /**
-   * Create a plugin-provided info field.
-   *
-   * <p>Typically, implementations will subclass {@code PluginDefinedInfo} to add additional fields.
-   *
-   * @param cd change.
-   * @param beanProvider provider of {@code DynamicBean}s, which may be used for reading options.
-   * @param plugin plugin name.
-   * @return the plugin's special change info.
-   */
-  PluginDefinedInfo create(ChangeData cd, BeanProvider beanProvider, String plugin);
-}
diff --git a/java/com/google/gerrit/server/change/ChangeETagComputation.java b/java/com/google/gerrit/server/change/ChangeETagComputation.java
index a5b7d49..2fd5755 100644
--- a/java/com/google/gerrit/server/change/ChangeETagComputation.java
+++ b/java/com/google/gerrit/server/change/ChangeETagComputation.java
@@ -26,7 +26,7 @@
  * <ul>
  *   <li>providing plugin defined attributes to {@link
  *       com.google.gerrit.extensions.common.ChangeInfo#plugins} (see {@link
- *       ChangeAttributeFactory})
+ *       ChangePluginDefinedInfoFactory})
  *   <li>implementing a {@link com.google.gerrit.server.rules.SubmitRule} which affects the
  *       computation of {@link com.google.gerrit.extensions.common.ChangeInfo#submittable}
  * </ul>
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 6091091..fb027bd 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -25,6 +25,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
@@ -63,6 +64,7 @@
 import com.google.gerrit.server.mail.send.CreateChangeSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -113,6 +115,7 @@
   private final ReviewerAdder reviewerAdder;
   private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
+  private final AutoMerger autoMerger;
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
@@ -163,6 +166,7 @@
       ReviewerAdder reviewerAdder,
       MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
+      AutoMerger autoMerger,
       @Assisted Change.Id changeId,
       @Assisted ObjectId commitId,
       @Assisted String refName) {
@@ -180,6 +184,7 @@
     this.reviewerAdder = reviewerAdder;
     this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
+    this.autoMerger = autoMerger;
 
     this.changeId = changeId;
     this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
@@ -377,6 +382,15 @@
       return;
     }
     ctx.addRefUpdate(cmd);
+    Optional<ReceiveCommand> autoMerge =
+        autoMerger.createAutoMergeCommitIfNecessary(
+            ctx.getRepoView(),
+            ctx.getRevWalk(),
+            ctx.getInserter(),
+            ctx.getRevWalk().parseCommit(commitId));
+    if (autoMerge.isPresent()) {
+      ctx.addRefUpdate(autoMerge.get());
+    }
   }
 
   @Override
@@ -546,6 +560,7 @@
               cmd,
               projectState.getProject(),
               change.getDest().branch(),
+              ImmutableListMultimap.of(),
               ctx.getRepoView().getConfig(),
               ctx.getRevWalk().getObjectReader(),
               commitId,
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 7b2663a..029f231 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -47,16 +47,18 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 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.SubmitRequirement;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
@@ -68,13 +70,14 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 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;
@@ -112,11 +115,14 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Produces {@link ChangeInfo} (which is serialized to JSON afterwards) from {@link ChangeData}.
@@ -158,17 +164,12 @@
     }
 
     public ChangeJson create(Iterable<ListChangesOption> options) {
-      return factory.create(options, Optional.empty(), Optional.empty());
+      return factory.create(options, Optional.empty());
     }
 
     public ChangeJson create(
-        Iterable<ListChangesOption> options,
-        PluginDefinedAttributesFactory pluginDefinedAttributesFactory,
-        PluginDefinedInfosFactory pluginDefinedInfosFactory) {
-      return factory.create(
-          options,
-          Optional.of(pluginDefinedAttributesFactory),
-          Optional.of(pluginDefinedInfosFactory));
+        Iterable<ListChangesOption> options, PluginDefinedInfosFactory pluginDefinedInfosFactory) {
+      return factory.create(options, Optional.of(pluginDefinedInfosFactory));
     }
 
     public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
@@ -179,7 +180,6 @@
   public interface AssistedFactory {
     ChangeJson create(
         Iterable<ListChangesOption> options,
-        Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
         Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory);
   }
 
@@ -226,7 +226,6 @@
   private final TrackingFooters trackingFooters;
   private final Metrics metrics;
   private final RevisionJson revisionJson;
-  private final Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory;
   private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
   private final boolean includeMergeable;
   private final boolean lazyLoad;
@@ -244,14 +243,13 @@
       Provider<ConsistencyChecker> checkerProvider,
       ActionJson actionJson,
       ChangeNotes.Factory notesFactory,
-      LabelsJson.Factory labelsJsonFactory,
+      LabelsJson labelsJson,
       RemoveReviewerControl removeReviewerControl,
       TrackingFooters trackingFooters,
       Metrics metrics,
       RevisionJson.Factory revisionJsonFactory,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
-      @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
     this.userProvider = user;
     this.changeDataFactory = cdf;
@@ -261,7 +259,7 @@
     this.checkerProvider = checkerProvider;
     this.actionJson = actionJson;
     this.notesFactory = notesFactory;
-    this.labelsJson = labelsJsonFactory.create(options);
+    this.labelsJson = labelsJson;
     this.removeReviewerControl = removeReviewerControl;
     this.trackingFooters = trackingFooters;
     this.metrics = metrics;
@@ -269,7 +267,6 @@
     this.options = Sets.immutableEnumSet(options);
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
     this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
-    this.pluginDefinedAttributesFactory = pluginDefinedAttributesFactory;
     this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
 
     logger.atFine().log("options = %s", options);
@@ -288,6 +285,11 @@
     return format(changeDataFactory.create(change));
   }
 
+  public ChangeInfo format(Change change, @Nullable ObjectId metaRevId) {
+    ChangeNotes notes = notesFactory.createChecked(change.getProject(), change.getId(), metaRevId);
+    return format(changeDataFactory.create(notes));
+  }
+
   public ChangeInfo format(ChangeData cd) {
     return format(cd, Optional.empty(), true, getPluginInfos(cd));
   }
@@ -330,9 +332,13 @@
   }
 
   public ChangeInfo format(Project.NameKey project, Change.Id id) {
+    return format(project, id, null);
+  }
+
+  public ChangeInfo format(Project.NameKey project, Change.Id id, @Nullable ObjectId metaRevId) {
     ChangeNotes notes;
     try {
-      notes = notesFactory.createChecked(project, id);
+      notes = notesFactory.createChecked(project, id, metaRevId);
     } catch (StorageException e) {
       if (!has(CHECK)) {
         throw e;
@@ -343,21 +349,22 @@
     return format(cd, Optional.empty(), true, getPluginInfos(cd));
   }
 
-  private static Collection<SubmitRequirementInfo> requirementsFor(ChangeData cd) {
-    Collection<SubmitRequirementInfo> reqInfos = new ArrayList<>();
+  private static Collection<LegacySubmitRequirementInfo> requirementsFor(ChangeData cd) {
+    Collection<LegacySubmitRequirementInfo> reqInfos = new ArrayList<>();
     for (SubmitRecord submitRecord : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
       if (submitRecord.requirements == null) {
         continue;
       }
-      for (SubmitRequirement requirement : submitRecord.requirements) {
+      for (LegacySubmitRequirement requirement : submitRecord.requirements) {
         reqInfos.add(requirementToInfo(requirement, submitRecord.status));
       }
     }
     return reqInfos;
   }
 
-  private static SubmitRequirementInfo requirementToInfo(SubmitRequirement req, Status status) {
-    return new SubmitRequirementInfo(status.name(), req.fallbackText(), req.type());
+  private static LegacySubmitRequirementInfo requirementToInfo(
+      LegacySubmitRequirement req, Status status) {
+    return new LegacySubmitRequirementInfo(status.name(), req.fallbackText(), req.type());
   }
 
   private static void finish(ChangeInfo info) {
@@ -399,6 +406,10 @@
 
   private void ensureLoaded(Iterable<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);
+      }
       ChangeData.ensureChangeLoaded(all);
       if (has(ALL_REVISIONS)) {
         ChangeData.ensureAllPatchSetsLoaded(all);
@@ -411,7 +422,8 @@
       ChangeData.ensureCurrentApprovalsLoaded(all);
     } else {
       for (ChangeData cd : all) {
-        cd.setLazyLoad(false);
+        // Mark all ChangeDatas as coming from the index. Disallow using NoteDb
+        cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_ONLY);
       }
     }
   }
@@ -577,6 +589,15 @@
     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());
+    }
+
     if (user.isIdentifiedUser()) {
       Collection<String> stars = cd.stars(user.getAccountId());
       out.starred = stars.contains(StarredChangesUtil.DEFAULT_LABEL) ? true : null;
@@ -611,17 +632,9 @@
     }
 
     setSubmitter(cd, out);
-    if (pluginDefinedAttributesFactory.isPresent()) {
-      out.plugins = pluginDefinedAttributesFactory.get().create(cd);
-    }
 
     if (!pluginInfos.isEmpty()) {
-      if (out.plugins == null) {
-        out.plugins = pluginInfos;
-      } else {
-        out.plugins = new ArrayList<>(out.plugins);
-        out.plugins.addAll(pluginInfos);
-      }
+      out.plugins = pluginInfos;
     }
     out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
     out.submissionId = cd.change().getSubmissionId();
@@ -665,7 +678,7 @@
     }
 
     if (has(CURRENT_ACTIONS) || has(CHANGE_ACTIONS)) {
-      actionJson.addChangeActions(out, cd.notes());
+      actionJson.addChangeActions(out, cd);
     }
 
     if (has(TRACKING_IDS)) {
@@ -749,7 +762,14 @@
     // removed.
     Collection<LabelInfo> labels = out.labels.values();
     Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
-    Set<Account.Id> removable = 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()));
 
     // 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
@@ -766,11 +786,9 @@
       for (ApprovalInfo ai : label.all) {
         Account.Id id = Account.id(ai._accountId);
 
-        if (canRemoveAnyReviewer
-            || removeReviewerControl.testRemoveReviewer(
+        if (!canRemoveAnyReviewer
+            && !removeReviewerControl.testRemoveReviewer(
                 cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
-          removable.add(id);
-        } else {
           fixed.add(id);
         }
       }
diff --git a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
deleted file mode 100644
index 0db4cea..0000000
--- a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gson.JsonDeserializationContext;
-import com.google.gson.JsonDeserializer;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParseException;
-import com.google.gson.JsonSerializationContext;
-import com.google.gson.JsonSerializer;
-import java.lang.reflect.Type;
-
-/**
- * Adapter that serializes {@link com.google.gerrit.entities.Change.Key}'s {@code key} field as
- * {@code id}, for backwards compatibility in stream-events.
- */
-// TODO(dborowitz): auto-value-gson should support this directly using @SerializedName on the
-// AutoValue method.
-public class ChangeKeyAdapter implements JsonSerializer<Change.Key>, JsonDeserializer<Change.Key> {
-  @Override
-  public JsonElement serialize(Change.Key src, Type typeOfSrc, JsonSerializationContext context) {
-    JsonObject obj = new JsonObject();
-    obj.addProperty("id", src.get());
-    return obj;
-  }
-
-  @Override
-  public Change.Key deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
-      throws JsonParseException {
-    JsonElement keyJson = json.getAsJsonObject().get("id");
-    if (keyJson == null || !keyJson.isJsonPrimitive() || !keyJson.getAsJsonPrimitive().isString()) {
-      throw new JsonParseException("Key is not a string: " + keyJson);
-    }
-    String key = keyJson.getAsJsonPrimitive().getAsString();
-    return Change.key(key);
-  }
-}
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 5a1798d..3729b59 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -44,9 +44,10 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.inject.Inject;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.util.HashSet;
 import java.util.Optional;
 import java.util.Set;
@@ -66,6 +67,8 @@
 
   public interface Factory {
     ChangeResource create(ChangeNotes notes, CurrentUser user);
+
+    ChangeResource create(ChangeData changeData, CurrentUser user);
   }
 
   private static final String ZERO_ID_STRING = ObjectId.zeroId().name();
@@ -77,10 +80,10 @@
   private final StarredChangesUtil starredChangesUtil;
   private final ProjectCache projectCache;
   private final PluginSetContext<ChangeETagComputation> changeETagComputation;
-  private final ChangeNotes notes;
+  private final ChangeData changeData;
   private final CurrentUser user;
 
-  @Inject
+  @AssistedInject
   ChangeResource(
       AccountCache accountCache,
       ApprovalsUtil approvalUtil,
@@ -89,6 +92,7 @@
       StarredChangesUtil starredChangesUtil,
       ProjectCache projectCache,
       PluginSetContext<ChangeETagComputation> changeETagComputation,
+      ChangeData.Factory changeDataFactory,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user) {
     this.accountCache = accountCache;
@@ -98,12 +102,34 @@
     this.starredChangesUtil = starredChangesUtil;
     this.projectCache = projectCache;
     this.changeETagComputation = changeETagComputation;
-    this.notes = notes;
+    this.changeData = changeDataFactory.create(notes);
+    this.user = user;
+  }
+
+  @AssistedInject
+  ChangeResource(
+      AccountCache accountCache,
+      ApprovalsUtil approvalUtil,
+      PatchSetUtil patchSetUtil,
+      PermissionBackend permissionBackend,
+      StarredChangesUtil starredChangesUtil,
+      ProjectCache projectCache,
+      PluginSetContext<ChangeETagComputation> changeETagComputation,
+      @Assisted ChangeData changeData,
+      @Assisted CurrentUser user) {
+    this.accountCache = accountCache;
+    this.approvalUtil = approvalUtil;
+    this.patchSetUtil = patchSetUtil;
+    this.permissionBackend = permissionBackend;
+    this.starredChangesUtil = starredChangesUtil;
+    this.projectCache = projectCache;
+    this.changeETagComputation = changeETagComputation;
+    this.changeData = changeData;
     this.user = user;
   }
 
   public PermissionBackend.ForChange permissions() {
-    return permissionBackend.user(user).change(notes);
+    return permissionBackend.user(user).change(getNotes());
   }
 
   public CurrentUser getUser() {
@@ -111,7 +137,7 @@
   }
 
   public Change.Id getId() {
-    return notes.getChangeId();
+    return changeData.getId();
   }
 
   /** @return true if {@link #getUser()} is the change's owner. */
@@ -121,7 +147,7 @@
   }
 
   public Change getChange() {
-    return notes.getChange();
+    return changeData.change();
   }
 
   public Project.NameKey getProject() {
@@ -129,7 +155,11 @@
   }
 
   public ChangeNotes getNotes() {
-    return notes;
+    return changeData.notes();
+  }
+
+  public ChangeData getChangeData() {
+    return changeData;
   }
 
   // This includes all information relevant for ETag computation
@@ -153,7 +183,7 @@
       accounts.add(getChange().getAssignee());
     }
     try {
-      patchSetUtil.byChange(notes).stream().map(PatchSet::uploader).forEach(accounts::add);
+      patchSetUtil.byChange(getNotes()).stream().map(PatchSet::uploader).forEach(accounts::add);
 
       // It's intentional to include the states for *all* reviewers into the ETag computation.
       // We need the states of all current reviewers and CCs because they are part of ChangeInfo.
@@ -162,7 +192,7 @@
       // set of accounts that posted a message is too expensive. However everyone who posts a
       // message is automatically added as reviewer. Hence if we include removed reviewers we can
       // be sure that we have all accounts that posted messages on the change.
-      accounts.addAll(approvalUtil.getReviewers(notes).all());
+      accounts.addAll(approvalUtil.getReviewers(getNotes()).all());
     } catch (StorageException e) {
       // This ETag will be invalidated if it loads next time.
     }
@@ -178,7 +208,7 @@
 
     ObjectId noteId;
     try {
-      noteId = notes.loadRevision();
+      noteId = getNotes().loadRevision();
     } catch (StorageException e) {
       noteId = null; // This ETag will be invalidated if it loads next time.
     }
@@ -194,7 +224,7 @@
 
     changeETagComputation.runEach(
         c -> {
-          String pluginETag = c.getETag(notes.getProjectName(), notes.getChangeId());
+          String pluginETag = c.getETag(changeData.project(), changeData.getId());
           if (pluginETag != null) {
             h.putString(pluginETag, UTF_8);
           }
@@ -207,8 +237,8 @@
         TraceContext.newTimer(
             "Compute change ETag",
             Metadata.builder()
-                .changeId(notes.getChangeId().get())
-                .projectName(notes.getProjectName().get())
+                .changeId(changeData.getId().get())
+                .projectName(changeData.project().get())
                 .build())) {
       Hasher h = Hashing.murmur3_128().newHasher();
       if (user.isIdentifiedUser()) {
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 07cb04f..bf00d27 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RemoveReviewerControl;
@@ -132,9 +133,15 @@
     for (LabelType lt : labelTypes.getLabelTypes()) {
       newApprovals.put(lt.getName(), (short) 0);
     }
-
+    String ccOrReviewer =
+        approvalsUtil
+                .getReviewers(ctx.getNotes())
+                .byState(ReviewerStateInternal.CC)
+                .contains(reviewerId)
+            ? "cc"
+            : "reviewer";
     StringBuilder msg = new StringBuilder();
-    msg.append("Removed reviewer " + reviewer.account().fullName());
+    msg.append(String.format("Removed %s %s", ccOrReviewer, reviewer.account().fullName()));
     StringBuilder removedVotesMsg = new StringBuilder();
     removedVotesMsg.append(" with the following votes:\n\n");
     boolean votesRemoved = false;
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index aca4fb0..ad6f9c7 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -16,108 +16,67 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.util.Map;
-import java.util.TreeMap;
-import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.errors.NoMergeBaseException;
 import org.eclipse.jgit.lib.ObjectId;
 
-@Singleton
-public class FileInfoJson {
-  private final PatchListCache patchListCache;
+/** Compute and return the list of modified files between two commits. */
+public interface FileInfoJson {
 
-  @Inject
-  FileInfoJson(PatchListCache patchListCache) {
-    this.patchListCache = patchListCache;
-  }
-
-  public Map<String, FileInfo> toFileInfoMap(Change change, PatchSet patchSet)
+  /**
+   * Computes the list of modified files for a given change and patchset against the parent commit.
+   *
+   * @param change a Gerrit change.
+   * @param patchSet a single revision of the change.
+   * @return a mapping of the file paths to their related diff information.
+   */
+  default Map<String, FileInfo> getFileInfoMap(Change change, PatchSet patchSet)
       throws ResourceConflictException, PatchListNotAvailableException {
-    return toFileInfoMap(change, patchSet.commitId(), null);
+    return getFileInfoMap(change, patchSet.commitId(), null);
   }
 
-  public Map<String, FileInfo> toFileInfoMap(
-      Change change, ObjectId objectId, @Nullable PatchSet base)
+  /**
+   * Computes the list of modified files for a given change and patchset against its parent. For
+   * merge commits, callers can use 0, 1, 2, etc... to choose a specific parent. The first parent is
+   * 0.
+   *
+   * @param change a Gerrit change.
+   * @param objectId a commit SHA-1 identifying a patchset commit.
+   * @param parentNum an integer identifying the parent number used for comparison.
+   * @return a mapping of the file paths to their related diff information.
+   */
+  default Map<String, FileInfo> getFileInfoMap(Change change, ObjectId objectId, int parentNum)
       throws ResourceConflictException, PatchListNotAvailableException {
-    ObjectId a = base != null ? base.commitId() : null;
-    return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
+    return getFileInfoMap(change.getProject(), objectId, parentNum);
   }
 
-  public Map<String, FileInfo> toFileInfoMap(Change change, ObjectId objectId, int parent)
-      throws ResourceConflictException, PatchListNotAvailableException {
-    return toFileInfoMap(
-        change, PatchListKey.againstParentNum(parent + 1, objectId, Whitespace.IGNORE_NONE));
-  }
+  /**
+   * Computes the list of modified files for a given change and patchset identified by its {@code
+   * objectId} against a specified base patchset.
+   *
+   * @param change a Gerrit change.
+   * @param objectId a commit SHA-1 identifying a patchset commit.
+   * @param base a base patchset to compare the commit identified by {@code objectId} against.
+   * @return a mapping of the file paths to their related diff information.
+   */
+  Map<String, FileInfo> getFileInfoMap(Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws ResourceConflictException, PatchListNotAvailableException;
 
-  private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
-      throws ResourceConflictException, PatchListNotAvailableException {
-    return toFileInfoMap(change.getProject(), key);
-  }
-
-  public Map<String, FileInfo> toFileInfoMap(Project.NameKey project, PatchListKey key)
-      throws ResourceConflictException, PatchListNotAvailableException {
-    PatchList list;
-    try {
-      list = patchListCache.get(key, project);
-    } catch (PatchListNotAvailableException e) {
-      Throwable cause = e.getCause();
-      if (cause instanceof ExecutionException) {
-        cause = cause.getCause();
-      }
-      if (cause instanceof NoMergeBaseException) {
-        throw new ResourceConflictException(
-            String.format("Cannot create auto merge commit: %s", e.getMessage()), e);
-      }
-      throw e;
-    }
-
-    Map<String, FileInfo> files = new TreeMap<>();
-    for (PatchListEntry e : list.getPatches()) {
-      FileInfo d = new FileInfo();
-      d.status =
-          e.getChangeType() != Patch.ChangeType.MODIFIED ? e.getChangeType().getCode() : null;
-      d.oldPath = e.getOldName();
-      d.sizeDelta = e.getSizeDelta();
-      d.size = e.getSize();
-      if (e.getPatchType() == Patch.PatchType.BINARY) {
-        d.binary = true;
-      } else {
-        d.linesInserted = e.getInsertions() > 0 ? e.getInsertions() : null;
-        d.linesDeleted = e.getDeletions() > 0 ? e.getDeletions() : null;
-      }
-
-      FileInfo o = files.put(e.getNewName(), d);
-      if (o != null) {
-        // This should only happen on a delete-add break created by JGit
-        // when the file was rewritten and too little content survived. Write
-        // a single record with data from both sides.
-        d.status = Patch.ChangeType.REWRITE.getCode();
-        d.sizeDelta = o.sizeDelta;
-        d.size = o.size;
-        if (o.binary != null && o.binary) {
-          d.binary = true;
-        }
-        if (o.linesInserted != null) {
-          d.linesInserted = o.linesInserted;
-        }
-        if (o.linesDeleted != null) {
-          d.linesDeleted = o.linesDeleted;
-        }
-      }
-    }
-    return files;
-  }
+  /**
+   * Computes the list of modified files for a given project and commit against its parent. For
+   * merge commits, callers can use 0, 1, 2, etc... to choose a specific parent. The first parent is
+   * 0. A value of -1 for parent can be passed to use the default base commit, which is the only
+   * parent for commits having only one parent, or the auto-merge otherwise.
+   *
+   * @param project a project identifying a repository.
+   * @param objectId a commit SHA-1 identifying a patchset commit.
+   * @param parentNum an integer identifying the parent number used for comparison.
+   * @return a mapping of the file paths to their related diff information.
+   */
+  Map<String, FileInfo> getFileInfoMap(Project.NameKey project, ObjectId objectId, int parentNum)
+      throws ResourceConflictException, PatchListNotAvailableException;
 }
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java
new file mode 100644
index 0000000..228d631
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java
@@ -0,0 +1,160 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.patch.DiffExecutor;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Implementation of FileInfoJson which uses {@link FileInfoJsonOldImpl}, but also runs {@link
+ * FileInfoJsonNewImpl} asynchronously and compares the results. This implementation is temporary
+ * and will be used to verify that the results are the same.
+ */
+public class FileInfoJsonComparingImpl implements FileInfoJson {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final FileInfoJsonOldImpl oldImpl;
+  private final FileInfoJsonNewImpl newImpl;
+  private final ExecutorService executor;
+  private final Metrics metrics;
+
+  /**
+   * TODO(ghareeb): These metrics are temporary for launching the new diff cache redesign and are
+   * not documented. These will be removed soon.
+   */
+  @VisibleForTesting
+  @Singleton
+  static class Metrics {
+    private enum Status {
+      MATCH,
+      MISMATCH,
+      ERROR
+    }
+
+    final Counter1<Status> diffs;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      diffs =
+          metricMaker.newCounter(
+              "diff/list_files/dark_launch",
+              new Description(
+                      "Total number of matching, non-matching, or error in list-files diffs in the old and new diff cache implementations.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofEnum(Status.class, "type", Metadata.Builder::eventType).build());
+    }
+  }
+
+  @Inject
+  public FileInfoJsonComparingImpl(
+      FileInfoJsonOldImpl oldImpl,
+      FileInfoJsonNewImpl newImpl,
+      @DiffExecutor ExecutorService executor,
+      Metrics metrics) {
+    this.oldImpl = oldImpl;
+    this.newImpl = newImpl;
+    this.executor = executor;
+    this.metrics = metrics;
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    Map<String, FileInfo> result = oldImpl.getFileInfoMap(change, objectId, base);
+    @SuppressWarnings("unused")
+    Future<?> ignored =
+        executor.submit(
+            () -> {
+              try {
+                Map<String, FileInfo> fileInfoNew = newImpl.getFileInfoMap(change, objectId, base);
+                compareAndLogMetrics(
+                    result,
+                    fileInfoNew,
+                    String.format(
+                        "Mismatch comparing old and new diff implementations for change: %s, objectId: %s and base: %s",
+                        change, objectId, base == null ? "none" : base.id()));
+              } catch (ResourceConflictException | PatchListNotAvailableException e) {
+                // If an exception happens while evaluating the new diff, increment the non-matching
+                // counter
+                metrics.diffs.increment(Metrics.Status.ERROR);
+                logger.atWarning().withCause(e).log(
+                    "Error comparing old and new diff implementations.");
+              }
+            });
+    return result;
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Project.NameKey project, ObjectId objectId, int parentNum)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    Map<String, FileInfo> result = oldImpl.getFileInfoMap(project, objectId, parentNum);
+    @SuppressWarnings("unused")
+    Future<?> ignored =
+        executor.submit(
+            () -> {
+              try {
+                Map<String, FileInfo> resultNew =
+                    newImpl.getFileInfoMap(project, objectId, parentNum);
+                compareAndLogMetrics(
+                    result,
+                    resultNew,
+                    String.format(
+                        "Mismatch comparing old and new diff implementations for project: %s, objectId: %s and parentNum: %d",
+                        project, objectId, parentNum));
+              } catch (ResourceConflictException | PatchListNotAvailableException e) {
+                // If an exception happens while evaluating the new diff, increment the non-matching
+                // ctr
+                metrics.diffs.increment(Metrics.Status.ERROR);
+                logger.atWarning().withCause(e).log(
+                    "Error comparing old and new diff implementations.");
+              }
+            });
+    return result;
+  }
+
+  private void compareAndLogMetrics(
+      Map<String, FileInfo> fileInfoMapOld,
+      Map<String, FileInfo> fileInfoMapNew,
+      String warningMessage) {
+    if (fileInfoMapOld.equals(fileInfoMapNew)) {
+      metrics.diffs.increment(Metrics.Status.MATCH);
+      return;
+    }
+    metrics.diffs.increment(Metrics.Status.MISMATCH);
+    logger.atWarning().log(warningMessage);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonModule.java b/java/com/google/gerrit/server/change/FileInfoJsonModule.java
new file mode 100644
index 0000000..de116bb
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJsonModule.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.AbstractModule;
+import org.eclipse.jgit.lib.Config;
+
+public class FileInfoJsonModule extends AbstractModule {
+  /** Use the new diff cache implementation {@link FileInfoJsonNewImpl}. */
+  private final boolean useNewDiffCache;
+
+  /** Used to dark launch the new diff cache with the list files endpoint. */
+  private final boolean runNewDiffCacheAsync;
+
+  public FileInfoJsonModule(@GerritServerConfig Config cfg) {
+    this.useNewDiffCache =
+        cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_ListFiles", false);
+    this.runNewDiffCacheAsync =
+        cfg.getBoolean("cache", "diff_cache", "runNewDiffCacheAsync_listFiles", false);
+  }
+
+  @Override
+  public void configure() {
+    if (runNewDiffCacheAsync) {
+      bind(FileInfoJson.class).to(FileInfoJsonComparingImpl.class);
+      return;
+    }
+    bind(FileInfoJson.class)
+        .to(useNewDiffCache ? FileInfoJsonNewImpl.class : FileInfoJsonOldImpl.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
new file mode 100644
index 0000000..1ca2c93
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.FilePathAdapter;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.errors.NoMergeBaseException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Implementation of {@link FileInfoJson} using the new diff cache {@link DiffOperations}. */
+public class FileInfoJsonNewImpl implements FileInfoJson {
+  private final DiffOperations diffs;
+
+  @Inject
+  FileInfoJsonNewImpl(DiffOperations diffOperations) {
+    this.diffs = diffOperations;
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    try {
+      if (base == null) {
+        return asFileInfo(
+            diffs.listModifiedFilesAgainstParent(change.getProject(), objectId, null));
+      }
+      return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
+    } catch (DiffNotAvailableException e) {
+      convertException(e);
+      return null; // unreachable. handleAndThrow will throw an exception anyway
+    }
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Project.NameKey project, ObjectId objectId, int parent)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    try {
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffs.listModifiedFilesAgainstParent(project, objectId, parent + 1);
+      return asFileInfo(modifiedFiles);
+    } catch (DiffNotAvailableException e) {
+      convertException(e);
+      return null; // unreachable. handleAndThrow will throw an exception anyway
+    }
+  }
+
+  private void convertException(DiffNotAvailableException e)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    Throwable cause = e.getCause();
+    if (cause != null && !(cause instanceof NoMergeBaseException)) {
+      cause = cause.getCause();
+    }
+    if (cause instanceof NoMergeBaseException) {
+      throw new ResourceConflictException(
+          String.format("Cannot create auto merge commit: %s", e.getMessage()), e);
+    }
+    throw new PatchListNotAvailableException(e);
+  }
+
+  private Map<String, FileInfo> asFileInfo(Map<String, FileDiffOutput> fileDiffs) {
+    Map<String, FileInfo> result = new HashMap<>();
+    for (String path : fileDiffs.keySet()) {
+      FileDiffOutput fileDiff = fileDiffs.get(path);
+      FileInfo fileInfo = new FileInfo();
+      fileInfo.status =
+          fileDiff.changeType() != Patch.ChangeType.MODIFIED
+              ? fileDiff.changeType().getCode()
+              : null;
+      fileInfo.oldPath = FilePathAdapter.getOldPath(fileDiff.oldPath(), fileDiff.changeType());
+      fileInfo.sizeDelta = fileDiff.sizeDelta();
+      fileInfo.size = fileDiff.size();
+      if (fileDiff.patchType().get() == Patch.PatchType.BINARY) {
+        fileInfo.binary = true;
+      } else {
+        fileInfo.linesInserted = fileDiff.insertions() > 0 ? fileDiff.insertions() : null;
+        fileInfo.linesDeleted = fileDiff.deletions() > 0 ? fileDiff.deletions() : null;
+      }
+      result.put(path, fileInfo);
+    }
+    return result;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
new file mode 100644
index 0000000..55d162a
--- /dev/null
+++ b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.errors.NoMergeBaseException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Implementation of {@link FileInfoJson} using the old diff cache {@link PatchListCache}. */
+@Deprecated
+@Singleton
+class FileInfoJsonOldImpl implements FileInfoJson {
+  private final PatchListCache patchListCache;
+
+  @Inject
+  FileInfoJsonOldImpl(PatchListCache patchListCache) {
+    this.patchListCache = patchListCache;
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    ObjectId a = base != null ? base.commitId() : null;
+    return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
+  }
+
+  @Override
+  public Map<String, FileInfo> getFileInfoMap(
+      Project.NameKey project, ObjectId objectId, int parentNum)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    PatchListKey key =
+        parentNum == -1
+            ? PatchListKey.againstDefaultBase(objectId, Whitespace.IGNORE_NONE)
+            : PatchListKey.againstParentNum(
+                parentNum + 1, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+    return toFileInfoMap(project, key);
+  }
+
+  private Map<String, FileInfo> toFileInfoMap(Change change, PatchListKey key)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    return toFileInfoMap(change.getProject(), key);
+  }
+
+  Map<String, FileInfo> toFileInfoMap(Project.NameKey project, PatchListKey key)
+      throws ResourceConflictException, PatchListNotAvailableException {
+    PatchList list;
+    try {
+      list = patchListCache.get(key, project);
+    } catch (PatchListNotAvailableException e) {
+      Throwable cause = e.getCause();
+      if (cause instanceof ExecutionException) {
+        cause = cause.getCause();
+      }
+      if (cause instanceof NoMergeBaseException) {
+        throw new ResourceConflictException(
+            String.format("Cannot create auto merge commit: %s", e.getMessage()), e);
+      }
+      throw e;
+    }
+
+    Map<String, FileInfo> files = new TreeMap<>();
+    for (PatchListEntry e : list.getPatches()) {
+      FileInfo fileInfo = new FileInfo();
+      fileInfo.status =
+          e.getChangeType() != Patch.ChangeType.MODIFIED ? e.getChangeType().getCode() : null;
+      fileInfo.oldPath = e.getOldName();
+      fileInfo.sizeDelta = e.getSizeDelta();
+      fileInfo.size = e.getSize();
+      if (e.getPatchType() == Patch.PatchType.BINARY) {
+        fileInfo.binary = true;
+      } else {
+        fileInfo.linesInserted = e.getInsertions() > 0 ? e.getInsertions() : null;
+        fileInfo.linesDeleted = e.getDeletions() > 0 ? e.getDeletions() : null;
+      }
+
+      FileInfo o = files.put(e.getNewName(), fileInfo);
+      if (o != null) {
+        // This should only happen on a delete-add break created by JGit
+        // when the file was rewritten and too little content survived. Write
+        // a single record with data from both sides.
+        fileInfo.status = Patch.ChangeType.REWRITE.getCode();
+        fileInfo.sizeDelta = o.sizeDelta;
+        fileInfo.size = o.size;
+        if (o.binary != null && o.binary) {
+          fileInfo.binary = true;
+        }
+        if (o.linesInserted != null) {
+          fileInfo.linesInserted = o.linesInserted;
+        }
+        if (o.linesDeleted != null) {
+          fileInfo.linesDeleted = o.linesDeleted;
+        }
+      }
+    }
+    return files;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index b1d154c..acff03c 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -20,14 +20,12 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
-import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
@@ -38,21 +36,18 @@
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.VotingRangeInfo;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -68,28 +63,15 @@
 /**
  * Produces label-related entities, like {@link LabelInfo}s, which is serialized to JSON afterwards.
  */
+@Singleton
 public class LabelsJson {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    LabelsJson create(Iterable<ListChangesOption> options);
-  }
-
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeNotes.Factory notesFactory;
   private final PermissionBackend permissionBackend;
-  private final boolean lazyLoad;
 
   @Inject
-  LabelsJson(
-      ApprovalsUtil approvalsUtil,
-      ChangeNotes.Factory notesFactory,
-      PermissionBackend permissionBackend,
-      @Assisted Iterable<ListChangesOption> options) {
-    this.approvalsUtil = approvalsUtil;
-    this.notesFactory = notesFactory;
+  LabelsJson(PermissionBackend permissionBackend) {
     this.permissionBackend = permissionBackend;
-    this.lazyLoad = containsAnyOf(Sets.immutableEnumSet(options), ChangeJson.REQUIRE_LAZY_LOAD);
   }
 
   /**
@@ -171,11 +153,6 @@
     return permitted.asMap();
   }
 
-  private static boolean containsAnyOf(
-      ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
-    return !Sets.intersection(toFind, set).isEmpty();
-  }
-
   private static boolean isOnlyZero(Collection<String> values) {
     return values.isEmpty() || (values.size() == 1 && values.contains(" 0"));
   }
@@ -253,14 +230,10 @@
 
   private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd) {
     Map<String, Short> result = new HashMap<>();
-    for (PatchSetApproval psa :
-        approvalsUtil.byPatchSetUser(
-            lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
-            cd.change().currentPatchSetId(),
-            accountId,
-            null,
-            null)) {
-      result.put(psa.label(), psa.value());
+    for (PatchSetApproval psa : cd.currentApprovals()) {
+      if (psa.accountId().equals(accountId)) {
+        result.put(psa.label(), psa.value());
+      }
     }
     return result;
   }
diff --git a/java/com/google/gerrit/server/change/MergeabilityComputationBehavior.java b/java/com/google/gerrit/server/change/MergeabilityComputationBehavior.java
index 26e7a89..87134cb 100644
--- a/java/com/google/gerrit/server/change/MergeabilityComputationBehavior.java
+++ b/java/com/google/gerrit/server/change/MergeabilityComputationBehavior.java
@@ -40,10 +40,7 @@
   /** Returns a {@link MergeabilityComputationBehavior} constructed from a Gerrit server config. */
   public static MergeabilityComputationBehavior fromConfig(Config cfg) {
     return cfg.getEnum(
-        "change",
-        null,
-        "mergeabilityComputationBehavior",
-        MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX);
+        "change", null, "mergeabilityComputationBehavior", MergeabilityComputationBehavior.NEVER);
   }
 
   /** Whether {@code mergeable} should be included in the change API. */
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index ef06ea1..d2bf3fe 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -43,6 +44,7 @@
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -59,6 +61,7 @@
 import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -81,6 +84,7 @@
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
   private final MessageIdGenerator messageIdGenerator;
+  private final AutoMerger autoMerger;
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
@@ -123,6 +127,7 @@
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
       MessageIdGenerator messageIdGenerator,
+      AutoMerger autoMerger,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
       @Assisted ObjectId commitId) {
@@ -137,6 +142,7 @@
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
     this.messageIdGenerator = messageIdGenerator;
+    this.autoMerger = autoMerger;
 
     this.origNotes = notes;
     this.psId = psId;
@@ -213,6 +219,15 @@
       throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
     validate(ctx);
     ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
+    Optional<ReceiveCommand> autoMerge =
+        autoMerger.createAutoMergeCommitIfNecessary(
+            ctx.getRepoView(),
+            ctx.getRevWalk(),
+            ctx.getInserter(),
+            ctx.getRevWalk().parseCommit(commitId));
+    if (autoMerge.isPresent()) {
+      ctx.addRefUpdate(autoMerge.get());
+    }
   }
 
   @Override
@@ -342,6 +357,7 @@
                 .orElseThrow(illegalState(origNotes.getProjectName()))
                 .getProject(),
             origNotes.getChange().getDest().branch(),
+            ImmutableListMultimap.of(),
             ctx.getRepoView().getConfig(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
index b474dab..db21f11 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
@@ -14,55 +14,22 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.util.concurrent.TimeUnit.MINUTES;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.server.DynamicOptions.BeanProvider;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.util.Collection;
-import java.util.Objects;
 import java.util.stream.Stream;
 
-/** Static helpers for use by {@link PluginDefinedAttributesFactory} implementations. */
+/** Static helpers for use by {@link PluginDefinedInfosFactory} implementations. */
 public class PluginDefinedAttributesFactories {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  @Nullable
-  public static ImmutableList<PluginDefinedInfo> createAll(
-      ChangeData cd,
-      BeanProvider beanProvider,
-      Stream<Extension<ChangeAttributeFactory>> attrFactories) {
-    ImmutableList<PluginDefinedInfo> result =
-        attrFactories
-            .map(e -> tryCreate(cd, beanProvider, e.getPluginName(), e.get()))
-            .filter(Objects::nonNull)
-            .collect(toImmutableList());
-    return !result.isEmpty() ? result : null;
-  }
-
-  @Nullable
-  private static PluginDefinedInfo tryCreate(
-      ChangeData cd, BeanProvider beanProvider, String plugin, ChangeAttributeFactory attrFactory) {
-    PluginDefinedInfo pdi = null;
-    try {
-      pdi = attrFactory.create(cd, beanProvider, plugin);
-    } catch (RuntimeException ex) {
-      logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
-          "error populating attribute on change %s from plugin %s", cd.getId(), plugin);
-    }
-    if (pdi != null) {
-      pdi.name = plugin;
-    }
-    return pdi;
-  }
-
   public static ImmutableListMultimap<Change.Id, PluginDefinedInfo> createAll(
       Collection<ChangeData> cds,
       BeanProvider beanProvider,
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 231359b..b43996e 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -15,8 +15,11 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -26,6 +29,8 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.RebaseUtil.Base;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -41,13 +46,27 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.diff.Sequence;
+import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.merge.MergeResult;
+import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMerger;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * BatchUpdate operation that rebases a change.
+ *
+ * <p>Can only be executed in a {@link com.google.gerrit.server.update.BatchUpdate} set has a {@link
+ * CodeReviewRevWalk} set as {@link RevWalk} (set via {@link
+ * com.google.gerrit.server.update.BatchUpdate#setRepository(org.eclipse.jgit.lib.Repository,
+ * RevWalk, org.eclipse.jgit.lib.ObjectInserter)}).
+ */
 public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
     RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
@@ -69,12 +88,13 @@
   private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private boolean forceContentMerge;
+  private boolean allowConflicts;
   private boolean detailedCommitMessage;
   private boolean postMessage = true;
   private boolean sendEmail = true;
   private boolean matchAuthorToCommitterDate = false;
 
-  private RevCommit rebasedCommit;
+  private CodeReviewCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
   private PatchSetInserter patchSetInserter;
   private PatchSet rebasedPatchSet;
@@ -126,6 +146,19 @@
     return this;
   }
 
+  /**
+   * Allows the rebase to succeed if there are conflicts.
+   *
+   * <p>This setting requires that {@link #forceContentMerge} is set {@code true}. If {@link
+   * #forceContentMerge} is {@code false} this setting has no effect.
+   *
+   * @see #setForceContentMerge(boolean)
+   */
+  public RebaseChangeOp setAllowConflicts(boolean allowConflicts) {
+    this.allowConflicts = allowConflicts;
+    return this;
+  }
+
   public RebaseChangeOp setDetailedCommitMessage(boolean detailedCommitMessage) {
     this.detailedCommitMessage = detailedCommitMessage;
     return this;
@@ -187,13 +220,15 @@
             .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
             .setValidate(validate)
             .setSendEmail(sendEmail);
+
+    if (!rebasedCommit.getFilesWithGitConflicts().isEmpty()
+        && !notes.getChange().isWorkInProgress()) {
+      patchSetInserter.setWorkInProgress(true);
+    }
+
     if (postMessage) {
       patchSetInserter.setMessage(
-          "Patch Set "
-              + rebasedPatchSetId.get()
-              + ": Patch Set "
-              + originalPatchSet.id().get()
-              + " was rebased");
+          messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
     }
 
     if (base != null && !base.notes().getChange().isMerged()) {
@@ -208,6 +243,24 @@
     patchSetInserter.updateRepo(ctx);
   }
 
+  private static String messageForRebasedChange(
+      PatchSet.Id rebasePatchSetId, PatchSet.Id originalPatchSetId, CodeReviewCommit commit) {
+    StringBuilder stringBuilder =
+        new StringBuilder(
+            String.format(
+                "Patch Set %d: Patch Set %d was rebased",
+                rebasePatchSetId.get(), originalPatchSetId.get()));
+
+    if (!commit.getFilesWithGitConflicts().isEmpty()) {
+      stringBuilder.append("\n\nThe following files contain Git conflicts:\n");
+      commit.getFilesWithGitConflicts().stream()
+          .sorted()
+          .forEach(filePath -> stringBuilder.append("* ").append(filePath).append("\n"));
+    }
+
+    return stringBuilder.toString();
+  }
+
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, IOException, BadRequestException {
@@ -221,7 +274,7 @@
     patchSetInserter.postUpdate(ctx);
   }
 
-  public RevCommit getRebasedCommit() {
+  public CodeReviewCommit getRebasedCommit() {
     checkState(rebasedCommit != null, "getRebasedCommit() only valid after updateRepo");
     return rebasedCommit;
   }
@@ -254,7 +307,7 @@
    * @throws MergeConflictException the rebase failed due to a merge conflict.
    * @throws IOException the merge failed for another reason.
    */
-  private RevCommit rebaseCommit(
+  private CodeReviewCommit rebaseCommit(
       RepoContext ctx, RevCommit original, ObjectId base, String commitMessage)
       throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
@@ -266,15 +319,56 @@
     ThreeWayMerger merger =
         newMergeUtil().newThreeWayMerger(ctx.getInserter(), ctx.getRepoView().getConfig());
     merger.setBase(parentCommit);
+
+    DirCache dc = DirCache.newInCore();
+    if (allowConflicts && merger instanceof ResolveMerger) {
+      // The DirCache must be set on ResolveMerger before calling
+      // ResolveMerger#merge(AnyObjectId...) otherwise the entries in DirCache don't get populated.
+      ((ResolveMerger) merger).setDirCache(dc);
+    }
+
     boolean success = merger.merge(original, base);
 
-    if (!success || merger.getResultTreeId() == null) {
-      throw new MergeConflictException(
-          "The change could not be rebased due to a conflict during merge.");
+    ObjectId tree;
+    ImmutableSet<String> filesWithGitConflicts;
+    if (success) {
+      filesWithGitConflicts = null;
+      tree = merger.getResultTreeId();
+    } else {
+      List<String> conflicts = ImmutableList.of();
+      if (merger instanceof ResolveMerger) {
+        conflicts = ((ResolveMerger) merger).getUnmergedPaths();
+      }
+
+      if (!allowConflicts || !(merger instanceof ResolveMerger)) {
+        throw new MergeConflictException(
+            "The change could not be rebased due to a conflict during merge.\n\n"
+                + MergeUtil.createConflictMessage(conflicts));
+      }
+
+      Map<String, MergeResult<? extends Sequence>> mergeResults =
+          ((ResolveMerger) merger).getMergeResults();
+
+      filesWithGitConflicts =
+          mergeResults.entrySet().stream()
+              .filter(e -> e.getValue().containsConflicts())
+              .map(Map.Entry::getKey)
+              .collect(toImmutableSet());
+
+      tree =
+          MergeUtil.mergeWithConflicts(
+              ctx.getRevWalk(),
+              ctx.getInserter(),
+              dc,
+              "PATCH SET",
+              original,
+              "BASE",
+              ctx.getRevWalk().parseCommit(base),
+              mergeResults);
     }
 
     CommitBuilder cb = new CommitBuilder();
-    cb.setTreeId(merger.getResultTreeId());
+    cb.setTreeId(tree);
     cb.setParentId(base);
     cb.setAuthor(original.getAuthorIdent());
     cb.setMessage(commitMessage);
@@ -290,6 +384,8 @@
     }
     ObjectId objectId = ctx.getInserter().insert(cb);
     ctx.getInserter().flush();
-    return ctx.getRevWalk().parseCommit(objectId);
+    CodeReviewCommit commit = ((CodeReviewRevWalk) ctx.getRevWalk()).parseCommit(objectId);
+    commit.setFilesWithGitConflicts(filesWithGitConflicts);
+    return commit;
   }
 }
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index 3d986d2..5d55b4d 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -19,7 +19,6 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
@@ -140,12 +139,12 @@
     }
 
     logger.atFine().log(
-        "Adding account %d from author/committer identity of commit %s as reviewer to change %d",
+        "Adding account %d from author/committer identity of commit %s as cc to change %d",
         accountId.get(), commitId.name(), change.getChangeId());
 
     InternalAddReviewerInput in = new InternalAddReviewerInput();
     in.reviewer = accountId.toString();
-    in.state = REVIEWER;
+    in.state = CC;
     in.notify = notify;
     in.otherFailureBehavior = FailureBehavior.IGNORE;
     return Optional.of(in);
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index a3136d4a..761b57d 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -47,20 +46,17 @@
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final AccountLoader.Factory accountLoaderFactory;
-  private final SubmitRuleEvaluator submitRuleEvaluator;
 
   @Inject
   ReviewerJson(
       PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
-      AccountLoader.Factory accountLoaderFactory,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+      AccountLoader.Factory accountLoaderFactory) {
     this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.accountLoaderFactory = accountLoaderFactory;
-    submitRuleEvaluator = submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
   }
 
   public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
@@ -123,7 +119,7 @@
     if (ps != null) {
       PermissionBackend.ForChange perm = permissionBackend.absentUser(reviewerAccountId).change(cd);
 
-      for (SubmitRecord rec : submitRuleEvaluator.evaluate(cd)) {
+      for (SubmitRecord rec : cd.submitRecords(SubmitRuleOptions.defaults())) {
         if (rec.labels == null) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 414107f..b702440 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -164,7 +164,12 @@
    * RevWalk and assumes it is backed by an open repository.
    */
   public CommitInfo getCommitInfo(
-      Project.NameKey project, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
+      Project.NameKey project,
+      RevWalk rw,
+      RevCommit commit,
+      boolean addLinks,
+      boolean fillCommit,
+      String branchName)
       throws IOException {
     CommitInfo info = new CommitInfo();
     if (fillCommit) {
@@ -177,7 +182,8 @@
     info.message = commit.getFullMessage();
 
     if (addLinks) {
-      ImmutableList<WebLinkInfo> links = webLinks.getPatchSetLinks(project, commit.name());
+      ImmutableList<WebLinkInfo> links =
+          webLinks.getPatchSetLinks(project, commit.name(), commit.getFullMessage(), branchName);
       info.webLinks = links.isEmpty() ? null : links;
     }
 
@@ -187,7 +193,8 @@
       i.commit = parent.name();
       i.subject = parent.getShortMessage();
       if (addLinks) {
-        ImmutableList<WebLinkInfo> parentLinks = webLinks.getParentLinks(project, parent.name());
+        ImmutableList<WebLinkInfo> parentLinks =
+            webLinks.getParentLinks(project, parent.name(), parent.getFullMessage(), branchName);
         i.webLinks = parentLinks.isEmpty() ? null : parentLinks;
       }
       info.parents.add(i);
@@ -288,11 +295,12 @@
       String rev = in.commitId().name();
       RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
       rw.parseBody(commit);
+      String branchName = cd.change().getDest().branch();
       if (setCommit) {
-        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit);
+        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit, branchName);
       }
       if (addFooters) {
-        Ref ref = repo.exactRef(cd.change().getDest().branch());
+        Ref ref = repo.exactRef(branchName);
         RevCommit mergeTip = null;
         if (ref != null) {
           mergeTip = rw.parseCommit(ref.getObjectId());
@@ -307,7 +315,7 @@
 
     if (has(ALL_FILES) || (out.isCurrent && has(CURRENT_FILES))) {
       try {
-        out.files = fileInfoJson.toFileInfoMap(c, in);
+        out.files = fileInfoJson.getFileInfoMap(c, in);
         out.files.remove(Patch.COMMIT_MSG);
         out.files.remove(Patch.MERGE_LIST);
       } catch (ResourceConflictException e) {
@@ -319,7 +327,7 @@
       actionJson.addRevisionActions(
           changeInfo,
           out,
-          new RevisionResource(changeResourceFactory.create(cd.notes(), userProvider.get()), in));
+          new RevisionResource(changeResourceFactory.create(cd, userProvider.get()), in));
     }
 
     if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
diff --git a/java/com/google/gerrit/server/restapi/change/SetTopicOp.java b/java/com/google/gerrit/server/change/SetTopicOp.java
similarity index 88%
rename from java/com/google/gerrit/server/restapi/change/SetTopicOp.java
rename to java/com/google/gerrit/server/change/SetTopicOp.java
index 9eff5c1..c4a49b0 100644
--- a/java/com/google/gerrit/server/restapi/change/SetTopicOp.java
+++ b/java/com/google/gerrit/server/change/SetTopicOp.java
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.restapi.change;
+package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.extensions.api.changes.TopicInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.extensions.events.TopicEdited;
@@ -31,10 +31,10 @@
 
 public class SetTopicOp implements BatchUpdateOp {
   public interface Factory {
-    SetTopicOp create(TopicInput input);
+    SetTopicOp create(@Nullable String topic);
   }
 
-  private final TopicInput input;
+  private final String topic;
   private final TopicEdited topicEdited;
   private final ChangeMessagesUtil cmUtil;
 
@@ -44,8 +44,8 @@
 
   @Inject
   public SetTopicOp(
-      TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Assisted TopicInput input) {
-    this.input = input;
+      TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Nullable @Assisted String topic) {
+    this.topic = topic;
     this.topicEdited = topicEdited;
     this.cmUtil = cmUtil;
   }
@@ -54,7 +54,7 @@
   public boolean updateChange(ChangeContext ctx) throws BadRequestException {
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-    newTopicName = Strings.nullToEmpty(input.topic);
+    newTopicName = Strings.nullToEmpty(topic);
     oldTopicName = Strings.nullToEmpty(change.getTopic());
     if (oldTopicName.equals(newTopicName)) {
       return false;
diff --git a/java/com/google/gerrit/server/comment/CommentContextCache.java b/java/com/google/gerrit/server/comment/CommentContextCache.java
new file mode 100644
index 0000000..c954a38
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCache.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.server.comment;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.CommentContext;
+
+/**
+ * Caches the context lines of comments (source file content surrounding and including the lines
+ * where the comment was written)
+ */
+public interface CommentContextCache {
+
+  /**
+   * Returns the context lines for a single comment. Works for published and draft comments.
+   *
+   * @param key a key representing a subset of fields for a comment that serves as an identifier.
+   * @return a {@link CommentContext} object containing all line numbers and text of the context.
+   */
+  CommentContext get(CommentContextKey key);
+
+  /**
+   * Returns the context lines for multiple comments - identified by their {@code keys}. Works for
+   * published and draft comments.
+   *
+   * @param keys list of keys, where each key represents a single comment through its project,
+   *     change ID, patchset, path and ID. The keys can belong to different projects and changes.
+   * @return {@code Map} of {@code CommentContext} containing the context for all comments.
+   */
+  ImmutableMap<CommentContextKey, CommentContext> getAll(Iterable<CommentContextKey> keys);
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
new file mode 100644
index 0000000..e12b538
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -0,0 +1,313 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License
+
+package com.google.gerrit.server.comment;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto.CommentContextProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.comment.CommentContextLoader.ContextInput;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/** Implementation of {@link CommentContextCache}. */
+public class CommentContextCacheImpl implements CommentContextCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String CACHE_NAME = "comment_context";
+
+  /**
+   * Comment context is expected to contain just few lines of code to be displayed beside the
+   * comment. Setting an upper bound of 100 for padding.
+   */
+  @VisibleForTesting public static final int MAX_CONTEXT_PADDING = 50;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        persist(CACHE_NAME, CommentContextKey.class, CommentContext.class)
+            .version(5)
+            .diskLimit(1 << 30) // limit the total cache size to 1 GB
+            .maximumWeight(1 << 23) // Limit the size of the in-memory cache to 8 MB
+            .weigher(CommentContextWeigher.class)
+            .keySerializer(CommentContextKey.Serializer.INSTANCE)
+            .valueSerializer(CommentContextSerializer.INSTANCE)
+            .loader(Loader.class);
+
+        bind(CommentContextCache.class).to(CommentContextCacheImpl.class);
+      }
+    };
+  }
+
+  private final LoadingCache<CommentContextKey, CommentContext> contextCache;
+
+  @Inject
+  CommentContextCacheImpl(
+      @Named(CACHE_NAME) LoadingCache<CommentContextKey, CommentContext> contextCache) {
+    this.contextCache = contextCache;
+  }
+
+  @Override
+  public CommentContext get(CommentContextKey comment) {
+    return getAll(ImmutableList.of(comment)).get(comment);
+  }
+
+  @Override
+  public ImmutableMap<CommentContextKey, CommentContext> getAll(
+      Iterable<CommentContextKey> inputKeys) {
+    ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder();
+
+    List<CommentContextKey> adjustedKeys =
+        Streams.stream(inputKeys)
+            .map(CommentContextCacheImpl::adjustMaxContextPadding)
+            .collect(ImmutableList.toImmutableList());
+
+    // Convert the input keys to the same keys but with their file paths hashed
+    Map<CommentContextKey, CommentContextKey> keysToCacheKeys =
+        adjustedKeys.stream()
+            .collect(
+                Collectors.toMap(
+                    Function.identity(),
+                    k -> k.toBuilder().path(Loader.hashPath(k.path())).build()));
+
+    try {
+      ImmutableMap<CommentContextKey, CommentContext> allContext =
+          contextCache.getAll(keysToCacheKeys.values());
+
+      for (CommentContextKey inputKey : inputKeys) {
+        CommentContextKey cacheKey = keysToCacheKeys.get(adjustMaxContextPadding(inputKey));
+        result.put(inputKey, allContext.get(cacheKey));
+      }
+      return result.build();
+    } catch (ExecutionException e) {
+      throw new StorageException("Failed to retrieve comments' context", e);
+    }
+  }
+
+  private static CommentContextKey adjustMaxContextPadding(CommentContextKey key) {
+    if (key.contextPadding() < 0) {
+      logger.atWarning().log(
+          "Cannot set context padding to a negative number %d. Adjusting the number to 0",
+          key.contextPadding());
+      return key.toBuilder().contextPadding(0).build();
+    }
+    if (key.contextPadding() > MAX_CONTEXT_PADDING) {
+      logger.atWarning().log(
+          "Number of requested context lines is %d and exceeding the configured maximum of %d."
+              + " Adjusting the number to the maximum.",
+          key.contextPadding(), MAX_CONTEXT_PADDING);
+      return key.toBuilder().contextPadding(MAX_CONTEXT_PADDING).build();
+    }
+    return key;
+  }
+
+  public enum CommentContextSerializer implements CacheSerializer<CommentContext> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(CommentContext commentContext) {
+      AllCommentContextProto.Builder allBuilder = AllCommentContextProto.newBuilder();
+      allBuilder.setContentType(commentContext.contentType());
+
+      commentContext
+          .lines()
+          .entrySet()
+          .forEach(
+              c ->
+                  allBuilder.addContext(
+                      CommentContextProto.newBuilder()
+                          .setLineNumber(c.getKey())
+                          .setContextLine(c.getValue())));
+      return Protos.toByteArray(allBuilder.build());
+    }
+
+    @Override
+    public CommentContext deserialize(byte[] in) {
+      ImmutableMap.Builder<Integer, String> contextLinesMap = ImmutableMap.builder();
+      AllCommentContextProto proto = Protos.parseUnchecked(AllCommentContextProto.parser(), in);
+      proto.getContextList().stream()
+          .forEach(c -> contextLinesMap.put(c.getLineNumber(), c.getContextLine()));
+      return CommentContext.create(contextLinesMap.build(), proto.getContentType());
+    }
+  }
+
+  static class Loader extends CacheLoader<CommentContextKey, CommentContext> {
+    private final ChangeNotes.Factory notesFactory;
+    private final CommentsUtil commentsUtil;
+    private final CommentContextLoader.Factory factory;
+
+    @Inject
+    Loader(
+        CommentsUtil commentsUtil,
+        ChangeNotes.Factory notesFactory,
+        CommentContextLoader.Factory factory) {
+      this.commentsUtil = commentsUtil;
+      this.notesFactory = notesFactory;
+      this.factory = factory;
+    }
+
+    /**
+     * Load the comment context of a single comment identified by its key.
+     *
+     * @param key a {@link CommentContextKey} identifying a comment.
+     * @return the comment context associated with the comment.
+     * @throws IOException an error happened while parsing the commit or loading the file where the
+     *     comment is written.
+     */
+    @Override
+    public CommentContext load(CommentContextKey key) throws IOException {
+      return loadAll(ImmutableList.of(key)).get(key);
+    }
+
+    /**
+     * Load the comment context of different comments identified by their keys.
+     *
+     * @param keys list of {@link CommentContextKey} identifying some comments.
+     * @return a map of the input keys to their corresponding comment context.
+     * @throws IOException an error happened while parsing the commits or loading the files where
+     *     the comments are written.
+     */
+    @Override
+    public Map<CommentContextKey, CommentContext> loadAll(
+        Iterable<? extends CommentContextKey> keys) throws IOException {
+      ImmutableMap.Builder<CommentContextKey, CommentContext> result =
+          ImmutableMap.builderWithExpectedSize(Iterables.size(keys));
+
+      Map<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> groupedKeys =
+          Streams.stream(keys)
+              .distinct()
+              .map(k -> (CommentContextKey) k)
+              .collect(
+                  Collectors.groupingBy(
+                      CommentContextKey::project,
+                      Collectors.groupingBy(CommentContextKey::changeId)));
+
+      for (Map.Entry<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> perProject :
+          groupedKeys.entrySet()) {
+        Map<Change.Id, List<CommentContextKey>> keysPerProject = perProject.getValue();
+
+        for (Map.Entry<Change.Id, List<CommentContextKey>> perChange : keysPerProject.entrySet()) {
+          Map<CommentContextKey, CommentContext> context =
+              loadForSameChange(perChange.getValue(), perProject.getKey(), perChange.getKey());
+          result.putAll(context);
+        }
+      }
+      return result.build();
+    }
+
+    /**
+     * Load the comment context for comments (published and drafts) of the same project and change
+     * ID.
+     *
+     * @param keys a list of keys corresponding to some comments
+     * @param project a gerrit project/repository
+     * @param changeId an identifier for a change
+     * @return a map of the input keys to their corresponding {@link CommentContext}
+     */
+    private Map<CommentContextKey, CommentContext> loadForSameChange(
+        List<CommentContextKey> keys, Project.NameKey project, Change.Id changeId)
+        throws IOException {
+      ChangeNotes notes = notesFactory.createChecked(project, changeId);
+      List<HumanComment> humanComments = commentsUtil.publishedHumanCommentsByChange(notes);
+      List<HumanComment> drafts = commentsUtil.draftByChange(notes);
+      List<HumanComment> allComments =
+          Streams.concat(humanComments.stream(), drafts.stream()).collect(Collectors.toList());
+      CommentContextLoader loader = factory.create(project);
+      Map<ContextInput, CommentContextKey> commentsToKeys = new HashMap<>();
+      for (CommentContextKey key : keys) {
+        Comment comment = getCommentForKey(allComments, key);
+        commentsToKeys.put(ContextInput.fromComment(comment, key.contextPadding()), key);
+      }
+      Map<ContextInput, CommentContext> allContext = loader.getContext(commentsToKeys.keySet());
+      return allContext.entrySet().stream()
+          .collect(Collectors.toMap(e -> commentsToKeys.get(e.getKey()), Map.Entry::getValue));
+    }
+
+    /**
+     * Return the single comment from the {@code allComments} input list corresponding to the key
+     * parameter.
+     *
+     * @param allComments a list of comments.
+     * @param key a key representing a single comment.
+     * @return the single comment corresponding to the key parameter.
+     */
+    private Comment getCommentForKey(List<HumanComment> allComments, CommentContextKey key) {
+      return allComments.stream()
+          .filter(
+              c ->
+                  key.id().equals(c.key.uuid)
+                      && key.patchset() == c.key.patchSetId
+                      && key.path().equals(hashPath(c.key.filename)))
+          .findFirst()
+          .orElseThrow(() -> new IllegalArgumentException("Unable to find comment for key " + key));
+    }
+
+    /**
+     * Hash an input String using the general {@link Hashing#murmur3_128()} hash.
+     *
+     * @param input the input String
+     * @return a hashed representation of the input String
+     */
+    static String hashPath(String input) {
+      return Hashing.murmur3_128().hashString(input, UTF_8).toString();
+    }
+  }
+
+  private static class CommentContextWeigher implements Weigher<CommentContextKey, CommentContext> {
+    @Override
+    public int weigh(CommentContextKey key, CommentContext commentContext) {
+      int size = 0;
+      size += key.id().length();
+      size += key.path().length();
+      size += key.project().get().length();
+      size += 4;
+      for (String line : commentContext.lines().values()) {
+        size += 4; // line number
+        size += line.length(); // number of characters in the context line
+      }
+      return size;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
new file mode 100644
index 0000000..af2ae92
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -0,0 +1,88 @@
+package com.google.gerrit.server.comment;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+
+/**
+ * An identifier of a comment that should be used to load the comment context using {@link
+ * CommentContextCache#get(CommentContextKey)}, or {@link CommentContextCache#getAll(Iterable)}.
+ *
+ * <p>The {@link CommentContextCacheImpl} implementation uses this class as the cache key, while
+ * replacing the {@link #path()} field with the hashed path.
+ */
+@AutoValue
+public abstract class CommentContextKey {
+  abstract Project.NameKey project();
+
+  abstract Change.Id changeId();
+
+  /** The unique comment ID. */
+  abstract String id();
+
+  /** File path at which the comment was written. */
+  abstract String path();
+
+  abstract Integer patchset();
+
+  /** Number of extra lines of context that should be added before and after the comment range. */
+  abstract int contextPadding();
+
+  abstract Builder toBuilder();
+
+  public static Builder builder() {
+    return new AutoValue_CommentContextKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder project(Project.NameKey nameKey);
+
+    public abstract Builder changeId(Change.Id changeId);
+
+    public abstract Builder id(String id);
+
+    public abstract Builder path(String path);
+
+    public abstract Builder patchset(Integer patchset);
+
+    public abstract Builder contextPadding(Integer numLines);
+
+    public abstract CommentContextKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<CommentContextKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(CommentContextKey key) {
+      return Protos.toByteArray(
+          Cache.CommentContextKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setChangeId(key.changeId().toString())
+              .setPatchset(key.patchset())
+              .setPathHash(key.path())
+              .setCommentId(key.id())
+              .setContextPadding(key.contextPadding())
+              .build());
+    }
+
+    @Override
+    public CommentContextKey deserialize(byte[] in) {
+      Cache.CommentContextKeyProto proto =
+          Protos.parseUnchecked(Cache.CommentContextKeyProto.parser(), in);
+      return CommentContextKey.builder()
+          .project(Project.NameKey.parse(proto.getProject()))
+          .changeId(Change.Id.tryParse(proto.getChangeId()).get())
+          .patchset(proto.getPatchset())
+          .id(proto.getCommentId())
+          .path(proto.getPathHash())
+          .contextPadding(proto.getContextPadding())
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextLoader.java b/java/com/google/gerrit/server/comment/CommentContextLoader.java
new file mode 100644
index 0000000..a5aca48
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextLoader.java
@@ -0,0 +1,293 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.comment;
+
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.ContextLineInfo;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mime.FileTypeRegistry;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.SrcContentResolver;
+import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import eu.medsea.mimeutil.MimeType;
+import eu.medsea.mimeutil.MimeUtil2;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+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.treewalk.TreeWalk;
+
+/**
+ * Computes the list of {@link ContextLineInfo} for a given comment, that is, the lines of the
+ * source file surrounding and including the area where the comment was written.
+ */
+public class CommentContextLoader {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final FileTypeRegistry registry;
+  private final GitRepositoryManager repoManager;
+  private final Project.NameKey project;
+  private final ProjectState projectState;
+
+  public interface Factory {
+    CommentContextLoader create(Project.NameKey project);
+  }
+
+  @Inject
+  CommentContextLoader(
+      FileTypeRegistry registry,
+      GitRepositoryManager repoManager,
+      ProjectCache projectCache,
+      @Assisted Project.NameKey project) {
+    this.registry = registry;
+    this.repoManager = repoManager;
+    this.project = project;
+    projectState = projectCache.get(project).orElseThrow(illegalState(project));
+  }
+
+  /**
+   * Load the comment context for multiple contextInputs at once. This method will open the
+   * repository and read the source files for all necessary contextInputs' file paths.
+   *
+   * @param contextInputs a list of contextInputs.
+   * @return a Map where all entries consist of the input contextInputs and the values are their
+   *     corresponding {@link CommentContext}.
+   */
+  public Map<ContextInput, CommentContext> getContext(Collection<ContextInput> contextInputs)
+      throws IOException {
+    ImmutableMap.Builder<ContextInput, CommentContext> result =
+        ImmutableMap.builderWithExpectedSize(Iterables.size(contextInputs));
+
+    // Group contextInputs by commit ID so that each commit is parsed only once
+    Map<ObjectId, List<ContextInput>> commentsByCommitId =
+        contextInputs.stream().collect(groupingBy(ContextInput::commitId));
+
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      for (ObjectId commitId : commentsByCommitId.keySet()) {
+        RevCommit commit = rw.parseCommit(commitId);
+        for (ContextInput contextInput : commentsByCommitId.get(commitId)) {
+          Optional<Range> range = getStartAndEndLines(contextInput);
+          if (!range.isPresent()) {
+            result.put(contextInput, CommentContext.empty());
+            continue;
+          }
+          String filePath = contextInput.filePath();
+          switch (filePath) {
+            case COMMIT_MSG:
+              result.put(
+                  contextInput,
+                  getContextForCommitMessage(
+                      rw.getObjectReader(), commit, range.get(), contextInput.contextPadding()));
+              break;
+            case MERGE_LIST:
+              result.put(
+                  contextInput,
+                  getContextForMergeList(
+                      rw.getObjectReader(), commit, range.get(), contextInput.contextPadding()));
+              break;
+            default:
+              result.put(
+                  contextInput,
+                  getContextForFilePath(
+                      repo, rw, commit, filePath, range.get(), contextInput.contextPadding()));
+          }
+        }
+      }
+      return result.build();
+    }
+  }
+
+  private CommentContext getContextForCommitMessage(
+      ObjectReader reader, RevCommit commit, Range commentRange, int contextPadding)
+      throws IOException {
+    Text text = Text.forCommit(reader, commit);
+    return createContext(
+        text, commentRange, contextPadding, FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE);
+  }
+
+  private CommentContext getContextForMergeList(
+      ObjectReader reader, RevCommit commit, Range commentRange, int contextPadding)
+      throws IOException {
+    ComparisonType cmp = ComparisonType.againstParent(1);
+    Text text = Text.forMergeList(cmp, reader, commit);
+    return createContext(
+        text, commentRange, contextPadding, FileContentUtil.TEXT_X_GERRIT_MERGE_LIST);
+  }
+
+  private CommentContext getContextForFilePath(
+      Repository repo,
+      RevWalk rw,
+      RevCommit commit,
+      String filePath,
+      Range commentRange,
+      int contextPadding)
+      throws IOException {
+    // TODO(ghareeb): We can further group the comments by file paths to avoid opening
+    // the same file multiple times.
+    try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), filePath, commit.getTree())) {
+      if (tw == null) {
+        logger.atWarning().log(
+            "Could not find path %s in the git tree of ID %s.", filePath, commit.getTree().getId());
+        return CommentContext.empty();
+      }
+      ObjectId id = tw.getObjectId(0);
+      byte[] sourceContent = SrcContentResolver.getSourceContent(repo, id, tw.getFileMode(0));
+      Text textSrc = new Text(sourceContent);
+      String contentType = getContentType(tw, filePath, textSrc);
+      return createContext(textSrc, commentRange, contextPadding, contentType);
+    }
+  }
+
+  private String getContentType(TreeWalk tw, String filePath, Text src) {
+    PatchScript.FileMode fileMode = PatchScript.FileMode.fromJgitFileMode(tw.getFileMode(0));
+    String mimeType = MimeUtil2.UNKNOWN_MIME_TYPE.toString();
+    if (src.size() > 0 && PatchScript.FileMode.SYMLINK != fileMode) {
+      MimeType registryMimeType = registry.getMimeType(filePath, src.getContent());
+      mimeType = registryMimeType.toString();
+    }
+    return FileContentUtil.resolveContentType(projectState, filePath, fileMode, mimeType);
+  }
+
+  private static CommentContext createContext(
+      Text src, Range commentRange, int contextPadding, String contentType) {
+    if (commentRange.start() < 1 || commentRange.end() - 1 > src.size()) {
+      // TODO(ghareeb): We should throw an exception in this case. See
+      // https://bugs.chromium.org/p/gerrit/issues/detail?id=14102 which is an example where the
+      // diff contains an extra line not in the original file.
+      return CommentContext.empty();
+    }
+    commentRange = adjustRange(commentRange, contextPadding, src.size());
+    ImmutableMap.Builder<Integer, String> context =
+        ImmutableMap.builderWithExpectedSize(commentRange.end() - commentRange.start());
+    for (int i = commentRange.start(); i < commentRange.end(); i++) {
+      context.put(i, src.getString(i - 1));
+    }
+    return CommentContext.create(context.build(), contentType);
+  }
+
+  /**
+   * Adjust the {@code commentRange} parameter by adding {@code contextPadding} lines before and
+   * after the comment range.
+   */
+  private static Range adjustRange(Range commentRange, int contextPadding, int fileLines) {
+    int newStartLine = commentRange.start() - contextPadding;
+    int newEndLine = commentRange.end() + contextPadding;
+    return Range.create(Math.max(1, newStartLine), Math.min(fileLines + 1, newEndLine));
+  }
+
+  private static Optional<Range> getStartAndEndLines(ContextInput comment) {
+    if (comment.range() != null) {
+      return Optional.of(Range.create(comment.range().startLine, comment.range().endLine + 1));
+    } else if (comment.lineNumber() > 0) {
+      return Optional.of(Range.create(comment.lineNumber(), comment.lineNumber() + 1));
+    }
+    return Optional.empty();
+  }
+
+  @AutoValue
+  abstract static class Range {
+    static Range create(int start, int end) {
+      return new AutoValue_CommentContextLoader_Range(start, end);
+    }
+
+    /** Start line of the comment (inclusive). */
+    abstract int start();
+
+    /** End line of the comment (exclusive). */
+    abstract int end();
+
+    /** Number of lines covered by this range. */
+    int size() {
+      return end() - start();
+    }
+  }
+
+  /** This entity only contains comment fields needed to load the comment context. */
+  @AutoValue
+  abstract static class ContextInput {
+    static ContextInput fromComment(Comment comment, int contextPadding) {
+      return new AutoValue_CommentContextLoader_ContextInput.Builder()
+          .commitId(comment.getCommitId())
+          .filePath(comment.key.filename)
+          .range(comment.range)
+          .lineNumber(comment.lineNbr)
+          .contextPadding(contextPadding)
+          .build();
+    }
+
+    /** 20 bytes SHA-1 of the patchset commit containing the file where the comment is written. */
+    abstract ObjectId commitId();
+
+    /** File path where the comment is written. */
+    abstract String filePath();
+
+    /**
+     * Position of the comment in the file (start line, start char, end line, end char). This field
+     * can be null if the range is not available for this comment.
+     */
+    @Nullable
+    abstract Comment.Range range();
+
+    /**
+     * The 1-based line number where the comment is written. A value 0 means that the line number is
+     * not available for this comment.
+     */
+    abstract Integer lineNumber();
+
+    /** Number of extra lines of context that should be added before and after the comment range. */
+    abstract Integer contextPadding();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      public abstract Builder commitId(ObjectId commitId);
+
+      public abstract Builder filePath(String filePath);
+
+      public abstract Builder range(@Nullable Comment.Range range);
+
+      public abstract Builder lineNumber(Integer lineNumber);
+
+      public abstract Builder contextPadding(Integer contextPadding);
+
+      public abstract ContextInput build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index 43c05e0..27ded63 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -16,6 +16,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.flogger.FluentLogger;
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Modifier;
@@ -30,6 +31,8 @@
 import org.eclipse.jgit.lib.Config;
 
 public class ConfigUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @SuppressWarnings("unchecked")
   private static <T> T[] allValuesOf(T defaultValue) {
     try {
@@ -138,7 +141,12 @@
     } else {
       for (String string : values) {
         if (string != null) {
-          list.add(getEnum(section, subsection, setting, string, all));
+          try {
+            list.add(getEnum(section, subsection, setting, string, all));
+          } catch (IllegalArgumentException ex) {
+            // It's better to ignore a wrongly configured enum, rather than fail to load Gerrit.
+            logger.atWarning().log(ex.getMessage());
+          }
         }
       }
     }
diff --git a/java/com/google/gerrit/server/config/EnableReverseDnsLookup.java b/java/com/google/gerrit/server/config/EnablePeerIPInReflogRecord.java
similarity index 88%
rename from java/com/google/gerrit/server/config/EnableReverseDnsLookup.java
rename to java/com/google/gerrit/server/config/EnablePeerIPInReflogRecord.java
index ec57338..708bb15 100644
--- a/java/com/google/gerrit/server/config/EnableReverseDnsLookup.java
+++ b/java/com/google/gerrit/server/config/EnablePeerIPInReflogRecord.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -21,4 +21,4 @@
 
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface EnableReverseDnsLookup {}
+public @interface EnablePeerIPInReflogRecord {}
diff --git a/java/com/google/gerrit/server/config/EnableReverseDnsLookupProvider.java b/java/com/google/gerrit/server/config/EnablePeerIPInReflogRecordProvider.java
similarity index 64%
rename from java/com/google/gerrit/server/config/EnableReverseDnsLookupProvider.java
rename to java/com/google/gerrit/server/config/EnablePeerIPInReflogRecordProvider.java
index 71086a9..c274b84 100644
--- a/java/com/google/gerrit/server/config/EnableReverseDnsLookupProvider.java
+++ b/java/com/google/gerrit/server/config/EnablePeerIPInReflogRecordProvider.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -18,16 +18,17 @@
 import com.google.inject.Provider;
 import org.eclipse.jgit.lib.Config;
 
-public class EnableReverseDnsLookupProvider implements Provider<Boolean> {
-  private final Boolean enableReverseDnsLookup;
+public class EnablePeerIPInReflogRecordProvider implements Provider<Boolean> {
+  private final Boolean enablePeerIPInReflogRecord;
 
   @Inject
-  EnableReverseDnsLookupProvider(@GerritServerConfig Config config) {
-    enableReverseDnsLookup = config.getBoolean("gerrit", null, "enableReverseDnsLookup", false);
+  EnablePeerIPInReflogRecordProvider(@GerritServerConfig Config config) {
+    enablePeerIPInReflogRecord =
+        config.getBoolean("gerrit", null, "enablePeerIPInReflogRecord", false);
   }
 
   @Override
   public Boolean get() {
-    return enableReverseDnsLookup;
+    return enablePeerIPInReflogRecord;
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 78dd38c..bb851e2 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -81,6 +81,7 @@
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.ExceptionHookImpl;
+import com.google.gerrit.server.ExternalUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.TraceRequestListener;
@@ -100,21 +101,20 @@
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
-import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeETagComputation;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
-import com.google.gerrit.server.change.LabelsJson;
+import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.change.RevisionJson;
+import com.google.gerrit.server.comment.CommentContextCacheImpl;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.EventsMetrics;
@@ -130,6 +130,7 @@
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.receive.PluginPushOption;
 import com.google.gerrit.server.git.receive.ReceiveCommitsModule;
 import com.google.gerrit.server.git.validators.CommentCountValidator;
 import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
@@ -162,6 +163,7 @@
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.notedb.NoteDbModule;
+import com.google.gerrit.server.patch.DiffOperationsImpl;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.patch.PatchScriptFactory;
 import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
@@ -180,6 +182,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.quota.QuotaEnforcer;
+import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
@@ -218,12 +221,10 @@
 /** Starts global state with standard dependencies. */
 public class GerritGlobalModule extends FactoryModule {
   private final Config cfg;
-  private final AuthModule authModule;
 
   @Inject
-  GerritGlobalModule(@GerritServerConfig Config cfg, AuthModule authModule) {
+  GerritGlobalModule(@GerritServerConfig Config cfg) {
     this.cfg = cfg;
-    this.authModule = authModule;
   }
 
   @Override
@@ -233,7 +234,6 @@
     bind(IdGenerator.class);
     bind(RulesCache.class);
     bind(BlameCache.class).to(BlameCacheImpl.class);
-    install(authModule);
     install(AccountCacheImpl.module());
     install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
@@ -246,11 +246,12 @@
     install(ServiceUserClassifierImpl.module());
     install(PatchListCacheImpl.module());
     install(ProjectCacheImpl.module());
+    install(DiffOperationsImpl.module());
     install(SectionSortCache.module());
     install(SubmitStrategy.module());
     install(TagCache.module());
-    install(OAuthTokenCache.module());
     install(PureRevertCache.module());
+    install(CommentContextCacheImpl.module());
 
     install(new AccessControlModule());
     install(new CmdLineParserModule());
@@ -265,19 +266,20 @@
     install(new IgnoreSelfApprovalRule.Module());
     install(new ReceiveCommitsModule());
     install(new SshAddressesModule());
+    install(new FileInfoJsonModule(cfg));
     install(ThreadLocalRequestContext.module());
 
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeJson.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
-    factory(LabelsJson.Factory.class);
     factory(MergeUtil.Factory.class);
     factory(PatchScriptFactory.Factory.class);
     factory(PatchScriptFactoryForAutoFix.Factory.class);
     factory(ProjectState.Factory.class);
     factory(RevisionJson.Factory.class);
     factory(InboundEmailRejectionSender.Factory.class);
+    factory(ExternalUser.Factory.class);
     bind(PermissionCollection.Factory.class);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
     AccountDefaultDisplayName accountDefaultDisplayName =
@@ -308,8 +310,8 @@
     bind(SoySauce.class).annotatedWith(MailTemplates.class).toProvider(MailSoySauceProvider.class);
     bind(FromAddressGenerator.class).toProvider(FromAddressGeneratorProvider.class).in(SINGLETON);
     bind(Boolean.class)
-        .annotatedWith(EnableReverseDnsLookup.class)
-        .toProvider(EnableReverseDnsLookupProvider.class)
+        .annotatedWith(EnablePeerIPInReflogRecord.class)
+        .toProvider(EnablePeerIPInReflogRecordProvider.class)
         .in(SINGLETON);
 
     bind(PatchSetInfoFactory.class);
@@ -387,6 +389,7 @@
         .toInstance(SuggestReviewers.configListener());
     DynamicSet.setOf(binder(), ExternalIncludedIn.class);
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
+    DynamicSet.setOf(binder(), PluginPushOption.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
     DynamicSet.setOf(binder(), ParentWebLink.class);
     DynamicSet.setOf(binder(), FileWebLink.class);
@@ -413,6 +416,7 @@
     DynamicSet.setOf(binder(), ExceptionHook.class);
     DynamicSet.bind(binder(), ExceptionHook.class).to(ExceptionHookImpl.class);
     DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
+    DynamicSet.setOf(binder(), OnPostReview.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
@@ -437,7 +441,6 @@
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeIsOperandFactory.class);
-    DynamicSet.setOf(binder(), ChangeAttributeFactory.class);
     DynamicSet.setOf(binder(), ChangePluginDefinedInfoFactory.class);
 
     install(new GitwebConfig.LegacyModule(cfg));
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index b5f09fd..0bc4380 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -315,11 +315,13 @@
     }
 
     @Override
-    public WebLinkInfo getFileWebLink(String projectName, String revision, String fileName) {
+    public WebLinkInfo getFileWebLink(
+        String projectName, String revision, String hash, String fileName) {
       if (file != null) {
         return link(
             file.replace("project", encode(projectName))
                 .replace("commit", encode(revision))
+                .replace("hash", encode(hash))
                 .replace("file", encode(fileName))
                 .toString());
       }
@@ -327,8 +329,10 @@
     }
 
     @Override
-    public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+    public WebLinkInfo getPatchSetWebLink(
+        String projectName, String commit, String commitMessage, String branchName) {
       if (revision != null) {
+        // commitMessage and branchName are not needed, hence not used.
         return link(
             revision
                 .replace("project", encode(projectName))
@@ -339,9 +343,10 @@
     }
 
     @Override
-    public WebLinkInfo getParentWebLink(String projectName, String commit) {
+    public WebLinkInfo getParentWebLink(
+        String projectName, String commit, String commitMessage, String branchName) {
       // For Gitweb treat parent revision links the same as patch set links
-      return getPatchSetWebLink(projectName, commit);
+      return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index a9abd1e..2d0f9a5 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -289,7 +289,7 @@
    */
   public Config getProjectPluginConfigWithInheritance(
       Project.NameKey projectName, String pluginName) throws NoSuchProjectException {
-    return getPluginConfig(projectName, pluginName).getWithInheritance(false);
+    return getPluginConfig(projectName, pluginName).getWithInheritance(/* merge= */ false);
   }
 
   /**
@@ -311,7 +311,7 @@
    */
   public Config getProjectPluginConfigWithInheritance(
       ProjectState projectState, String pluginName) {
-    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance(false);
+    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance(/* merge= */ false);
   }
 
   /**
@@ -336,7 +336,7 @@
    */
   public Config getProjectPluginConfigWithMergedInheritance(
       Project.NameKey projectName, String pluginName) throws NoSuchProjectException {
-    return getPluginConfig(projectName, pluginName).getWithInheritance(true);
+    return getPluginConfig(projectName, pluginName).getWithInheritance(/* merge= */ true);
   }
 
   /**
@@ -359,7 +359,7 @@
    */
   public Config getProjectPluginConfigWithMergedInheritance(
       ProjectState projectState, String pluginName) {
-    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance(true);
+    return projectState.getConfig(pluginName + EXTENSION).getWithInheritance(/* merge= */ true);
   }
 
   private ProjectLevelConfig getPluginConfig(Project.NameKey projectName, String pluginName)
diff --git a/java/com/google/gerrit/server/config/SshClientImplementation.java b/java/com/google/gerrit/server/config/SshClientImplementation.java
new file mode 100644
index 0000000..5811e4d
--- /dev/null
+++ b/java/com/google/gerrit/server/config/SshClientImplementation.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Enums;
+import com.google.common.base.Strings;
+
+/* SSH implementation to use by JGit SSH client transport protocol. */
+public enum SshClientImplementation {
+  /** JCraft JSch implementation. */
+  JSCH,
+
+  /** Apache MINA implementation. */
+  APACHE;
+
+  private static final String ENV_VAR = "SSH_CLIENT_IMPLEMENTATION";
+  private static final String SYS_PROP = "gerrit.sshClientImplementation";
+
+  @VisibleForTesting
+  public static SshClientImplementation getFromEnvironment() {
+    String value = System.getenv(ENV_VAR);
+    if (Strings.isNullOrEmpty(value)) {
+      value = System.getProperty(SYS_PROP);
+    }
+    if (Strings.isNullOrEmpty(value)) {
+      return APACHE;
+    }
+    SshClientImplementation client =
+        Enums.getIfPresent(SshClientImplementation.class, value).orNull();
+    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
+      checkArgument(
+          client != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
+    } else {
+      checkArgument(
+          client != null,
+          "Invalid value for system property %s: %s",
+          SYS_PROP,
+          System.getProperty(SYS_PROP));
+    }
+    return client;
+  }
+
+  public boolean isMina() {
+    return this == APACHE;
+  }
+}
diff --git a/java/com/google/gerrit/server/data/BUILD b/java/com/google/gerrit/server/data/BUILD
index c3dc672..1aaab96 100644
--- a/java/com/google/gerrit/server/data/BUILD
+++ b/java/com/google/gerrit/server/data/BUILD
@@ -9,7 +9,6 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/org/apache/commons/net",
         "//lib:gson",
     ],
 )
diff --git a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
index ed4ea8a..2d2b207 100644
--- a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.data;
 
-import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 
 /**
- * Represents a {@link SubmitRequirement} that does not depend on Gerrit internal classes, to be
- * serialized
+ * Represents a {@link LegacySubmitRequirement} that does not depend on Gerrit internal classes, to
+ * be serialized
  */
 public class SubmitRequirementAttribute {
   public String type;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index 2d5e708..d71f83e 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -21,14 +21,14 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.vladsch.flexmark.ast.Block;
 import com.vladsch.flexmark.ast.Heading;
-import com.vladsch.flexmark.ast.Node;
 import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
 import com.vladsch.flexmark.html.HtmlRenderer;
 import com.vladsch.flexmark.parser.Parser;
 import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
-import com.vladsch.flexmark.util.options.MutableDataHolder;
+import com.vladsch.flexmark.util.ast.Block;
+import com.vladsch.flexmark.util.ast.Node;
+import com.vladsch.flexmark.util.data.MutableDataHolder;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
index 00f7ec1..1875b64 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.documentation;
 
 import com.vladsch.flexmark.ast.Heading;
-import com.vladsch.flexmark.ast.Node;
 import com.vladsch.flexmark.ext.anchorlink.AnchorLink;
 import com.vladsch.flexmark.ext.anchorlink.internal.AnchorLinkNodeRenderer;
 import com.vladsch.flexmark.html.HtmlRenderer;
@@ -28,8 +27,9 @@
 import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
 import com.vladsch.flexmark.profiles.pegdown.Extensions;
 import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
-import com.vladsch.flexmark.util.options.DataHolder;
-import com.vladsch.flexmark.util.options.MutableDataHolder;
+import com.vladsch.flexmark.util.ast.Node;
+import com.vladsch.flexmark.util.data.DataHolder;
+import com.vladsch.flexmark.util.data.MutableDataHolder;
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Set;
@@ -119,7 +119,7 @@
 
     public static class Factory implements DelegatingNodeRendererFactory {
       @Override
-      public NodeRenderer create(final DataHolder options) {
+      public NodeRenderer apply(final DataHolder options) {
         return new HeadingNodeRenderer();
       }
 
diff --git a/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index eb4d9ee..de355ea 100644
--- a/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import java.io.IOException;
@@ -29,6 +30,7 @@
   public ReceiveCommand command;
   public Project project;
   public String refName;
+  public ImmutableListMultimap<String, String> pushOptions;
   public Config repoConfig;
   public RevWalk revWalk;
   public RevCommit commit;
@@ -42,6 +44,7 @@
       ReceiveCommand command,
       Project project,
       String refName,
+      ImmutableListMultimap<String, String> pushOptions,
       Config repoConfig,
       ObjectReader reader,
       ObjectId commitId,
@@ -51,6 +54,7 @@
     this.command = command;
     this.project = project;
     this.refName = refName;
+    this.pushOptions = pushOptions;
     this.repoConfig = repoConfig;
     this.revWalk = new RevWalk(reader);
     this.commit = revWalk.parseCommit(commitId);
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 1382be1..addeb59 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -27,10 +27,10 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.UserIdentity;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -213,7 +213,7 @@
   private void addSubmitRecordRequirements(SubmitRecord submitRecord, SubmitRecordAttribute sa) {
     if (submitRecord.requirements != null && !submitRecord.requirements.isEmpty()) {
       sa.requirements = new ArrayList<>();
-      for (SubmitRequirement req : submitRecord.requirements) {
+      for (LegacySubmitRequirement req : submitRecord.requirements) {
         SubmitRequirementAttribute re = new SubmitRequirementAttribute();
         re.fallbackText = req.fallbackText();
         re.type = req.type();
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java
index ab51518..bd784cf 100644
--- a/java/com/google/gerrit/server/events/EventGsonProvider.java
+++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -15,9 +15,8 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EntitiesAdapterFactory;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.change.ChangeKeyAdapter;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.inject.Provider;
@@ -26,10 +25,11 @@
   @Override
   public Gson get() {
     return new GsonBuilder()
+        .registerTypeAdapter(Event.class, new EventSerializer())
         .registerTypeAdapter(Event.class, new EventDeserializer())
         .registerTypeAdapter(Supplier.class, new SupplierSerializer())
         .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
-        .registerTypeAdapter(Change.Key.class, new ChangeKeyAdapter())
+        .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
         .registerTypeHierarchyAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
         .create();
   }
diff --git a/java/com/google/gerrit/server/events/EventSerializer.java b/java/com/google/gerrit/server/events/EventSerializer.java
new file mode 100644
index 0000000..7322ef3
--- /dev/null
+++ b/java/com/google/gerrit/server/events/EventSerializer.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.gerrit.common.UsedAt;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import java.lang.reflect.Type;
+
+@UsedAt(UsedAt.Project.PLUGIN_MULTI_SITE)
+class EventSerializer implements JsonSerializer<Event> {
+  @Override
+  public JsonElement serialize(Event src, Type typeOfSrc, JsonSerializationContext context) {
+    String type = src.getType();
+
+    Class<?> cls = EventTypes.getClass(type);
+    if (cls == null) {
+      throw new JsonParseException("Unknown event type: " + type);
+    }
+
+    return context.serialize(src, cls);
+  }
+}
diff --git a/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
new file mode 100644
index 0000000..f526935
--- /dev/null
+++ b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.experiments;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * An implementation of {@link ExperimentFeatures} that uses gerrit.config to evaluate the status of
+ * the feature.
+ */
+@Singleton
+public class ConfigExperimentFeatures implements ExperimentFeatures {
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ExperimentFeatures.class).to(ConfigExperimentFeatures.class);
+    }
+  }
+
+  private ImmutableSet<String> enabledExperimentFeatures;
+
+  @Inject
+  public ConfigExperimentFeatures(@GerritServerConfig Config gerritServerConfig) {
+    Set<String> enabledExperiments = new HashSet<>();
+    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "enabled"))
+        .forEach(enabledExperiments::add);
+    ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.forEach(enabledExperiments::add);
+    Arrays.stream(gerritServerConfig.getStringList("experiments", null, "disabled"))
+        .forEach(enabledExperiments::remove);
+    enabledExperimentFeatures = ImmutableSet.copyOf(enabledExperiments);
+  }
+
+  @Override
+  public boolean isFeatureEnabled(String featureFlag) {
+    return getEnabledExperimentFeatures().contains(featureFlag);
+  }
+
+  @Override
+  public ImmutableSet<String> getEnabledExperimentFeatures() {
+    return enabledExperimentFeatures;
+  }
+}
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
new file mode 100644
index 0000000..dc9148a
--- /dev/null
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.experiments;
+
+import com.google.common.collect.ImmutableSet;
+
+/**
+ * Features that can be enabled/disabled on Gerrit (e. g. experiments to research new behavior in
+ * the current release).
+ *
+ * <p>It may depend on the implementation if the result is decided on the per-request basis or not,
+ * so the outcomes should not be persisted in {@link com.google.inject.Singleton}.
+ */
+public interface ExperimentFeatures {
+
+  /**
+   * Given the name of the feature, returns if it is enabled on the Gerrit server.
+   *
+   * <p>Depending on the implementation, it can be more efficient than filtering the results of
+   * {@link ExperimentFeatures#getEnabledExperimentFeatures}.
+   *
+   * @param featureFlag the name of the feature to test.
+   * @return if the feature is enabled.
+   */
+  boolean isFeatureEnabled(String featureFlag);
+
+  /**
+   * Returns the names of the features that are enabled on Gerrit instance (either by default or via
+   * gerrit.config).
+   */
+  ImmutableSet<String> getEnabledExperimentFeatures();
+}
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
new file mode 100644
index 0000000..af49438
--- /dev/null
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.experiments;
+
+import com.google.common.collect.ImmutableSet;
+
+/** Constants for Gerrit {@link ExperimentFeatures} */
+public class ExperimentFeaturesConstants {
+
+  /** Features that are known experiments and can be referenced in the code. */
+  public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
+
+  /** Features, enabled by default in the current release. */
+  public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
+      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS);
+}
diff --git a/java/com/google/gerrit/server/git/DelegateRepository.java b/java/com/google/gerrit/server/git/DelegateRepository.java
index 5ebe358..815dabc 100644
--- a/java/com/google/gerrit/server/git/DelegateRepository.java
+++ b/java/com/google/gerrit/server/git/DelegateRepository.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gerrit.common.UsedAt;
 import java.io.File;
 import java.io.IOException;
@@ -30,6 +32,7 @@
 import org.eclipse.jgit.errors.RevisionSyntaxException;
 import org.eclipse.jgit.events.ListenerList;
 import org.eclipse.jgit.events.RepositoryEvent;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.BaseRepositoryBuilder;
 import org.eclipse.jgit.lib.ObjectDatabase;
@@ -62,7 +65,8 @@
     this.delegate = delegate;
   }
 
-  Repository delegate() {
+  /** Returns the wrapped {@link Repository} instance. */
+  public Repository delegate() {
     return delegate;
   }
 
@@ -395,4 +399,19 @@
       throws IOException {
     delegate.writeRebaseTodoFile(path, steps, append);
   }
+
+  /**
+   * Converts between ref storage formats.
+   *
+   * @param format the format to convert to, either "reftable" or "refdir"
+   * @param writeLogs whether to write reflogs
+   * @param backup whether to make a backup of the old data
+   * @throws IOException on I/O problems.
+   */
+  public void convertRefStorage(String format, boolean writeLogs, boolean backup)
+      throws IOException {
+    checkState(
+        delegate instanceof FileRepository, "Repository is not an instance of FileRepository!");
+    ((FileRepository) delegate).convertRefStorage(format, writeLogs, backup);
+  }
 }
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 9e0f2ee..5bbe5e2 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -29,7 +29,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.receive.ReceivePackRefCache;
@@ -43,7 +42,6 @@
 import java.util.Set;
 import java.util.TreeSet;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -231,7 +229,7 @@
 
   private boolean isGroupFromExistingPatchSet(RevCommit commit, String group) throws IOException {
     ObjectId id = parseGroup(commit, group);
-    return id != null && !receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES).isEmpty();
+    return id != null && !receivePackRefCache.patchSetIdsFromObjectId(id).isEmpty();
   }
 
   private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates)
@@ -273,17 +271,13 @@
   private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws IOException {
     ObjectId id = parseGroup(forCommit, group);
     if (id != null) {
-      Ref ref =
-          Iterables.getFirst(receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES), null);
-      if (ref != null) {
-        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-        if (psId != null) {
-          List<String> groups = groupLookup.lookup(psId);
-          // Group for existing patch set may be missing, e.g. if group has not
-          // been migrated yet.
-          if (groups != null && !groups.isEmpty()) {
-            return groups;
-          }
+      PatchSet.Id psId = Iterables.getFirst(receivePackRefCache.patchSetIdsFromObjectId(id), null);
+      if (psId != null) {
+        List<String> groups = groupLookup.lookup(psId);
+        // Group for existing patch set may be missing, e.g. if group has not
+        // been migrated yet.
+        if (groups != null && !groups.isEmpty()) {
+          return groups;
         }
       }
     }
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index d42ed25..10220d8 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -148,11 +148,11 @@
   @Override
   public Repository createRepository(Project.NameKey name)
       throws RepositoryNotFoundException, RepositoryCaseMismatchException, IOException {
-    Path path = getBasePath(name);
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
     }
 
+    Path path = getBasePath(name);
     File dir = FileKey.resolve(path.resolve(name.get()).toFile(), FS.DETECTED);
     if (dir != null) {
       // Already exists on disk, use the repository we found.
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index e0b101b..16a1ae6 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -488,6 +488,10 @@
   }
 
   public static String createConflictMessage(List<String> conflicts) {
+    if (conflicts.isEmpty()) {
+      return "";
+    }
+
     StringBuilder sb = new StringBuilder("merge conflict(s):");
     for (String c : conflicts) {
       sb.append('\n').append(c);
@@ -637,11 +641,11 @@
   }
 
   private static boolean isCodeReview(LabelId id) {
-    return "Code-Review".equalsIgnoreCase(id.get());
+    return LabelId.CODE_REVIEW.equalsIgnoreCase(id.get());
   }
 
   private static boolean isVerified(LabelId id) {
-    return "Verified".equalsIgnoreCase(id.get());
+    return LabelId.VERIFIED.equalsIgnoreCase(id.get());
   }
 
   private Iterable<PatchSetApproval> safeGetApprovals(ChangeNotes notes, PatchSet.Id psId) {
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 40e2730..78cb013 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -39,6 +39,7 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Constants;
@@ -189,8 +190,13 @@
                   @Override
                   public void run() {
                     try {
+                      // The stickyApprovalDiff is always empty here since this is not supported
+                      // for direct pushes.
                       MergedSender emailSender =
-                          mergedSenderFactory.create(ctx.getProject(), psId.changeId());
+                          mergedSenderFactory.create(
+                              ctx.getProject(),
+                              psId.changeId(),
+                              /* stickyApprovalDiff= */ Optional.empty());
                       emailSender.setFrom(ctx.getAccountId());
                       emailSender.setPatchSet(patchSet, info);
                       emailSender.setMessageId(
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index 43483bf..d6220a2 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -16,9 +16,9 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto;
@@ -37,6 +37,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectIdOwnerMap;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -44,6 +45,12 @@
 
 class TagSet {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final ImmutableSet<String> SKIPPABLE_REF_PREFIXES =
+      ImmutableSet.of(
+          RefNames.REFS_CHANGES,
+          RefNames.REFS_CACHE_AUTOMERGE,
+          RefNames.REFS_DRAFT_COMMENTS,
+          RefNames.REFS_STARRED_CHANGES);
 
   private final Project.NameKey projectName;
   private final Map<String, CachedRef> refs;
@@ -179,7 +186,9 @@
 
     try (TagWalk rw = new TagWalk(git)) {
       rw.setRetainBody(false);
-      for (Ref ref : git.getRefDatabase().getRefs()) {
+      for (Ref ref :
+          git.getRefDatabase()
+              .getRefsByPrefixWithExclusions(RefDatabase.ALL, SKIPPABLE_REF_PREFIXES)) {
         if (skip(ref)) {
           continue;
 
@@ -365,9 +374,7 @@
   static boolean skip(Ref ref) {
     return ref.isSymbolic()
         || ref.getObjectId() == null
-        || PatchSet.isChangeRef(ref.getName())
-        || RefNames.isNoteDbMetaRef(ref.getName())
-        || ref.getName().startsWith(RefNames.REFS_CACHE_AUTOMERGE);
+        || SKIPPABLE_REF_PREFIXES.stream().anyMatch(p -> ref.getName().startsWith(p));
   }
 
   private static boolean isTag(Ref ref) {
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
index 55261223..f680b7b 100644
--- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -19,6 +19,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
@@ -109,12 +110,13 @@
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
+      ImmutableListMultimap<String, String> pushOptions,
       boolean isMerged,
       NoteMap rejectCommits,
       @Nullable Change change)
       throws IOException {
     return validateCommit(
-        repository, objectReader, cmd, commit, isMerged, rejectCommits, change, false);
+        repository, objectReader, cmd, commit, pushOptions, isMerged, rejectCommits, change, false);
   }
 
   /**
@@ -134,6 +136,7 @@
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
+      ImmutableListMultimap<String, String> pushOptions,
       boolean isMerged,
       NoteMap rejectCommits,
       @Nullable Change change,
@@ -146,6 +149,7 @@
               cmd,
               project,
               branch.branch(),
+              pushOptions,
               new Config(repository.getConfig()),
               objectReader,
               commit,
diff --git a/java/com/google/gerrit/server/git/receive/PluginPushOption.java b/java/com/google/gerrit/server/git/receive/PluginPushOption.java
new file mode 100644
index 0000000..788df70
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/PluginPushOption.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+/**
+ * Push option that can be specified on push.
+ *
+ * <p>On push the option has to be specified as {@code -o <pluginName>~<name>=<value>}, or if a
+ * value is not required as {@code -o <pluginName>~<name>}.
+ */
+public interface PluginPushOption {
+  /** The name of the push option. */
+  public String getName();
+
+  /** The description of the push option. */
+  public String getDescription();
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 0209105..55ff260 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -17,10 +17,12 @@
 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.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 import static com.google.gerrit.entities.RefNames.isConfigRef;
+import static com.google.gerrit.entities.RefNames.isRefsUsersSelf;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
 import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
@@ -47,6 +49,7 @@
 import com.google.common.collect.BiMap;
 import com.google.common.collect.HashBiMap;
 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.ImmutableSetMultimap;
@@ -112,6 +115,7 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
@@ -144,6 +148,7 @@
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -179,7 +184,6 @@
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -210,6 +214,7 @@
 import java.util.concurrent.Future;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -325,6 +330,7 @@
   private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
   private final CreateRefControl createRefControl;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+  private final DynamicSet<PluginPushOption> pluginPushOptions;
   private final PluginSetContext<ReceivePackInitializer> initializers;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
@@ -344,12 +350,14 @@
   private final RequestScopePropagator requestScopePropagator;
   private final Sequences seq;
   private final SetHashtagsOp.Factory hashtagsFactory;
-  private final SubmissionListener superprojectUpdateSubmissionListener;
+  private final SetTopicOp.Factory setTopicFactory;
+  private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
   private final TagCache tagCache;
   private final ProjectConfig.Factory projectConfigFactory;
   private final SetPrivateOp.Factory setPrivateOpFactory;
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final DynamicItem<UrlFormatter> urlFormatter;
+  private final AutoMerger autoMerger;
 
   // Assisted injected fields.
   private final ProjectState projectState;
@@ -406,6 +414,7 @@
       CreateGroupPermissionSyncer createGroupPermissionSyncer,
       CreateRefControl createRefControl,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      DynamicSet<PluginPushOption> pluginPushOptions,
       PluginSetContext<ReceivePackInitializer> initializers,
       PluginSetContext<CommentValidator> commentValidators,
       MergedByPushOp.Factory mergedByPushOpFactory,
@@ -426,11 +435,14 @@
       RequestScopePropagator requestScopePropagator,
       Sequences seq,
       SetHashtagsOp.Factory hashtagsFactory,
-      @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
+      SetTopicOp.Factory setTopicFactory,
+      @SuperprojectUpdateOnSubmission
+          ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
       TagCache tagCache,
       SetPrivateOp.Factory setPrivateOpFactory,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
       DynamicItem<UrlFormatter> urlFormatter,
+      AutoMerger autoMerger,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
       @Assisted ReceivePack rp,
@@ -452,6 +464,7 @@
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
     this.editUtil = editUtil;
     this.hashtagsFactory = hashtagsFactory;
+    this.setTopicFactory = setTopicFactory;
     this.indexer = indexer;
     this.initializers = initializers;
     this.mergeOpProvider = mergeOpProvider;
@@ -462,6 +475,7 @@
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.permissionBackend = permissionBackend;
     this.pluginConfigEntries = pluginConfigEntries;
+    this.pluginPushOptions = pluginPushOptions;
     this.projectCache = projectCache;
     this.psUtil = psUtil;
     this.performanceLoggers = performanceLoggers;
@@ -474,12 +488,13 @@
     this.retryHelper = retryHelper;
     this.requestScopePropagator = requestScopePropagator;
     this.seq = seq;
-    this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
+    this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
     this.tagCache = tagCache;
     this.projectConfigFactory = projectConfigFactory;
     this.setPrivateOpFactory = setPrivateOpFactory;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.urlFormatter = urlFormatter;
+    this.autoMerger = autoMerger;
 
     // Assisted injected fields.
     this.projectState = projectState;
@@ -624,7 +639,7 @@
   private void processCommandsUnsafe(
       Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
     logger.atFine().log("Calling user: %s", user.getLoggableName());
-    logger.atFine().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+    logger.atFine().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
 
     if (!projectState.getProject().getState().permitsWrite()) {
       for (ReceiveCommand cmd : commands) {
@@ -742,7 +757,7 @@
         logger.atFine().log("Added %d additional ref updates", added);
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(false, superprojectUpdateSubmissionListener);
+            new SubmissionExecutor(false, superprojectUpdateSubmissionListeners);
 
         submissionExecutor.execute(ImmutableList.of(bu));
 
@@ -1076,7 +1091,7 @@
   private ReceiveCommand wrapReceiveCommand(ReceiveCommand cmd, Task progress) {
     String refname = cmd.getRefName();
 
-    if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
+    if (isRefsUsersSelf(cmd.getRefName(), projectState.isAllUsers())) {
       refname = RefNames.refsUsers(user.getAccountId());
       logger.atFine().log("Swapping out command for %s to %s", RefNames.REFS_USERS_SELF, refname);
     }
@@ -1783,8 +1798,13 @@
       String ref;
       magicBranch.cmdLineParser = optionParserFactory.create(magicBranch);
 
+      // Filter out plugin push options, as the parser would reject them as unknown.
+      ImmutableListMultimap<String, String> pushOptionsToParse =
+          pushOptions.entries().stream()
+              .filter(e -> !isPluginPushOption(e.getKey()))
+              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue()));
       try {
-        ref = magicBranch.parse(pushOptions);
+        ref = magicBranch.parse(pushOptionsToParse);
       } catch (CmdLineException e) {
         if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
           logger.atFine().log("Invalid branch syntax");
@@ -1803,6 +1823,20 @@
         StringWriter w = new StringWriter();
         w.write("\nHelp for refs/for/branch:\n\n");
         magicBranch.cmdLineParser.printUsage(w, null);
+
+        String pluginPushOptionsHelp =
+            StreamSupport.stream(pluginPushOptions.entries().spliterator(), /* parallel= */ false)
+                .map(
+                    e ->
+                        String.format(
+                            "-o %s~%s: %s",
+                            e.getPluginName(), e.get().getName(), e.get().getDescription()))
+                .sorted()
+                .collect(joining("\n"));
+        if (!pluginPushOptionsHelp.isEmpty()) {
+          w.write("\nPlugin push options:\n" + pluginPushOptionsHelp);
+        }
+
         addMessage(w.toString());
         reject(cmd, "see help");
         return;
@@ -1967,6 +2001,11 @@
     }
   }
 
+  private boolean isPluginPushOption(String pushOptionName) {
+    return StreamSupport.stream(pluginPushOptions.entries().spliterator(), /* parallel= */ false)
+        .anyMatch(e -> pushOptionName.equals(e.getPluginName() + "~" + e.get().getName()));
+  }
+
   // Validate that the new commits are connected with the target
   // 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
@@ -2145,15 +2184,15 @@
           receivePack.getRevWalk().parseBody(c);
           String name = c.name();
           groupCollector.visit(c);
-          Collection<Ref> existingRefs =
-              receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES);
+          Collection<PatchSet.Id> existingPatchSets =
+              receivePackRefCache.patchSetIdsFromObjectId(c);
 
           if (rejectImplicitMerges) {
             Collections.addAll(mergedParents, c.getParents());
             mergedParents.remove(c);
           }
 
-          boolean commitAlreadyTracked = !existingRefs.isEmpty();
+          boolean commitAlreadyTracked = !existingPatchSets.isEmpty();
           if (commitAlreadyTracked) {
             alreadyTracked++;
             // Corner cases where an existing commit might need a new group:
@@ -2169,9 +2208,7 @@
             //      A's group.
             // C) Commit is a PatchSet of a pre-existing change uploaded with a
             //    different target branch.
-            existingRefs.stream()
-                .map(r -> PatchSet.Id.fromRef(r.getName()))
-                .filter(Objects::nonNull)
+            existingPatchSets.stream()
                 .forEach(i -> updateGroups.add(new UpdateGroupsRequest(i, c)));
             if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
               continue;
@@ -2216,6 +2253,7 @@
                   receivePack.getRevWalk().getObjectReader(),
                   magicBranch.cmd,
                   c,
+                  ImmutableListMultimap.copyOf(pushOptions),
                   magicBranch.merged,
                   rejectCommits,
                   null);
@@ -2312,8 +2350,7 @@
 
             // In case the change look up from the index failed,
             // double check against the existing refs
-            if (foundInExistingRef(
-                receivePackRefCache.tipsFromObjectId(p.commit, RefNames.REFS_CHANGES))) {
+            if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) {
               if (pending.size() == 1) {
                 reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
                 return Collections.emptyList();
@@ -2361,11 +2398,10 @@
     }
   }
 
-  private boolean foundInExistingRef(Collection<Ref> existingRefs) {
-    try (TraceTimer traceTimer = newTimer("foundInExistingRef")) {
-      for (Ref ref : existingRefs) {
-        ChangeNotes notes =
-            notesFactory.create(project.getNameKey(), Change.Id.fromRef(ref.getName()));
+  private boolean foundInExistingPatchSets(Collection<PatchSet.Id> existingPatchSets) {
+    try (TraceTimer traceTimer = newTimer("foundInExistingPatchSet")) {
+      for (PatchSet.Id psId : existingPatchSets) {
+        ChangeNotes notes = notesFactory.create(project.getNameKey(), psId.changeId());
         Change change = notes.getChange();
         if (change.getDest().equals(magicBranch.dest)) {
           logger.atFine().log("Found change %s from existing refs.", change.getKey());
@@ -2598,7 +2634,7 @@
                     .setFireEvent(false));
           }
           if (!Strings.isNullOrEmpty(magicBranch.topic)) {
-            bu.addOp(changeId, new SetTopicOp(magicBranch.topic));
+            bu.addOp(changeId, setTopicFactory.create(magicBranch.topic));
           }
           bu.addOp(
               changeId,
@@ -2837,15 +2873,15 @@
           return false;
         }
 
-        List<Ref> existingChangesWithSameCommit =
-            receivePackRefCache.tipsFromObjectId(newCommit, RefNames.REFS_CHANGES);
-        if (!existingChangesWithSameCommit.isEmpty()) {
+        List<PatchSet.Id> existingPatchSetsWithSameCommit =
+            receivePackRefCache.patchSetIdsFromObjectId(newCommit);
+        if (!existingPatchSetsWithSameCommit.isEmpty()) {
           // TODO(hiesel, hanwen): Remove this check entirely when Gerrit requires change IDs
           //  without the option to turn that off.
           reject(
               inputCommand,
               "commit already exists (in the project): "
-                  + existingChangesWithSameCommit.get(0).getName());
+                  + existingPatchSetsWithSameCommit.get(0).toRefName());
           return false;
         }
 
@@ -3031,6 +3067,25 @@
         if (progress != null) {
           bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
         }
+        bu.addRepoOnlyOp(
+            new RepoOnlyOp() {
+              @Override
+              public void updateRepo(RepoContext ctx) throws Exception {
+                // Create auto merge ref if the new patch set is a merge commit. This is only
+                // required for new patch sets on existing changes as these do not go through
+                // PatchSetInserter. New changes pushed via git go through ChangeInserter and have
+                // their auto merge commits created there.
+                Optional<ReceiveCommand> autoMerge =
+                    autoMerger.createAutoMergeCommitIfNecessary(
+                        ctx.getRepoView(),
+                        ctx.getRevWalk(),
+                        ctx.getInserter(),
+                        ctx.getRevWalk().parseCommit(newCommitId));
+                if (autoMerge.isPresent()) {
+                  ctx.addRefUpdate(autoMerge.get());
+                }
+              }
+            });
       }
     }
 
@@ -3224,13 +3279,21 @@
                     "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
             return;
           }
-          if (!receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES).isEmpty()) {
+          if (!receivePackRefCache.patchSetIdsFromObjectId(c).isEmpty()) {
             continue;
           }
 
           BranchCommitValidator.Result validationResult =
               validator.validateCommit(
-                  repo, walk.getObjectReader(), cmd, c, false, rejectCommits, null, skipValidation);
+                  repo,
+                  walk.getObjectReader(),
+                  cmd,
+                  c,
+                  ImmutableListMultimap.copyOf(pushOptions),
+                  false,
+                  rejectCommits,
+                  null,
+                  skipValidation);
           messages.addAll(validationResult.messages());
           if (!validationResult.isValid()) {
             break;
@@ -3293,12 +3356,8 @@
 
                       // Check if change refs point to this commit. Usually there are 0-1 change
                       // refs pointing to this commit.
-                      for (Ref ref :
-                          receivePackRefCache.tipsFromObjectId(c.copy(), RefNames.REFS_CHANGES)) {
-                        if (!PatchSet.isChangeRef(ref.getName())) {
-                          continue;
-                        }
-                        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+                      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) {
@@ -3478,19 +3537,4 @@
     b.append(")\n");
     return b.toString();
   }
-
-  private static class SetTopicOp implements BatchUpdateOp {
-
-    private final String topic;
-
-    public SetTopicOp(String topic) {
-      this.topic = topic;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws ValidationException {
-      ctx.getUpdate(ctx.getChange().currentPatchSetId()).setTopic(topic);
-      return true;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
index 376ab2d..8568810 100644
--- a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
+++ b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
@@ -21,9 +21,11 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import java.io.IOException;
 import java.util.Map;
+import java.util.Objects;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -58,8 +60,8 @@
     return new WithAdvertisedRefs(allRefsSupplier);
   }
 
-  /** Returns a list of refs whose name starts with {@code prefix} that point to {@code id}. */
-  ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix) throws IOException;
+  /** Returns a list of {@link com.google.gerrit.entities.PatchSet.Id}s that point to {@code id}. */
+  ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException;
 
   /** Returns all refs whose name starts with {@code prefix}. */
   ImmutableList<Ref> byPrefix(String prefix) throws IOException;
@@ -76,10 +78,10 @@
     }
 
     @Override
-    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix)
-        throws IOException {
+    public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException {
       return delegate.getTipsWithSha1(id).stream()
-          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .map(r -> PatchSet.Id.fromRef(r.getName()))
+          .filter(Objects::nonNull)
           .collect(toImmutableList());
     }
 
@@ -113,10 +115,11 @@
     }
 
     @Override
-    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, String prefix) {
+    public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) {
       lazilyInitRefMaps();
       return refsByObjectId.get(id).stream()
-          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .map(r -> PatchSet.Id.fromRef(r.getName()))
+          .filter(Objects::nonNull)
           .collect(toImmutableList());
     }
 
diff --git a/java/com/google/gerrit/server/git/validators/AccountValidator.java b/java/com/google/gerrit/server/git/validators/AccountValidator.java
index 4755f5f..c2a5047 100644
--- a/java/com/google/gerrit/server/git/validators/AccountValidator.java
+++ b/java/com/google/gerrit/server/git/validators/AccountValidator.java
@@ -92,7 +92,7 @@
       return ImmutableList.of(String.format("account '%s' does not exist", accountId.get()));
     }
 
-    if (accountId.equals(self.get().getAccountId()) && !newAccount.get().isActive()) {
+    if (!newAccount.get().isActive() && accountId.equals(self.get().getAccountId())) {
       messages.add("cannot deactivate own account");
     }
 
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
index d507531..6e640f3 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
@@ -34,6 +35,8 @@
  * issues. Note that autogenerated change messages are not subject to validation.
  */
 public class CommentCumulativeSizeValidator implements CommentValidator {
+  public static final int DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT = 3 << 20;
+
   private final int maxCumulativeSize;
   private final ChangeNotes.Factory notesFactory;
 
@@ -41,7 +44,9 @@
   CommentCumulativeSizeValidator(
       @GerritServerConfig Config serverConfig, ChangeNotes.Factory notesFactory) {
     this.notesFactory = notesFactory;
-    maxCumulativeSize = serverConfig.getInt("change", "cumulativeCommentSizeLimit", 3 << 20);
+    maxCumulativeSize =
+        serverConfig.getInt(
+            "change", "cumulativeCommentSizeLimit", DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT);
   }
 
   @Override
@@ -55,7 +60,13 @@
                     notes.getRobotComments().values().stream())
                 .mapToInt(Comment::getApproximateSize)
                 .sum()
-            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
+            + notes.getChangeMessages().stream()
+                // Auto-generated change messages are not counted for the limit. This method is not
+                // called when those change messages are created, but we should also skip them when
+                // counting the size for unrelated messages.
+                .filter(cm -> !ChangeMessagesUtil.isAutogenerated(cm.getTag()))
+                .mapToInt(cm -> cm.getMessage().length())
+                .sum();
     int newCumulativeSize =
         comments.stream().mapToInt(CommentForValidation::getApproximateSize).sum();
     ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder();
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index f84b696..870667b 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -56,11 +56,11 @@
 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.ssh.HostKey;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import com.jcraft.jsch.HostKey;
 import java.io.IOException;
 import java.net.MalformedURLException;
 import java.net.URL;
@@ -234,7 +234,7 @@
     List<CommitValidationMessage> messages = new ArrayList<>();
     try {
       for (CommitValidationListener commitValidator : validators) {
-        try (TraceTimer traceTimer =
+        try (TraceTimer ignored =
             TraceContext.newTimer(
                 "Running CommitValidationListener",
                 Metadata.builder()
@@ -336,7 +336,7 @@
       } else if (idList.size() > 1) {
         throw new CommitValidationException(MULTIPLE_CHANGE_ID_MSG, messages);
       } else {
-        String v = idList.get(idList.size() - 1).trim();
+        String v = idList.get(0).trim();
         // Reject Change-Ids with wrong format and invalid placeholder ID from
         // Egit (I0000000000000000000000000000000000000000).
         if (!CHANGE_ID.matcher(v).matches() || v.matches("^I00*$")) {
@@ -458,10 +458,11 @@
 
     private long countChangedFiles(CommitReceivedEvent receiveEvent) throws IOException {
       try (Repository repository = repoManager.openRepository(receiveEvent.project.getNameKey());
-          RevWalk revWalk = new RevWalk(repository);
           DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        diffFormatter.setReader(revWalk.getObjectReader(), repository.getConfig());
-        diffFormatter.setDetectRenames(true);
+        diffFormatter.setRepository(repository);
+        // Do not detect renames; that would require reading file contents, which is slow for large
+        // files.
+        diffFormatter.setDetectRenames(false);
         // For merge commits, i.e. >1 parents, we use parent #0 by convention.
         List<DiffEntry> diffEntries =
             diffFormatter.scan(
@@ -562,7 +563,7 @@
 
   /** Execute commit validation plug-ins */
   public static class PluginCommitValidationListener implements CommitValidationListener {
-    private boolean skipValidation;
+    private final boolean skipValidation;
     private final PluginSetContext<CommitValidationListener> commitValidationListeners;
 
     public PluginCommitValidationListener(
@@ -604,7 +605,8 @@
 
     @Override
     public boolean shouldValidateAllCommits() {
-      return commitValidationListeners.stream().anyMatch(v -> v.shouldValidateAllCommits());
+      return commitValidationListeners.stream()
+          .anyMatch(CommitValidationListener::shouldValidateAllCommits);
     }
   }
 
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
index 50ec893..546614c 100644
--- a/java/com/google/gerrit/server/group/GroupResolver.java
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
diff --git a/java/com/google/gerrit/server/group/InternalGroupDescription.java b/java/com/google/gerrit/server/group/InternalGroupDescription.java
index 740557a..62ebcfe 100644
--- a/java/com/google/gerrit/server/group/InternalGroupDescription.java
+++ b/java/com/google/gerrit/server/group/InternalGroupDescription.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.InternalGroup;
 import java.sql.Timestamp;
 
 public class InternalGroupDescription implements GroupDescription.Internal {
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index b5ccb18..5d50d22 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ExternalUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
@@ -90,6 +92,7 @@
   private final SortedMap<String, GroupReference> namesToGroups;
   private final ImmutableSet<String> names;
   private final ImmutableMap<AccountGroup.UUID, GroupReference> uuids;
+  private final ImmutableSet<AccountGroup.UUID> externalUserMemberships;
 
   @Inject
   @VisibleForTesting
@@ -114,6 +117,10 @@
         ImmutableSet.copyOf(
             namesToGroups.values().stream().map(GroupReference::getName).collect(toSet()));
     uuids = u.build();
+    externalUserMemberships =
+        cfg.getBoolean("groups", null, "includeExternalUsersInRegisteredUsersGroup", true)
+            ? ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS)
+            : ImmutableSet.of(ANONYMOUS_USERS);
   }
 
   public GroupReference getGroup(AccountGroup.UUID uuid) {
@@ -182,8 +189,14 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
-    return new ListGroupMembership(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS));
+  public GroupMembership membershipsOf(CurrentUser user) {
+    if (user instanceof ExternalUser) {
+      return new ListGroupMembership(externalUserMemberships);
+    }
+    if (user instanceof IdentifiedUser) {
+      return new ListGroupMembership(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS));
+    }
+    return new ListGroupMembership(ImmutableSet.of(ANONYMOUS_USERS));
   }
 
   public static class NameCheck implements StartupCheck {
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index 60a1e3e..b2d1849b 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -24,14 +24,15 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -156,7 +157,7 @@
       Project.NameKey projectName,
       Repository repository,
       AccountGroup.UUID groupUuid,
-      ObjectId groupRefObjectId)
+      @Nullable ObjectId groupRefObjectId)
       throws IOException, ConfigInvalidException {
     GroupConfig groupConfig = new GroupConfig(groupUuid);
     if (groupRefObjectId == null) {
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java b/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java
index 5627154..77c284a 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.entities.InternalGroup;
 import java.util.Optional;
 import java.util.Set;
 import java.util.StringJoiner;
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
index 81f5c7e..be56344 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
@@ -16,7 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.entities.InternalGroup;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 90a5a1f..30e37cb 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -17,13 +17,14 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroupByIdAudit;
 import com.google.gerrit.entities.AccountGroupMemberAudit;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -80,6 +81,23 @@
   }
 
   /**
+   * Returns the {@code InternalGroup} for the specified UUID and groupRefObjectId
+   *
+   * @param groupUuid the UUID of the group
+   * @param groupRefObjectId the ref revision of this group
+   * @return the found {@code InternalGroup} if it exists, or else an empty {@code Optional}
+   * @throws IOException if the group couldn't be retrieved from NoteDb
+   * @throws ConfigInvalidException if the group couldn't be retrieved from NoteDb
+   */
+  public Optional<InternalGroup> getGroup(
+      AccountGroup.UUID groupUuid, @Nullable ObjectId groupRefObjectId)
+      throws IOException, ConfigInvalidException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+      return getGroupFromNoteDb(allUsersName, allUsersRepo, groupUuid, groupRefObjectId);
+    }
+  }
+
+  /**
    * Loads an internal group from NoteDb using the group UUID. This method returns the latest state
    * of the internal group.
    */
@@ -97,7 +115,7 @@
       AllUsersName allUsersName,
       Repository allUsersRepository,
       AccountGroup.UUID uuid,
-      ObjectId groupRefObjectId)
+      @Nullable ObjectId groupRefObjectId)
       throws IOException, ConfigInvalidException {
     GroupConfig groupConfig =
         GroupConfig.loadForGroup(allUsersName, allUsersRepository, uuid, groupRefObjectId);
diff --git a/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
index 8a6cd94..732712e 100644
--- a/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsConsistencyChecker.java
@@ -19,13 +19,13 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index 35f5dea..01ee811 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -25,11 +25,11 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 4ec5c36..02d55eb 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
@@ -39,7 +40,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.GroupAuditService;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java b/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
index 9e6539a..5c7408c 100644
--- a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
+++ b/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
@@ -101,7 +101,7 @@
    *
    * <p>If this {@code InternalGroupUpdate} is passed next to an {@link InternalGroupCreation}
    * during a group creation, this {@code Timestamp} is used for the NoteDb commits of the new
-   * group. Hence, the {@link com.google.gerrit.server.group.InternalGroup#getCreatedOn()
+   * group. Hence, the {@link com.google.gerrit.entities.InternalGroup#getCreatedOn()
    * InternalGroup#getCreatedOn()} field will match this {@code Timestamp}.
    *
    * <p><strong>Note: </strong>{@code Timestamp}s of NoteDb commits for groups are used for events
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index fd61dff..77bb777 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -7,7 +7,6 @@
     testonly = True,
     srcs = glob(["*.java"]),
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
index 8f20e92..8a1221e 100644
--- a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
+++ b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
@@ -23,7 +23,7 @@
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.entities.InternalGroup;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
 
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 35f18a2..601ac59 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.project.ProjectState;
@@ -122,7 +122,10 @@
   }
 
   @Override
-  public GroupMembership membershipsOf(IdentifiedUser user) {
+  public GroupMembership membershipsOf(CurrentUser user) {
+    if (!user.isIdentifiedUser()) {
+      return GroupMembership.EMPTY;
+    }
     return memberships.getOrDefault(user.getAccountId(), GroupMembership.EMPTY);
   }
 
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 9e3d91c..ee8dfc8 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.io.IOException;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -107,7 +107,7 @@
     if (user.isIdentifiedUser()) {
       return user.getAccountId().toString();
     }
-    if (user instanceof SingleGroupUser) {
+    if (user instanceof GroupBackedUser) {
       return "group:" + user.getEffectiveGroups().getKnownGroups().iterator().next().toString();
     }
     return user.toString();
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index d6b8ef9..f176c38 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -164,6 +164,8 @@
           "Failed %s/%s changes (%s%%); not marking new index as ready",
           nFailed, nTotal, Math.round(pctFailed));
       ok.set(false);
+    } else if (nFailed > 0) {
+      logger.atWarning().log("Failed %s/%s changes", nFailed, nTotal);
     }
     return Result.create(sw, ok.get(), nDone, nFailed);
   }
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index ef538cb..ce748d1 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -47,17 +47,16 @@
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 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.SubmitRequirement;
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.proto.Protos;
@@ -66,9 +65,7 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.notedb.RobotCommentNotes;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -152,6 +149,12 @@
   public static final FieldDef<ChangeData, Timestamp> UPDATED =
       timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn));
 
+  /** When this change was merged, time since January 1, 1970. */
+  public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
+      timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
+          .stored()
+          .build(cd -> cd.getMergedOn().orElse(null));
+
   /** List of full file paths modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> PATH =
       // Named for backwards compatibility.
@@ -812,7 +815,7 @@
               });
 
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
-      SubmitRuleOptions.builder().allowClosed(true).build();
+      SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
 
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
       SubmitRuleOptions.builder().build();
@@ -856,12 +859,12 @@
       }
       if (rec.requirements != null) {
         this.requirements = new ArrayList<>(rec.requirements.size());
-        for (SubmitRequirement requirement : rec.requirements) {
+        for (LegacySubmitRequirement requirement : rec.requirements) {
           StoredRequirement sr = new StoredRequirement();
           sr.type = requirement.type();
           sr.fallbackText = requirement.fallbackText();
           // For backwards compatibility, write an empty map to the index.
-          // This is required, because the SubmitRequirement AutoValue can't
+          // This is required, because the LegacySubmitRequirement AutoValue can't
           // handle null in the old code.
           // TODO(hiesel): Remove once we have rolled out the new code
           //  and waited long enough to not need to roll back.
@@ -888,8 +891,8 @@
       if (requirements != null) {
         rec.requirements = new ArrayList<>(requirements.size());
         for (StoredRequirement req : requirements) {
-          SubmitRequirement sr =
-              SubmitRequirement.builder()
+          LegacySubmitRequirement sr =
+              LegacySubmitRequirement.builder()
                   .setType(req.type)
                   .setFallbackText(req.fallbackText)
                   .build();
@@ -914,11 +917,6 @@
   public static void parseSubmitRecords(
       Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
     List<SubmitRecord> records = parseSubmitRecords(values);
-    if (records.isEmpty()) {
-      // Assume no values means the field is not in the index;
-      // SubmitRuleEvaluator ensures the list is non-empty.
-      return;
-    }
     out.setSubmitRecords(opts, records);
   }
 
@@ -976,27 +974,9 @@
           .buildRepeatable(
               cd -> {
                 List<byte[]> result = new ArrayList<>();
-                Project.NameKey project = cd.change().getProject();
-
-                cd.editRefs()
-                    .values()
-                    .forEach(r -> result.add(RefState.of(r).toByteArray(project)));
-                cd.starRefs()
-                    .values()
-                    .forEach(r -> result.add(RefState.of(r.ref()).toByteArray(allUsers(cd))));
-
-                ChangeNotes notes = cd.notes();
-                result.add(
-                    RefState.create(notes.getRefName(), notes.getMetaId()).toByteArray(project));
-                notes.getRobotComments(); // Force loading robot comments.
-                RobotCommentNotes robotNotes = notes.getRobotCommentNotes();
-                result.add(
-                    RefState.create(robotNotes.getRefName(), robotNotes.getMetaId())
-                        .toByteArray(project));
-                cd.draftRefs()
-                    .values()
-                    .forEach(r -> result.add(RefState.of(r).toByteArray(allUsers(cd))));
-
+                cd.getRefStates()
+                    .entries()
+                    .forEach(e -> result.add(e.getValue().toByteArray(e.getKey())));
                 return result;
               });
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 928f21c..969b071 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -130,9 +130,14 @@
           .build();
 
   /** Added new fields {@link ChangeField#MERGE} */
+  @Deprecated
   static final Schema<ChangeData> V60 =
       new Schema.Builder<ChangeData>().add(V59).add(ChangeField.MERGE).build();
 
+  /** Added new field {@link ChangeField#MERGED_ON} */
+  static final Schema<ChangeData> V61 =
+      new Schema.Builder<ChangeData>().add(V60).add(ChangeField.MERGED_ON).build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 7e50104..ad5cc2b 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -93,7 +93,7 @@
       return StalenessCheckResult.stale("Document %s missing from index", id);
     }
     ChangeData cd = result.get();
-    return check(repoManager, id, parseStates(cd), parsePatterns(cd));
+    return check(repoManager, id, cd.getRefStates(), parsePatterns(cd));
   }
 
   /**
@@ -127,10 +127,6 @@
     return StalenessCheckResult.notStale();
   }
 
-  private SetMultimap<Project.NameKey, RefState> parseStates(ChangeData cd) {
-    return RefState.parseStates(cd.getRefStates());
-  }
-
   private ListMultimap<Project.NameKey, RefStatePattern> parsePatterns(ChangeData cd) {
     return parsePatterns(cd.getRefStatePatterns());
   }
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index fcf0d6d..075a4ce 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -24,9 +24,9 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsNoteDbConsistencyChecker;
 import com.google.gerrit.server.index.IndexExecutor;
@@ -36,7 +36,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Optional;
+import java.util.Map;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -92,6 +92,8 @@
     AtomicInteger done = new AtomicInteger();
     AtomicInteger failed = new AtomicInteger();
     Stopwatch sw = Stopwatch.createStarted();
+    groupCache.evict(uuids);
+    Map<AccountGroup.UUID, InternalGroup> reindexedGroups = groupCache.get(uuids);
     for (AccountGroup.UUID uuid : uuids) {
       String desc = "group " + uuid;
       ListenableFuture<?> future =
@@ -99,12 +101,12 @@
               () -> {
                 try {
                   groupCache.evict(uuid);
-                  Optional<InternalGroup> internalGroup = groupCache.get(uuid);
-                  if (internalGroup.isPresent()) {
+                  InternalGroup internalGroup = reindexedGroups.get(uuid);
+                  if (internalGroup != null) {
                     if (isFirstInsertForEntry.equals(isFirstInsertForEntry.YES)) {
-                      index.insert(internalGroup.get());
+                      index.insert(internalGroup);
                     } else {
-                      index.replace(internalGroup.get());
+                      index.replace(internalGroup);
                     }
                   } else {
                     index.delete(uuid);
diff --git a/java/com/google/gerrit/server/index/group/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
index a3d913d..df90c0d 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -25,10 +25,10 @@
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.SchemaUtil;
-import com.google.gerrit.server.group.InternalGroup;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
 
diff --git a/java/com/google/gerrit/server/index/group/GroupIndex.java b/java/com/google/gerrit/server/index/group/GroupIndex.java
index be569df..28c0384 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndex.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndex.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.query.group.GroupPredicates;
 
 /**
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexCollection.java b/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
index 6c36a97..e1941ab 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
@@ -16,8 +16,8 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.IndexCollection;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Singleton;
 
 /** Collection of active group indices. See {@link IndexCollection} for details on collections. */
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java b/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
index a9cd856..7b7d2e2 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.IndexDefinition;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 
 /** Bundle of service classes that make up the group index. */
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java b/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
index a7b9497..86050bf 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
@@ -16,11 +16,11 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.IndexRewriter;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
index e9897e8..51e679f 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -18,11 +18,11 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.StalenessCheckResult;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index 40e0d8e..c4d8952 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -16,9 +16,9 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
-import com.google.gerrit.server.group.InternalGroup;
 
 /** Definition of group index versions (schemata). See {@link SchemaDefinitions}. */
 public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
diff --git a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
index 319b834..90070b6 100644
--- a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
@@ -22,7 +23,6 @@
 import com.google.gerrit.index.query.IndexedQuery;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.group.InternalGroup;
 import java.util.HashSet;
 import java.util.Set;
 
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index ac4df7b..35594e9 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -42,11 +42,12 @@
   private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> forceLogging = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> performanceLogging = new ThreadLocal<>();
+  private static final ThreadLocal<Boolean> aclLogging = new ThreadLocal<>();
 
   /**
-   * When copying the logging context to a new thread we need to ensure that the performance log
-   * records that are added in the new thread are added to the same {@link
-   * MutablePerformanceLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+   * When copying the logging context to a new thread we need to ensure that the mutable log records
+   * (performance logs and ACL logs) that are added in the new thread are added to the same multable
+   * log records instance (see {@link LoggingContextAwareRunnable} and {@link
    * LoggingContextAwareCallable}). This is important since performance log records are processed
    * only at the end of the request and performance log records that are created in another thread
    * should not get lost.
@@ -54,6 +55,8 @@
   private static final ThreadLocal<MutablePerformanceLogRecords> performanceLogRecords =
       new ThreadLocal<>();
 
+  private static final ThreadLocal<MutableAclLogRecords> aclLogRecords = new ThreadLocal<>();
+
   private LoggingContext() {}
 
   /** This method is expected to be called via reflection (and might otherwise be unused). */
@@ -67,7 +70,9 @@
     }
 
     return new LoggingContextAwareRunnable(
-        runnable, getInstance().getMutablePerformanceLogRecords());
+        runnable,
+        getInstance().getMutablePerformanceLogRecords(),
+        getInstance().getMutableAclRecords());
   }
 
   public static <T> Callable<T> copy(Callable<T> callable) {
@@ -76,11 +81,18 @@
     }
 
     return new LoggingContextAwareCallable<>(
-        callable, getInstance().getMutablePerformanceLogRecords());
+        callable,
+        getInstance().getMutablePerformanceLogRecords(),
+        getInstance().getMutableAclRecords());
   }
 
   public boolean isEmpty() {
-    return tags.get() == null && forceLogging.get() == null && performanceLogging.get() == null;
+    return tags.get() == null
+        && forceLogging.get() == null
+        && performanceLogging.get() == null
+        && (performanceLogRecords.get() == null || performanceLogRecords.get().isEmtpy())
+        && aclLogging.get() == null
+        && (aclLogRecords.get() == null || aclLogRecords.get().isEmpty());
   }
 
   public void clear() {
@@ -88,6 +100,8 @@
     forceLogging.remove();
     performanceLogging.remove();
     performanceLogRecords.remove();
+    aclLogging.remove();
+    aclLogRecords.remove();
   }
 
   @Override
@@ -167,16 +181,13 @@
    * requests).
    *
    * @param enable whether performance logging should be enabled.
-   * @return whether performance logging was be enabled before invoking this method (old value).
    */
-  boolean performanceLogging(boolean enable) {
-    Boolean oldValue = performanceLogging.get();
+  void performanceLogging(boolean enable) {
     if (enable) {
       performanceLogging.set(true);
     } else {
       performanceLogging.remove();
     }
-    return oldValue != null ? oldValue : false;
   }
 
   /**
@@ -247,6 +258,97 @@
     return records;
   }
 
+  public boolean isAclLogging() {
+    Boolean isAclLogging = aclLogging.get();
+    return isAclLogging != null ? isAclLogging : false;
+  }
+
+  /**
+   * Enables ACL logging.
+   *
+   * <p>It's important to enable ACL logging only in a context that ensures to consume the captured
+   * ACL log records. Otherwise captured ACL log records might leak into other requests that are
+   * executed by the same thread (if a thread pool is used to process requests).
+   *
+   * @param enable whether ACL logging should be enabled.
+   * @return whether ACL logging was be enabled before invoking this method (old value).
+   */
+  boolean aclLogging(boolean enable) {
+    Boolean oldValue = aclLogging.get();
+    if (enable) {
+      aclLogging.set(true);
+    } else {
+      aclLogging.remove();
+    }
+    return oldValue != null ? oldValue : false;
+  }
+
+  /**
+   * Adds an ACL log record.
+   *
+   * @param aclLogRecord ACL log record
+   */
+  public void addAclLogRecord(String aclLogRecord) {
+    if (!isAclLogging()) {
+      return;
+    }
+
+    getMutableAclRecords().add(aclLogRecord);
+  }
+
+  ImmutableList<String> getAclLogRecords() {
+    MutableAclLogRecords records = aclLogRecords.get();
+    if (records != null) {
+      return records.list();
+    }
+    return ImmutableList.of();
+  }
+
+  /**
+   * Set the ACL log records in this logging context. Existing log records are overwritten.
+   *
+   * <p>This method makes a defensive copy of the passed in list.
+   *
+   * @param newAclLogRecords ACL log records that should be set
+   */
+  void setAclLogRecords(List<String> newAclLogRecords) {
+    if (newAclLogRecords.isEmpty()) {
+      aclLogRecords.remove();
+      return;
+    }
+
+    getMutableAclRecords().set(newAclLogRecords);
+  }
+
+  /**
+   * Sets a {@link MutableAclLogRecords} instance for storing ACL log records.
+   *
+   * <p><strong>Attention:</strong> The passed in {@link MutableAclLogRecords} instance is directly
+   * stored in the logging context.
+   *
+   * <p>This method is intended to be only used when the logging context is copied to a new thread
+   * to ensure that the ACL log records that are added in the new thread are added to the same
+   * {@link MutableAclLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+   * LoggingContextAwareCallable}). This is important since ACL log records are processed only at
+   * the end of the request and ACL log records that are created in another thread should not get
+   * lost.
+   *
+   * @param mutableAclLogRecords the {@link MutableAclLogRecords} instance in which ACL log records
+   *     should be stored
+   */
+  void setMutableAclLogRecords(MutableAclLogRecords mutableAclLogRecords) {
+    aclLogRecords.set(requireNonNull(mutableAclLogRecords));
+  }
+
+  private MutableAclLogRecords getMutableAclRecords() {
+    MutableAclLogRecords records = aclLogRecords.get();
+    if (records == null) {
+      records = new MutableAclLogRecords();
+      aclLogRecords.set(records);
+    }
+    return records;
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
@@ -254,6 +356,8 @@
         .add("forceLogging", forceLogging.get())
         .add("performanceLogging", performanceLogging.get())
         .add("performanceLogRecords", performanceLogRecords.get())
+        .add("aclLogging", aclLogging.get())
+        .add("aclLogRecords", aclLogRecords.get())
         .toString();
   }
 }
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
index 1adee1b..ab5db02 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -40,6 +40,8 @@
   private final boolean forceLogging;
   private final boolean performanceLogging;
   private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+  private final boolean aclLogging;
+  private final MutableAclLogRecords mutableAclLogRecords;
 
   /**
    * Creates a LoggingContextAwareCallable that wraps the given {@link Callable}.
@@ -47,15 +49,21 @@
    * @param callable Callable that should be wrapped.
    * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
    *     performance log records that are created from the runnable are added
+   * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+   *     that are created from the runnable are added
    */
   LoggingContextAwareCallable(
-      Callable<T> callable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+      Callable<T> callable,
+      MutablePerformanceLogRecords mutablePerformanceLogRecords,
+      MutableAclLogRecords mutableAclLogRecords) {
     this.callable = callable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
     this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
     this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+    this.aclLogging = LoggingContext.getInstance().isAclLogging();
+    this.mutableAclLogRecords = mutableAclLogRecords;
   }
 
   @Override
@@ -76,6 +84,8 @@
     loggingCtx.forceLogging(forceLogging);
     loggingCtx.performanceLogging(performanceLogging);
     loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+    loggingCtx.aclLogging(aclLogging);
+    loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
     try {
       return callable.call();
     } finally {
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
index d0559cc..3c4c563 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -58,6 +58,8 @@
   private final boolean forceLogging;
   private final boolean performanceLogging;
   private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+  private final boolean aclLogging;
+  private final MutableAclLogRecords mutableAclLogRecords;
 
   /**
    * Creates a LoggingContextAwareRunnable that wraps the given {@link Runnable}.
@@ -65,15 +67,21 @@
    * @param runnable Runnable that should be wrapped.
    * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
    *     performance log records that are created from the runnable are added
+   * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+   *     that are created from the runnable are added
    */
   LoggingContextAwareRunnable(
-      Runnable runnable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+      Runnable runnable,
+      MutablePerformanceLogRecords mutablePerformanceLogRecords,
+      MutableAclLogRecords mutableAclLogRecords) {
     this.runnable = runnable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
     this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
     this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+    this.aclLogging = LoggingContext.getInstance().isAclLogging();
+    this.mutableAclLogRecords = mutableAclLogRecords;
   }
 
   public Runnable unwrap() {
@@ -99,6 +107,8 @@
     loggingCtx.forceLogging(forceLogging);
     loggingCtx.performanceLogging(performanceLogging);
     loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+    loggingCtx.aclLogging(aclLogging);
+    loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
     try {
       runnable.run();
     } finally {
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 1a4a335..71ee01f 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -103,6 +103,9 @@
   public abstract Optional<Integer> indexVersion();
 
   /** The name of the implementation method. */
+  public abstract Optional<String> memoryPoolName();
+
+  /** The name of the implementation method. */
   public abstract Optional<String> methodName();
 
   /** One or more resources */
@@ -309,6 +312,8 @@
 
     public abstract Builder indexVersion(int indexVersion);
 
+    public abstract Builder memoryPoolName(@Nullable String memoryPoolName);
+
     public abstract Builder methodName(@Nullable String methodName);
 
     public abstract Builder multiple(boolean multiple);
diff --git a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
new file mode 100644
index 0000000..a692d2b
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
@@ -0,0 +1,56 @@
+// 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.logging;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thread-safe store for ACL log records.
+ *
+ * <p>This class is intended to keep track of user ACL records in {@link LoggingContext}. It needs
+ * to be thread-safe because it gets shared between threads when the logging context is copied to
+ * another thread (see {@link LoggingContextAwareRunnable} and {@link LoggingContextAwareCallable}.
+ * In this case the logging contexts of both threads share the same instance of this class. This is
+ * important since ACL log records are processed only at the end of a request and user ACL records
+ * that are created in another thread should not get lost.
+ */
+public class MutableAclLogRecords {
+  private final ArrayList<String> aclLogRecords = new ArrayList<>();
+
+  public synchronized void add(String record) {
+    aclLogRecords.add(record);
+  }
+
+  public synchronized void set(List<String> records) {
+    aclLogRecords.clear();
+    aclLogRecords.addAll(records);
+  }
+
+  public synchronized ImmutableList<String> list() {
+    return ImmutableList.copyOf(aclLogRecords);
+  }
+
+  public boolean isEmpty() {
+    return aclLogRecords.isEmpty();
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this).add("aclLogRecords", aclLogRecords).toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
index 4ee70d7..2965719 100644
--- a/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
+++ b/java/com/google/gerrit/server/logging/MutablePerformanceLogRecords.java
@@ -46,6 +46,10 @@
     return ImmutableList.copyOf(performanceLogRecords);
   }
 
+  public boolean isEmtpy() {
+    return performanceLogRecords.isEmpty();
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 21a4ce6..2fc19b5 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Stopwatch;
 import com.google.common.base.Strings;
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
@@ -222,9 +223,17 @@
   // Table<TAG_NAME, TAG_VALUE, REMOVE_ON_CLOSE>
   private final Table<String, String, Boolean> tags = HashBasedTable.create();
 
-  private boolean stopForceLoggingOnClose;
+  private final boolean oldAclLogging;
+  private final ImmutableList<String> oldAclLogRecords;
 
-  private TraceContext() {}
+  private boolean stopForceLoggingOnClose;
+  private boolean stopAclLoggingOnClose;
+
+  private TraceContext() {
+    // Just in case remember the old state and reset ACL log entries.
+    this.oldAclLogging = LoggingContext.getInstance().isAclLogging();
+    this.oldAclLogRecords = LoggingContext.getInstance().getAclLogRecords();
+  }
 
   public TraceContext addTag(RequestId.Type requestId, Object tagValue) {
     return addTag(requireNonNull(requestId, "request ID is required").name(), tagValue);
@@ -265,6 +274,23 @@
         .findFirst();
   }
 
+  public TraceContext enableAclLogging() {
+    if (stopAclLoggingOnClose) {
+      return this;
+    }
+
+    stopAclLoggingOnClose = !LoggingContext.getInstance().aclLogging(true);
+    return this;
+  }
+
+  public boolean isAclLoggingEnabled() {
+    return LoggingContext.getInstance().isAclLogging();
+  }
+
+  public ImmutableList<String> getAclLogRecords() {
+    return LoggingContext.getInstance().getAclLogRecords();
+  }
+
   @Override
   public void close() {
     for (Table.Cell<String, String, Boolean> cell : tags.cellSet()) {
@@ -275,5 +301,10 @@
     if (stopForceLoggingOnClose) {
       LoggingContext.getInstance().forceLogging(false);
     }
+
+    if (stopAclLoggingOnClose) {
+      LoggingContext.getInstance().aclLogging(oldAclLogging);
+      LoggingContext.getInstance().setAclLogRecords(oldAclLogRecords);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 7890d35..81ce101 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -91,12 +91,14 @@
   protected ProjectState projectState;
   protected Set<Account.Id> authors;
   protected boolean emailOnlyAuthors;
+  protected boolean emailOnlyAttentionSetIfEnabled;
 
   protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
     super(args, messageClass, changeData.change().getDest());
     this.changeData = changeData;
     this.change = changeData.change();
     this.emailOnlyAuthors = false;
+    this.emailOnlyAttentionSetIfEnabled = true;
     this.currentAttentionSet = getAttentionSet();
   }
 
@@ -409,7 +411,8 @@
   private void addRecipient(RecipientType rt, Account.Id to, boolean isWatcher) {
     if (!isWatcher) {
       Optional<AccountState> accountState = args.accountCache.get(to);
-      if (accountState.isPresent()
+      if (emailOnlyAttentionSetIfEnabled
+          && accountState.isPresent()
           && accountState.get().generalPreferences().getEmailStrategy()
               == EmailStrategy.ATTENTION_SET_ONLY
           && !currentAttentionSet.contains(to)) {
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index d5863a6..0de0dbe 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -61,7 +61,7 @@
     bccStarredBy();
     ccExistingReviewers();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    add(RecipientType.TO, reviewers);
+    reviewers.stream().forEach(r -> add(RecipientType.TO, r));
     addByEmail(RecipientType.TO, reviewersByEmail);
     removeUsersThatIgnoredTheChange();
   }
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
index 1b58057..aade30f 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
@@ -113,6 +113,11 @@
 
   private void addTemplate(SoyFileSet.Builder builder, String resourcePath, String name)
       throws ProvisionException {
+    if (!resourcePath.endsWith("/")) {
+      resourcePath += "/";
+    }
+    String logicalPath = resourcePath + name;
+
     // Load as a file in the mail templates directory if present.
     Path tmpl = site.mail_dir.resolve(name);
     if (Files.isRegularFile(tmpl)) {
@@ -125,14 +130,11 @@
         throw new ProvisionException(
             "Failed to read template file " + tmpl.toAbsolutePath().toString(), err);
       }
-      builder.add(content, tmpl.toAbsolutePath().toString());
+      builder.add(content, logicalPath);
       return;
     }
 
     // Otherwise load the template as a resource.
-    if (!resourcePath.endsWith("/")) {
-      resourcePath += "/";
-    }
-    builder.add(Resources.getResource(resourcePath + name));
+    builder.add(Resources.getResource(logicalPath), logicalPath);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index 928bdc3..ea76ab8 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -28,24 +28,34 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Optional;
 
 /** Send notice about a change successfully merged. */
 public class MergedSender extends ReplyToChangeSender {
   public interface Factory {
-    MergedSender create(Project.NameKey project, Change.Id changeId);
+    MergedSender create(
+        Project.NameKey project, Change.Id changeId, Optional<String> stickyApprovalDiff);
   }
 
   private final LabelTypes labelTypes;
+  private final Optional<String> stickyApprovalDiff;
 
   @Inject
   public MergedSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+      EmailArguments args,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id changeId,
+      @Assisted Optional<String> stickyApprovalDiff) {
     super(args, "merged", newChangeData(args, project, changeId));
     labelTypes = changeData.getLabelTypes();
+    this.stickyApprovalDiff = stickyApprovalDiff;
   }
 
   @Override
   protected void init() throws EmailException {
+    // We want to send the submit email even if the "send only when in attention set" is enabled.
+    emailOnlyAttentionSetIfEnabled = false;
+
     super.init();
 
     ccAllApprovals();
@@ -130,5 +140,8 @@
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContextEmailData.put("approvals", getApprovals());
+    if (stickyApprovalDiff.isPresent()) {
+      soyContextEmailData.put("stickyApprovalDiff", stickyApprovalDiff.get());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 0e97f7e..ee9a328 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -65,11 +65,11 @@
         break;
       case ALL:
       default:
-        add(RecipientType.CC, extraCC);
+        extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
         extraCCByEmail.stream().forEach(cc -> add(RecipientType.CC, cc));
         // $FALL-THROUGH$
       case OWNER_REVIEWERS:
-        add(RecipientType.TO, reviewers, true);
+        reviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
         addByEmail(RecipientType.TO, reviewersByEmail, true);
         break;
     }
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index a23c978..746a07a 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.EmailHeader.AddressList;
-import com.google.gerrit.entities.UserIdentity;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -57,7 +56,7 @@
 
 /** Sends an email to one or more interested parties. */
 public abstract class OutgoingEmail {
-  private static final String SOY_TEMPLATE_NAMESPACE = "com.google.gerrit.server.mail.template.";
+  private static final String SOY_TEMPLATE_NAMESPACE = "com.google.gerrit.server.mail.template";
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   protected String messageClass;
@@ -285,7 +284,7 @@
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
     for (RecipientType recipientType : notify.accounts().keySet()) {
-      add(recipientType, notify.accounts().get(recipientType));
+      notify.accounts().get(recipientType).stream().forEach(a -> add(recipientType, a));
     }
 
     setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
@@ -476,40 +475,18 @@
     return true;
   }
 
-  /** Schedule this message for delivery to the listed accounts. */
-  protected void add(RecipientType rt, Collection<Account.Id> list) {
-    add(rt, list, false);
-  }
-
-  /** Schedule this message for delivery to the listed accounts. */
-  protected void add(RecipientType rt, Collection<Account.Id> list, boolean override) {
-    for (final Account.Id id : list) {
-      add(rt, id, override);
-    }
-  }
-
   /** Schedule this message for delivery to the listed address. */
-  protected void addByEmail(RecipientType rt, Collection<Address> list) {
+  protected final void addByEmail(RecipientType rt, Collection<Address> list) {
     addByEmail(rt, list, false);
   }
 
   /** Schedule this message for delivery to the listed address. */
-  protected void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
+  protected final void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
     for (final Address id : list) {
       add(rt, id, override);
     }
   }
 
-  protected void add(RecipientType rt, UserIdentity who) {
-    add(rt, who, false);
-  }
-
-  protected void add(RecipientType rt, UserIdentity who, boolean override) {
-    if (who != null && who.getAccount() != null) {
-      add(rt, who.getAccount(), override);
-    }
-  }
-
   /** Schedule delivery of this message to the given account. */
   protected void add(RecipientType rt, Account.Id to) {
     add(rt, to, false);
@@ -536,11 +513,11 @@
   }
 
   /** Schedule delivery of this message to the given account. */
-  protected void add(RecipientType rt, Address addr) {
+  protected final void add(RecipientType rt, Address addr) {
     add(rt, addr, false);
   }
 
-  protected void add(RecipientType rt, Address addr, boolean override) {
+  protected final void add(RecipientType rt, Address addr, boolean override) {
     if (addr != null && addr.email() != null && addr.email().length() > 0) {
       if (!args.validator.isValid(addr.email())) {
         logger.atWarning().log("Not emailing %s (invalid email address)", addr.email());
@@ -611,10 +588,22 @@
 
   /** Configures a soy renderer for the given template name and rendering data map. */
   private SoySauce.Renderer configureRenderer(String templateName) {
-    return args.soySauce
-        .get()
-        .renderTemplate(SOY_TEMPLATE_NAMESPACE + templateName)
-        .setData(soyContext);
+    int baseNameIndex = templateName.indexOf("_");
+    // In case there are multiple templates in file (now only InboundEmailRejection and
+    // InboundEmailRejectionHtml).
+    String fileNamespace =
+        baseNameIndex == -1 ? templateName : templateName.substring(0, baseNameIndex);
+    String templateInFileNamespace =
+        String.join(".", SOY_TEMPLATE_NAMESPACE, fileNamespace, templateName);
+    String templateInCommonNamespace = String.join(".", SOY_TEMPLATE_NAMESPACE, templateName);
+    SoySauce soySauce = args.soySauce.get();
+    // For backwards compatibility with existing customizations and plugin templates with the
+    // old non-unique namespace.
+    String fullTemplateName =
+        soySauce.hasTemplate(templateInFileNamespace)
+            ? templateInFileNamespace
+            : templateInCommonNamespace;
+    return soySauce.renderTemplate(fullTemplateName).setData(soyContext);
   }
 
   protected void removeUser(Account user) {
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 0514337..173b121 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -150,7 +150,7 @@
       throws QueryParseException {
     logger.atFine().log("Checking watchers for notify config %s from project %s", nc, projectName);
     for (GroupReference groupRef : nc.getGroups()) {
-      CurrentUser user = new SingleGroupUser(groupRef.getUUID());
+      CurrentUser user = new GroupBackedUser(ImmutableSet.of(groupRef.getUUID()));
       if (filterMatch(user, nc.getFilter())) {
         deliverToMembers(matching.list(nc.getHeader()), groupRef.getUUID());
         logger.atFine().log("Added watchers for group %s", groupRef);
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 5caac37..9516b9f 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -64,8 +64,8 @@
     if (args.settings.sendNewPatchsetEmails) {
       if (notify.handling() == NotifyHandling.ALL
           || notify.handling() == NotifyHandling.OWNER_REVIEWERS) {
-        add(RecipientType.TO, reviewers);
-        add(RecipientType.CC, extraCC);
+        reviewers.stream().forEach(r -> add(RecipientType.TO, r));
+        extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
       }
       rcptToAuthors(RecipientType.CC);
     }
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index e81160a..74ecd68 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -124,6 +124,10 @@
     this.revision = metaSha1;
   }
 
+  protected AbstractChangeNotes(Args args, Change.Id changeId) {
+    this(args, changeId, null);
+  }
+
   public Change.Id getChangeId() {
     return changeId;
   }
@@ -175,7 +179,8 @@
    * <p>Implementations may override this method to provide auto-rebuilding behavior.
    *
    * @param repo open repository.
-   * @param id version SHA1 of the change notes to load
+   * @param id SHA1 of the entity to read from the repository. The SHA1 is not sanity checked and is
+   *     assumed to be valid. If null, lookup SHA1 from the /meta ref.
    * @return handle for reading the entity.
    * @throws NoSuchChangeException change does not exist.
    * @throws IOException a repo-level error occurred.
@@ -185,6 +190,7 @@
     if (id == null) {
       id = readRef(repo);
     }
+
     return new LoadHandle(repo, id);
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 28f25ec5..6500d92 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -64,6 +64,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -79,6 +80,9 @@
 import org.eclipse.jgit.lib.Repository;
 
 /** View of a single {@link Change} based on the log of its notes branch. */
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
 public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -189,8 +193,9 @@
     }
 
     /**
-     * Create change notes based on a {@link Change.Id}. This requires using the Change index and
-     * should only be used when {@link Project.NameKey} and the numeric change ID are not available.
+     * Create change notes based on a {@link com.google.gerrit.entities.Change.Id}. This 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.
      */
     public ChangeNotes createCheckedUsingIndexLookup(Change.Id changeId) {
       InternalChangeQuery query = queryProvider.get().noFields();
@@ -206,9 +211,9 @@
     }
 
     /**
-     * Create change notes based on a list of {@link Change.Id}s. This requires using the Change
-     * index and should only be used when {@link Project.NameKey} and the numeric change ID are not
-     * available.
+     * Create change notes based on a list of {@link com.google.gerrit.entities.Change.Id}s. This
+     * 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.
      */
     public List<ChangeNotes> createUsingIndexLookup(Collection<Change.Id> changeIds) {
       List<ChangeNotes> notes = new ArrayList<>();
@@ -456,6 +461,11 @@
     return state.attentionSet();
   }
 
+  /** Returns all updates for the attention set. */
+  public ImmutableList<AttentionSetUpdate> getAttentionSetUpdates() {
+    return state.allAttentionSetUpdates();
+  }
+
   /**
    * @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
    *     order of the set is the order in which they were assigned.
@@ -519,6 +529,11 @@
     return state.updateCount();
   }
 
+  /** @return {@link Optional} value of time when the change was merged. */
+  public Optional<Timestamp> getMergedOn() {
+    return Optional.ofNullable(state.mergedOn());
+  }
+
   public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(Account.Id author) {
     return getDraftComments(author, null);
   }
@@ -564,6 +579,7 @@
   }
 
   public RobotCommentNotes getRobotCommentNotes() {
+    loadRobotComments();
     return robotCommentNotes;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 7fde297..c554ca5 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -121,12 +121,18 @@
     // Single Timestamp overhead.
     private static final int T = O + 8;
 
+    /**
+     * {@inheritDoc}
+     *
+     * <p>Take all columns and all collection sizes into account, but use estimated average element
+     * sizes rather than iterating over collections. Numbers are largely hand-wavy based on
+     * http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
+     *
+     * <p>Should be kept up to date with {@link ChangeNotesState}. Please, keep weights listed in
+     * the same order as fields.
+     */
     @Override
     public int weigh(Key key, ChangeNotesState state) {
-      // Take all columns and all collection sizes into account, but use
-      // estimated average element sizes rather than iterating over collections.
-      // Numbers are largely hand-wavy based on
-      // http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
       return P
           + O
           + 20 // metaId
@@ -138,6 +144,7 @@
           + K // owner
           + P
           + str(state.columns().branch())
+          + P // status
           + P
           + patchSetId() // currentPatchSetId
           + P
@@ -148,9 +155,16 @@
           + str(state.columns().originalSubject())
           + P
           + str(state.columns().submissionId())
-          + P // status
+          + 1 // isPrivate
+          + 1 // workInProgress
+          + 1 // reviewStarted
+          + P
+          + K // revertOf
+          + P
+          + patchSetId() // cherryPickOf
           + P
           + set(state.hashtags(), str(10))
+          + str(state.serverId()) // serverId
           + P
           + list(state.patchSets(), patchSet())
           + P
@@ -168,15 +182,17 @@
           + P
           + list(state.assigneeUpdates(), 4 * O + K + K)
           + P
+          + set(state.attentionSet(), 4 * O + K + I + str(15))
+          + P
+          + list(state.allAttentionSetUpdates(), 4 * O + K + I + str(15))
+          + P
           + list(state.submitRecords(), P + list(2, str(4) + P + K) + P)
           + P
           + list(state.changeMessages(), changeMessage())
           + P
           + map(state.publishedComments().asMap(), comment())
-          + 1 // isPrivate
-          + 1 // workInProgress
-          + 1 // reviewStarted
-          + I; // updateCount
+          + I // updateCount
+          + T; // mergedOn
     }
 
     private static int str(String s) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 6f9f7e8..2eb69e6f 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -119,6 +119,8 @@
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   /** Holds only the most recent update per user. Older updates are discarded. */
   private final Map<Account.Id, AttentionSetUpdate> latestAttentionStatus;
+  /** Holds all updates to attention set. */
+  private final List<AttentionSetUpdate> allAttentionSetUpdates;
 
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
@@ -158,6 +160,7 @@
   // We only set the value once, based on the latest update (the actual value or Optional.empty() if
   // the latest record unsets the field).
   private Optional<PatchSet.Id> cherryPickOf;
+  private Timestamp mergedOn;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -179,6 +182,7 @@
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
     latestAttentionStatus = new HashMap<>();
+    allAttentionSetUpdates = new ArrayList<>();
     assigneeUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
@@ -250,6 +254,7 @@
         allPastReviewers,
         buildReviewerUpdates(),
         ImmutableSet.copyOf(latestAttentionStatus.values()),
+        allAttentionSetUpdates,
         assigneeUpdates,
         submitRecords,
         buildAllMessages(),
@@ -259,7 +264,8 @@
         firstNonNull(hasReviewStarted, true),
         revertOf,
         cherryPickOf != null ? cherryPickOf.orElse(null) : null,
-        updateCount);
+        updateCount,
+        mergedOn);
   }
 
   private Map<PatchSet.Id, PatchSet> buildPatchSets() throws ConfigInvalidException {
@@ -321,9 +327,9 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
-    Timestamp ts = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+    Timestamp commitTimestamp = getCommitTimestamp(commit);
 
-    createdOn = ts;
+    createdOn = commitTimestamp;
     parseTag(commit);
 
     if (branch == null) {
@@ -363,21 +369,20 @@
       originalSubject = currSubject;
     }
 
-    boolean hasChangeMessage = parseChangeMessage(psId, accountId, realAccountId, commit, ts);
+    boolean hasChangeMessage =
+        parseChangeMessage(psId, accountId, realAccountId, commit, commitTimestamp);
     if (topic == null) {
       topic = parseTopic(commit);
     }
 
     parseHashtags(commit);
     parseAttentionSetUpdates(commit);
-    parseAssigneeUpdates(ts, commit);
+    parseAssigneeUpdates(commitTimestamp, commit);
 
-    if (submissionId == null) {
-      submissionId = parseSubmissionId(commit);
-    }
+    parseSubmission(commit, commitTimestamp);
 
-    if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
-      lastUpdatedOn = ts;
+    if (lastUpdatedOn == null || commitTimestamp.after(lastUpdatedOn)) {
+      lastUpdatedOn = commitTimestamp;
     }
 
     if (deletedPatchSets.contains(psId)) {
@@ -391,16 +396,10 @@
 
     ObjectId currRev = parseRevision(commit);
     if (currRev != null) {
-      parsePatchSet(psId, currRev, accountId, ts);
+      parsePatchSet(psId, currRev, accountId, commitTimestamp);
     }
     parseCurrentPatchSet(psId, commit);
 
-    if (submitRecords.isEmpty()) {
-      // Only parse the most recent set of submit records; any older ones are
-      // still there, but not currently used.
-      parseSubmitRecords(commit.getFooterLineValues(FOOTER_SUBMITTED_WITH));
-    }
-
     if (status == null) {
       status = parseStatus(commit);
     }
@@ -408,15 +407,15 @@
     // Parse approvals after status to treat approvals in the same commit as
     // "Status: merged" as non-post-submit.
     for (String line : commit.getFooterLineValues(FOOTER_LABEL)) {
-      parseApproval(psId, accountId, realAccountId, ts, line);
+      parseApproval(psId, accountId, realAccountId, commitTimestamp, line);
     }
 
     for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
       for (String line : commit.getFooterLineValues(state.getFooterKey())) {
-        parseReviewer(ts, state, line);
+        parseReviewer(commitTimestamp, state, line);
       }
       for (String line : commit.getFooterLineValues(state.getByEmailFooterKey())) {
-        parseReviewerByEmail(ts, state, line);
+        parseReviewerByEmail(commitTimestamp, state, line);
       }
       // Don't update timestamp when a reviewer was added, matching RevewDb
       // behavior.
@@ -441,6 +440,24 @@
     }
   }
 
+  private void parseSubmission(ChangeNotesCommit commit, Timestamp commitTimestamp)
+      throws ConfigInvalidException {
+    // Only parse the most recent sumbit commit (there should be exactly one).
+    if (submissionId == null) {
+      submissionId = parseSubmissionId(commit);
+    }
+
+    if (submissionId != null && mergedOn == null) {
+      mergedOn = commitTimestamp;
+    }
+
+    if (submitRecords.isEmpty()) {
+      // Only parse the most recent set of submit records; any older ones are
+      // still there, but not currently used.
+      parseSubmitRecords(commit.getFooterLineValues(FOOTER_SUBMITTED_WITH));
+    }
+  }
+
   private String parseSubmissionId(ChangeNotesCommit commit) throws ConfigInvalidException {
     return parseOneFooter(commit, FOOTER_SUBMISSION_ID);
   }
@@ -595,6 +612,9 @@
       }
       // Processing is in reverse chronological order. Keep only the latest update.
       latestAttentionStatus.putIfAbsent(attentionStatus.get().account(), attentionStatus.get());
+
+      // Keep all updates as well.
+      allAttentionSetUpdates.add(attentionStatus.get());
     }
   }
 
@@ -884,6 +904,10 @@
         }
       } else {
         checkFooter(rec != null, FOOTER_SUBMITTED_WITH, line);
+        if (line.startsWith("Rule-Name")) {
+          // This is just added for forward compatibility. Ignore this field.
+          continue;
+        }
         SubmitRecord.Label label = new SubmitRecord.Label();
         if (rec.labels == null) {
           rec.labels = new ArrayList<>();
@@ -914,7 +938,7 @@
     if (a.getName().equals(c.getName()) && a.getEmailAddress().equals(c.getEmailAddress())) {
       return null;
     }
-    return parseIdent(commit.getAuthorIdent());
+    return parseIdent(a);
   }
 
   private void parseReviewer(Timestamp ts, ReviewerStateInternal state, String line)
@@ -1010,7 +1034,7 @@
    * @return {@link Optional} value of the parsed footer or {@code null} if the footer is missing in
    *     this commit.
    * @throws ConfigInvalidException if the footer value could not be parsed as a valid {@link
-   *     PatchSet.Id}.
+   *     com.google.gerrit.entities.PatchSet.Id}.
    */
   @Nullable
   private Optional<PatchSet.Id> parseCherryPickOf(ChangeNotesCommit commit)
@@ -1031,6 +1055,23 @@
     }
   }
 
+  /**
+   * Returns the {@link Timestamp} when the commit was applied.
+   *
+   * <p>The author's date only notes when the commit was originally made. Thus, use the commiter's
+   * date as it accounts for the rebase, cherry-pick, commit --amend and other commands that rewrite
+   * the history of the branch.
+   *
+   * <p>Don't use {@link org.eclipse.jgit.revwalk.RevCommit#getCommitTime} directly because it
+   * returns int and would overflow.
+   *
+   * @param commit the commit to return commit time.
+   * @return the timestamp when the commit was applied.
+   */
+  private Timestamp getCommitTimestamp(ChangeNotesCommit commit) {
+    return new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+  }
+
   private void pruneReviewers() {
     Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
         reviewers.cellSet().iterator();
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 76c4678..33bc039 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
-import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.AssigneeStatusUpdate;
@@ -65,8 +64,6 @@
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gson.Gson;
-import com.google.protobuf.ByteString;
-import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
@@ -81,9 +78,15 @@
  * <p>One instance is the output of a single {@link ChangeNotesParser}, and contains types required
  * to support public methods on {@link ChangeNotes}. It is intended to be cached in-process.
  *
+ * <p>When new fields are added to the {@link ChangeNotesState}, {@link
+ * ChangeNotesCache.Weigher#weigh} should be updated.
+ *
  * <p>Note that {@link ChangeNotes} contains more than just a single {@code ChangeNoteState}, such
  * as per-draft information, so that class is not cached directly.
  */
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
 @AutoValue
 public abstract class ChangeNotesState {
 
@@ -120,6 +123,7 @@
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
       Set<AttentionSetUpdate> attentionSetUpdates,
+      List<AttentionSetUpdate> allAttentionSetUpdates,
       List<AssigneeStatusUpdate> assigneeUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
@@ -129,7 +133,8 @@
       boolean reviewStarted,
       @Nullable Change.Id revertOf,
       @Nullable PatchSet.Id cherryPickOf,
-      int updateCount) {
+      int updateCount,
+      @Nullable Timestamp mergedOn) {
     requireNonNull(
         metaId,
         () ->
@@ -171,11 +176,13 @@
         .allPastReviewers(allPastReviewers)
         .reviewerUpdates(reviewerUpdates)
         .attentionSet(attentionSetUpdates)
+        .allAttentionSetUpdates(allAttentionSetUpdates)
         .assigneeUpdates(assigneeUpdates)
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
         .publishedComments(publishedComments)
         .updateCount(updateCount)
+        .mergedOn(mergedOn)
         .build();
   }
 
@@ -305,9 +312,12 @@
 
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
 
-  /** Returns the most recent update (i.e. current status status) per user. */
+  /** Returns the most recent update (i.e. current status) per user. */
   abstract ImmutableSet<AttentionSetUpdate> attentionSet();
 
+  /** Returns all attention set updates. */
+  abstract ImmutableList<AttentionSetUpdate> allAttentionSetUpdates();
+
   abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
 
   abstract ImmutableList<SubmitRecord> submitRecords();
@@ -318,6 +328,9 @@
 
   abstract int updateCount();
 
+  @Nullable
+  abstract Timestamp mergedOn();
+
   Change newChange(Project.NameKey project) {
     ChangeColumns c = requireNonNull(columns(), "columns are required");
     Change change =
@@ -386,6 +399,7 @@
           .allPastReviewers(ImmutableList.of())
           .reviewerUpdates(ImmutableList.of())
           .attentionSet(ImmutableSet.of())
+          .allAttentionSetUpdates(ImmutableList.of())
           .assigneeUpdates(ImmutableList.of())
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
@@ -421,6 +435,8 @@
 
     abstract Builder attentionSet(Set<AttentionSetUpdate> attentionSetUpdates);
 
+    abstract Builder allAttentionSetUpdates(List<AttentionSetUpdate> attentionSetUpdates);
+
     abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
 
     abstract Builder submitRecords(List<SubmitRecord> submitRecords);
@@ -431,9 +447,14 @@
 
     abstract Builder updateCount(int updateCount);
 
+    abstract Builder mergedOn(Timestamp mergedOn);
+
     abstract ChangeNotesState build();
   }
 
+  /**
+   * Convert ChangeNotesState (which is AutoValue based) to byte[] and back, using protocol buffers.
+   */
   enum Serializer implements CacheSerializer<ChangeNotesState> {
     INSTANCE;
 
@@ -461,13 +482,11 @@
       object.hashtags().forEach(b::addHashtag);
       object
           .patchSets()
-          .forEach(e -> b.addPatchSet(toByteString(e.getValue(), PatchSetProtoConverter.INSTANCE)));
+          .forEach(e -> b.addPatchSet(PatchSetProtoConverter.INSTANCE.toProto(e.getValue())));
       object
           .approvals()
           .forEach(
-              e ->
-                  b.addApproval(
-                      toByteString(e.getValue(), PatchSetApprovalProtoConverter.INSTANCE)));
+              e -> b.addApproval(PatchSetApprovalProtoConverter.INSTANCE.toProto(e.getValue())));
 
       object.reviewers().asTable().cellSet().forEach(c -> b.addReviewer(toReviewerSetEntry(c)));
       object
@@ -489,25 +508,26 @@
       object.allPastReviewers().forEach(a -> b.addPastReviewer(a.get()));
       object.reviewerUpdates().forEach(u -> b.addReviewerUpdate(toReviewerStatusUpdateProto(u)));
       object.attentionSet().forEach(u -> b.addAttentionSetUpdate(toAttentionSetUpdateProto(u)));
+      object
+          .allAttentionSetUpdates()
+          .forEach(u -> b.addAllAttentionSetUpdate(toAttentionSetUpdateProto(u)));
       object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
       object
           .submitRecords()
           .forEach(r -> b.addSubmitRecord(GSON.toJson(new StoredSubmitRecord(r))));
       object
           .changeMessages()
-          .forEach(m -> b.addChangeMessage(toByteString(m, ChangeMessageProtoConverter.INSTANCE)));
+          .forEach(m -> b.addChangeMessage(ChangeMessageProtoConverter.INSTANCE.toProto(m)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
       b.setUpdateCount(object.updateCount());
+      if (object.mergedOn() != null) {
+        b.setMergedOnMillis(object.mergedOn().getTime());
+        b.setHasMergedOn(true);
+      }
 
       return Protos.toByteArray(b.build());
     }
 
-    @VisibleForTesting
-    static <T> ByteString toByteString(T object, ProtoConverter<?, T> converter) {
-      MessageLite message = converter.toProto(object);
-      return Protos.toByteString(message);
-    }
-
     private static ChangeColumnsProto toChangeColumnsProto(ChangeColumns cols) {
       ChangeColumnsProto.Builder b =
           ChangeColumnsProto.newBuilder()
@@ -607,12 +627,12 @@
               .hashtags(proto.getHashtagList())
               .patchSets(
                   proto.getPatchSetList().stream()
-                      .map(bytes -> parseProtoFrom(PatchSetProtoConverter.INSTANCE, bytes))
+                      .map(msg -> PatchSetProtoConverter.INSTANCE.fromProto(msg))
                       .map(ps -> Maps.immutableEntry(ps.id(), ps))
                       .collect(toImmutableList()))
               .approvals(
                   proto.getApprovalList().stream()
-                      .map(bytes -> parseProtoFrom(PatchSetApprovalProtoConverter.INSTANCE, bytes))
+                      .map(msg -> PatchSetApprovalProtoConverter.INSTANCE.fromProto(msg))
                       .map(a -> Maps.immutableEntry(a.patchSetId(), a))
                       .collect(toImmutableList()))
               .reviewers(toReviewerSet(proto.getReviewerList()))
@@ -623,6 +643,8 @@
                   proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
               .reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
               .attentionSet(toAttentionSetUpdates(proto.getAttentionSetUpdateList()))
+              .allAttentionSetUpdates(
+                  toAllAttentionSetUpdates(proto.getAllAttentionSetUpdateList()))
               .assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
               .submitRecords(
                   proto.getSubmitRecordList().stream()
@@ -630,22 +652,17 @@
                       .collect(toImmutableList()))
               .changeMessages(
                   proto.getChangeMessageList().stream()
-                      .map(bytes -> parseProtoFrom(ChangeMessageProtoConverter.INSTANCE, bytes))
+                      .map(msg -> ChangeMessageProtoConverter.INSTANCE.fromProto(msg))
                       .collect(toImmutableList()))
               .publishedComments(
                   proto.getPublishedCommentList().stream()
                       .map(r -> GSON.fromJson(r, HumanComment.class))
                       .collect(toImmutableListMultimap(HumanComment::getCommitId, c -> c)))
-              .updateCount(proto.getUpdateCount());
+              .updateCount(proto.getUpdateCount())
+              .mergedOn(proto.getHasMergedOn() ? new Timestamp(proto.getMergedOnMillis()) : null);
       return b.build();
     }
 
-    private static <P extends MessageLite, T> T parseProtoFrom(
-        ProtoConverter<P, T> converter, ByteString byteString) {
-      P message = Protos.parseUnchecked(converter.getParser(), byteString);
-      return converter.fromProto(message);
-    }
-
     private static ChangeColumns toChangeColumns(Change.Id changeId, ChangeColumnsProto proto) {
       ChangeColumns.Builder b =
           ChangeColumns.builder()
@@ -735,6 +752,20 @@
       return b.build();
     }
 
+    private static ImmutableList<AttentionSetUpdate> toAllAttentionSetUpdates(
+        List<AttentionSetUpdateProto> protos) {
+      ImmutableList.Builder<AttentionSetUpdate> b = ImmutableList.builder();
+      for (AttentionSetUpdateProto proto : protos) {
+        b.add(
+            AttentionSetUpdate.createFromRead(
+                Instant.ofEpochMilli(proto.getTimestampMillis()),
+                Account.id(proto.getAccount()),
+                AttentionSetUpdate.Operation.valueOf(proto.getOperation()),
+                proto.getReason()));
+      }
+      return b.build();
+    }
+
     private static ImmutableList<AssigneeStatusUpdate> toAssigneeStatusUpdateList(
         List<AssigneeStatusUpdateProto> protos) {
       ImmutableList.Builder<AssigneeStatusUpdate> b = ImmutableList.builder();
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 52c551e..708212d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -65,6 +65,7 @@
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.ServiceUserClassifier;
@@ -87,6 +88,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -809,21 +811,25 @@
         getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER);
     Set<AttentionSetUpdate> updates = new HashSet<>();
     for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) {
-      // Only add new reviewers to the attention set.
-      if (reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
-          && !currentReviewers.contains(reviewer.getKey())) {
+      Account.Id reviewerId = reviewer.getKey();
+      ReviewerStateInternal reviewerState = reviewer.getValue();
+      // Only add new reviewers to the attention set. Also, don't add the owner because the owner
+      // can only be a "dummy" reviewer for legacy reasons.
+      if (reviewerState.equals(ReviewerStateInternal.REVIEWER)
+          && !currentReviewers.contains(reviewerId)
+          && !reviewerId.equals(getChange().getOwner())) {
         updates.add(
             AttentionSetUpdate.createForWrite(
-                reviewer.getKey(), AttentionSetUpdate.Operation.ADD, "Reviewer was added"));
+                reviewerId, AttentionSetUpdate.Operation.ADD, "Reviewer was added"));
       }
       boolean reviewerRemoved =
-          !reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
-              && currentReviewers.contains(reviewer.getKey());
-      boolean ccRemoved = reviewer.getValue().equals(ReviewerStateInternal.REMOVED);
+          !reviewerState.equals(ReviewerStateInternal.REVIEWER)
+              && currentReviewers.contains(reviewerId);
+      boolean ccRemoved = reviewerState.equals(ReviewerStateInternal.REMOVED);
       if (reviewerRemoved || ccRemoved) {
         updates.add(
             AttentionSetUpdate.createForWrite(
-                reviewer.getKey(), AttentionSetUpdate.Operation.REMOVE, "Reviewer/Cc was removed"));
+                reviewerId, AttentionSetUpdate.Operation.REMOVE, "Reviewer/Cc was removed"));
       }
     }
     addToPlannedAttentionSetUpdates(updates);
@@ -854,6 +860,22 @@
         AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
             .map(AttentionSetUpdate::account)
             .collect(Collectors.toSet());
+
+    // Current reviewers/ccs are the reviewers/ccs before the update + the new reviewers/ccs - the
+    // deleted reviewers/ccs.
+    Set<Account.Id> currentReviewers =
+        Stream.concat(
+                getNotes().getReviewers().all().stream(),
+                reviewers.entrySet().stream()
+                    .filter(r -> r.getValue().asReviewerState() != ReviewerState.REMOVED)
+                    .map(r -> r.getKey()))
+            .collect(Collectors.toSet());
+    currentReviewers.removeAll(
+        reviewers.entrySet().stream()
+            .filter(r -> r.getValue().asReviewerState() == ReviewerState.REMOVED)
+            .map(r -> r.getKey())
+            .collect(ImmutableSet.toImmutableSet()));
+
     for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
       if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
           && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
@@ -881,11 +903,27 @@
         continue;
       }
 
+      // Don't add accounts that are not active in the change to the attention set.
+      if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+          && !isActiveOnChange(currentReviewers, attentionSetUpdate.account())) {
+        continue;
+      }
+
       addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
     }
   }
 
   /**
+   * Returns whether {@code accountId} is active on a change based on the {@code currentReviewers}.
+   * Activity is defined as being a part of the reviewers, an uploader, or an owner of a change.
+   */
+  private boolean isActiveOnChange(Set<Account.Id> currentReviewers, Account.Id accountId) {
+    return currentReviewers.contains(accountId)
+        || getChange().getOwner().equals(accountId)
+        || getNotes().getCurrentPatchSet().uploader().equals(accountId);
+  }
+
+  /**
    * When set, default attention set rules are ignored (E.g, adding reviewers -> adds to attention
    * set, etc).
    */
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/server/notedb/MissingMetaObjectException.java
similarity index 60%
copy from java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
copy to java/com/google/gerrit/server/notedb/MissingMetaObjectException.java
index 08d6ce7..7f9de0d 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
+++ b/java/com/google/gerrit/server/notedb/MissingMetaObjectException.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 The Android Open Source Project
+// Copyright (C) 2021 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,12 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.change;
+package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.List;
+/** Separate exception type to throw if requested meta SHA1 is not available. */
+public class MissingMetaObjectException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
 
-public interface PluginDefinedAttributesFactory {
-  List<PluginDefinedInfo> create(ChangeData cd);
+  MissingMetaObjectException(String msg) {
+    super(msg);
+  }
 }
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 2e0214c..3f17a2e 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -16,19 +16,23 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.common.base.Throwables;
-import com.google.gerrit.common.Nullable;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryableAction.ActionType;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
@@ -37,13 +41,13 @@
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
  * Utility class for creating an auto-merge commit of a merge commit.
@@ -67,96 +71,130 @@
  * is that these refs should never be deleted.
  */
 public class AutoMerger {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static final String AUTO_MERGE_MSG_PREFIX = "Auto-merge of ";
+
   @UsedAt(UsedAt.Project.GOOGLE)
   public static boolean cacheAutomerge(Config cfg) {
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
   }
 
-  private final RetryHelper retryHelper;
+  private enum OperationType {
+    CACHE_LOAD,
+    IN_MEMORY_WRITE,
+    ON_DISK_WRITE
+  }
+
+  private final Counter1<OperationType> counter;
+  private final Timer1<OperationType> latency;
   private final PersonIdent gerritIdent;
   private final boolean save;
+  private final ThreeWayMergeStrategy configuredMergeStrategy;
 
   @Inject
   AutoMerger(
-      RetryHelper retryHelper,
+      MetricMaker metricMaker,
       @GerritServerConfig Config cfg,
       @GerritPersonIdent PersonIdent gerritIdent) {
-    this.retryHelper = retryHelper;
-    save = cacheAutomerge(cfg);
+    this.counter =
+        metricMaker.newCounter(
+            "git/auto-merge/num_operations",
+            new Description("AutoMerge computations").setRate().setUnit("auto merge computations"),
+            Field.ofEnum(OperationType.class, "type", Metadata.Builder::operationName).build());
+    this.latency =
+        metricMaker.newTimer(
+            "git/auto-merge/latency",
+            new Description("AutoMerge computation latency")
+                .setCumulative()
+                .setUnit("milliseconds"),
+            Field.ofEnum(OperationType.class, "type", Metadata.Builder::operationName).build());
+    this.save = cacheAutomerge(cfg);
     this.gerritIdent = gerritIdent;
+    this.configuredMergeStrategy = MergeUtil.getMergeStrategy(cfg);
   }
 
   /**
-   * Creates an auto-merge commit of the parents of the given merge commit.
+   * Reads or creates an auto-merge commit of the parents of the given merge commit.
    *
-   * <p>In case of an exception the creation of the auto-merge commit is retried a few times. E.g.
-   * this allows the operation to succeed if a Git update fails due to a temporary issue.
+   * <p>The result is read from Git or computed in-memory and not written back to Git. This method
+   * exists for backwards compatibility only. All new changes have their auto-merge commits written
+   * transactionally when the change or patch set is created.
    *
    * @return auto-merge commit. Headers of the returned RevCommit are parsed.
    */
-  public RevCommit merge(
+  public RevCommit lookupFromGitOrMergeInMemory(
       Repository repo,
       RevWalk rw,
-      ObjectInserter ins,
-      RevCommit merge,
-      ThreeWayMergeStrategy mergeStrategy)
-      throws IOException {
-    try {
-      return retryHelper
-          .action(
-              ActionType.GIT_UPDATE,
-              "createAutoMerge",
-              () -> createAutoMergeCommit(repo, rw, ins, merge, mergeStrategy))
-          .call();
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      Throwables.throwIfInstanceOf(e, IOException.class);
-      throw new IllegalStateException(e);
-    }
-  }
-
-  /**
-   * Creates an auto-merge commit of the parents of the given merge commit.
-   *
-   * @return auto-merge commit. Headers of the returned RevCommit are parsed.
-   */
-  private RevCommit createAutoMergeCommit(
-      Repository repo,
-      RevWalk rw,
-      ObjectInserter ins,
+      InMemoryInserter ins,
       RevCommit merge,
       ThreeWayMergeStrategy mergeStrategy)
       throws IOException {
     checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
+    Optional<RevCommit> existingCommit =
+        lookupCommit(repo, rw, RefNames.refsCacheAutomerge(merge.name()));
+    if (existingCommit.isPresent()) {
+      counter.increment(OperationType.CACHE_LOAD);
+      return existingCommit.get();
+    }
+    counter.increment(OperationType.IN_MEMORY_WRITE);
+    logger.atInfo().log("Computing in-memory AutoMerge for " + merge.name());
+    try (Timer1.Context ignored = latency.start(OperationType.IN_MEMORY_WRITE)) {
+      return rw.parseCommit(createAutoMergeCommit(repo.getConfig(), rw, ins, merge, mergeStrategy));
+    }
+  }
 
-    InMemoryInserter tmpIns = null;
-    if (ins instanceof InMemoryInserter) {
-      // Caller gave us an in-memory inserter, so ensure anything we write from
-      // this method is visible to them.
-      tmpIns = (InMemoryInserter) ins;
-    } else if (!save) {
-      // 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.
-      tmpIns = new InMemoryInserter(rw.getObjectReader());
+  /**
+   * Creates an auto merge commit for the provided commit in case it is a merge commit. To be used
+   * whenever Gerrit creates new patch sets.
+   *
+   * <p>Callers need to include the returned {@link ReceiveCommand} in their ref transaction.
+   *
+   * @return A {@link ReceiveCommand} wrapped in an {@link Optional} to be used in a {@link
+   *     org.eclipse.jgit.lib.BatchRefUpdate}. {@link Optional#empty()} in case we don't need an
+   *     auto merge commit.
+   */
+  public Optional<ReceiveCommand> createAutoMergeCommitIfNecessary(
+      RepoView repoView, RevWalk rw, ObjectInserter ins, RevCommit maybeMergeCommit)
+      throws IOException {
+    if (maybeMergeCommit.getParentCount() != 2 || !save) {
+      logger.atFine().log("AutoMerge not required");
+      return Optional.empty();
     }
 
+    ObjectId autoMerge;
+    try (Timer1.Context ignored = latency.start(OperationType.ON_DISK_WRITE)) {
+      autoMerge =
+          createAutoMergeCommit(
+              repoView.getConfig(), rw, ins, maybeMergeCommit, configuredMergeStrategy);
+    }
+    counter.increment(OperationType.ON_DISK_WRITE);
+    logger.atFine().log("Added %s AutoMerge ref update for commit", autoMerge.name());
+    return Optional.of(
+        new ReceiveCommand(
+            ObjectId.zeroId(), autoMerge, RefNames.refsCacheAutomerge(maybeMergeCommit.name())));
+  }
+
+  /**
+   * Creates an auto-merge commit of the parents of the given merge commit.
+   *
+   * @return auto-merge commit. Headers of the returned RevCommit are parsed.
+   */
+  private ObjectId createAutoMergeCommit(
+      Config repoConfig,
+      RevWalk rw,
+      ObjectInserter ins,
+      RevCommit merge,
+      ThreeWayMergeStrategy mergeStrategy)
+      throws IOException {
     rw.parseHeaders(merge);
-    String refName = RefNames.refsCacheAutomerge(merge.name());
-    Ref ref = repo.getRefDatabase().exactRef(refName);
-    if (ref != null && ref.getObjectId() != null) {
-      RevObject obj = rw.parseAny(ref.getObjectId());
-      if (obj instanceof RevCommit) {
-        return (RevCommit) obj;
-      }
-      return commit(repo, rw, tmpIns, ins, refName, obj, merge);
-    }
-
-    ResolveMerger m = (ResolveMerger) mergeStrategy.newMerger(repo, true);
+    ResolveMerger m = (ResolveMerger) mergeStrategy.newMerger(ins, repoConfig);
     DirCache dc = DirCache.newInCore();
     m.setDirCache(dc);
-    m.setObjectInserter(tmpIns == null ? new NonFlushingWrapper(ins) : tmpIns);
+    // 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());
 
@@ -176,18 +214,6 @@
               m.getMergeResults());
     }
 
-    return commit(repo, rw, tmpIns, ins, refName, treeId, merge);
-  }
-
-  private RevCommit commit(
-      Repository repo,
-      RevWalk rw,
-      @Nullable InMemoryInserter tmpIns,
-      ObjectInserter ins,
-      String refName,
-      ObjectId tree,
-      RevCommit merge)
-      throws IOException {
     rw.parseHeaders(merge);
     // For maximum stability, choose a single ident using the committer time of
     // the input commit, using the server name and timezone.
@@ -197,50 +223,34 @@
     CommitBuilder cb = new CommitBuilder();
     cb.setAuthor(ident);
     cb.setCommitter(ident);
-    cb.setTreeId(tree);
-    cb.setMessage("Auto-merge of " + merge.name() + '\n');
+    cb.setTreeId(treeId);
+    cb.setMessage(AUTO_MERGE_MSG_PREFIX + merge.name() + '\n');
     for (RevCommit p : merge.getParents()) {
       cb.addParentId(p);
     }
 
-    if (!save) {
-      checkArgument(tmpIns != null);
-      try (ObjectReader tmpReader = tmpIns.newReader();
+    if (ins instanceof InMemoryInserter) {
+      // When using an InMemoryInserter we need to read back the values from that inserter because
+      // they are not available.
+      try (ObjectReader tmpReader = ins.newReader();
           RevWalk tmpRw = new RevWalk(tmpReader)) {
-        return tmpRw.parseCommit(tmpIns.insert(cb));
+        return tmpRw.parseCommit(ins.insert(cb));
       }
     }
 
-    checkArgument(tmpIns == null);
-    checkArgument(!(ins instanceof InMemoryInserter));
-    ObjectId commitId = ins.insert(cb);
-    ins.flush();
+    return rw.parseCommit(ins.insert(cb));
+  }
 
-    RefUpdate ru = repo.updateRef(refName);
-    ru.setNewObjectId(commitId);
-    ru.disableRefLog();
-    switch (ru.forceUpdate()) {
-      case FAST_FORWARD:
-      case FORCED:
-      case NEW:
-      case NO_CHANGE:
-        return rw.parseCommit(commitId);
-      case LOCK_FAILURE:
-        throw new LockFailureException(
-            String.format("Failed to create auto-merge of %s", merge.name()), ru);
-      case IO_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      case RENAMED:
-      default:
-        throw new IOException(
-            String.format(
-                "Failed to create auto-merge of %s: Cannot write %s (%s)",
-                merge.name(), refName, ru.getResult()));
+  private Optional<RevCommit> lookupCommit(Repository repo, RevWalk rw, String refName)
+      throws IOException {
+    Ref ref = repo.getRefDatabase().exactRef(refName);
+    if (ref != null && ref.getObjectId() != null) {
+      RevObject obj = rw.parseAny(ref.getObjectId());
+      if (obj instanceof RevCommit) {
+        return Optional.of((RevCommit) obj);
+      }
     }
+    return Optional.empty();
   }
 
   private static class NonFlushingWrapper extends ObjectInserter.Filter {
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
new file mode 100644
index 0000000..7c06a62
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -0,0 +1,121 @@
+// 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.patch;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+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.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+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.merge.ThreeWayMergeStrategy;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** A utility class for computing the base commit / parent for a specific patchset commit. */
+class BaseCommitUtil {
+  private final AutoMerger autoMerger;
+  private final ThreeWayMergeStrategy mergeStrategy;
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  BaseCommitUtil(AutoMerger am, @GerritServerConfig Config cfg, GitRepositoryManager repoManager) {
+    this.autoMerger = am;
+    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
+    this.repoManager = repoManager;
+  }
+
+  RevObject getBaseCommit(Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
+      throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        InMemoryInserter ins = new InMemoryInserter(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.
+   *
+   * @param project a specific git repository.
+   * @param commitId 20 bytes commitId SHA-1 hash.
+   * @return an integer representing the number of parents of the designated commit.
+   */
+  int getNumParents(Project.NameKey project, ObjectId commitId) throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      RevCommit current = rw.parseCommit(commitId);
+      return current.getParentCount();
+    }
+  }
+
+  /**
+   * Returns the parent commit Object of the commit represented by the commitId parameter.
+   *
+   * @param repo a git repository.
+   * @param ins a git object inserter in the database.
+   * @param rw a {@link RevWalk} object of the repository.
+   * @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.
+   */
+  RevObject getParentCommit(
+      Repository repo,
+      InMemoryInserter ins,
+      RevWalk rw,
+      @Nullable Integer parentNum,
+      ObjectId commitId)
+      throws IOException {
+    RevCommit current = rw.parseCommit(commitId);
+    switch (current.getParentCount()) {
+      case 0:
+        return rw.parseAny(emptyTree(ins));
+      case 1:
+        return current.getParent(0);
+      default:
+        if (parentNum != null) {
+          RevCommit r = current.getParent(parentNum - 1);
+          rw.parseBody(r);
+          return r;
+        }
+        // Only support auto-merge for 2 parents, not octopus merges
+        if (current.getParentCount() == 2) {
+          return autoMerger.lookupFromGitOrMergeInMemory(repo, rw, ins, current, mergeStrategy);
+        }
+        return null;
+    }
+  }
+
+  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
+    ObjectId id = ins.insert(Constants.OBJ_TREE, new byte[] {});
+    ins.flush();
+    return id;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/ComparisonType.java b/java/com/google/gerrit/server/patch/ComparisonType.java
index 260c507..eca2658 100644
--- a/java/com/google/gerrit/server/patch/ComparisonType.java
+++ b/java/com/google/gerrit/server/patch/ComparisonType.java
@@ -16,34 +16,40 @@
 
 import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
-import static java.util.Objects.requireNonNull;
 
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.cache.proto.Cache.FileDiffOutputProto;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.util.Optional;
 
-public class ComparisonType {
+/** Relation between the old and new commits used in the diff. */
+@AutoValue
+public abstract class ComparisonType {
 
-  /** 1-based parent */
-  private final Integer parentNum;
+  /**
+   * 1-based parent. Available if the old commit is the parent of the new commit and old commit is
+   * not the auto-merge.
+   */
+  abstract Optional<Integer> parentNum();
 
-  private final boolean autoMerge;
+  abstract boolean autoMerge();
 
   public static ComparisonType againstOtherPatchSet() {
-    return new ComparisonType(null, false);
+    return new AutoValue_ComparisonType(Optional.empty(), false);
   }
 
   public static ComparisonType againstParent(int parentNum) {
-    return new ComparisonType(parentNum, false);
+    return new AutoValue_ComparisonType(Optional.of(parentNum), false);
   }
 
   public static ComparisonType againstAutoMerge() {
-    return new ComparisonType(null, true);
+    return new AutoValue_ComparisonType(Optional.empty(), true);
   }
 
-  private ComparisonType(Integer parentNum, boolean autoMerge) {
-    this.parentNum = parentNum;
-    this.autoMerge = autoMerge;
+  private static ComparisonType create(Optional<Integer> parent, boolean automerge) {
+    return new AutoValue_ComparisonType(parent, automerge);
   }
 
   public boolean isAgainstParentOrAutoMerge() {
@@ -51,27 +57,43 @@
   }
 
   public boolean isAgainstParent() {
-    return parentNum != null;
+    return parentNum().isPresent();
   }
 
   public boolean isAgainstAutoMerge() {
-    return autoMerge;
+    return autoMerge();
   }
 
-  public int getParentNum() {
-    requireNonNull(parentNum);
-    return parentNum;
+  public Optional<Integer> getParentNum() {
+    return parentNum();
   }
 
   void writeTo(OutputStream out) throws IOException {
-    writeVarInt32(out, parentNum != null ? parentNum : 0);
-    writeVarInt32(out, autoMerge ? 1 : 0);
+    writeVarInt32(out, isAgainstParent() ? parentNum().get() : 0);
+    writeVarInt32(out, autoMerge() ? 1 : 0);
   }
 
   static ComparisonType readFrom(InputStream in) throws IOException {
     int p = readVarInt32(in);
-    Integer parentNum = p > 0 ? p : null;
+    Optional<Integer> parentNum = p > 0 ? Optional.of(p) : Optional.empty();
     boolean autoMerge = readVarInt32(in) != 0;
-    return new ComparisonType(parentNum, autoMerge);
+    return create(parentNum, autoMerge);
+  }
+
+  public FileDiffOutputProto.ComparisonType toProto() {
+    FileDiffOutputProto.ComparisonType.Builder builder =
+        FileDiffOutputProto.ComparisonType.newBuilder().setAutoMerge(autoMerge());
+    if (parentNum().isPresent()) {
+      builder.setParentNum(parentNum().get());
+    }
+    return builder.build();
+  }
+
+  public static ComparisonType fromProto(FileDiffOutputProto.ComparisonType proto) {
+    Optional<Integer> parentNum = Optional.empty();
+    if (proto.hasField(FileDiffOutputProto.ComparisonType.getDescriptor().findFieldByNumber(1))) {
+      parentNum = Optional.of(proto.getParentNum());
+    }
+    return create(parentNum, proto.getAutoMerge());
   }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffExecutor.java b/java/com/google/gerrit/server/patch/DiffExecutor.java
index 072c2da..63d5c50 100644
--- a/java/com/google/gerrit/server/patch/DiffExecutor.java
+++ b/java/com/google/gerrit/server/patch/DiffExecutor.java
@@ -16,6 +16,7 @@
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
+import com.google.gerrit.server.patch.filediff.PatchListLoader;
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
 import java.util.concurrent.ExecutorService;
diff --git a/java/com/google/gerrit/server/patch/DiffMappings.java b/java/com/google/gerrit/server/patch/DiffMappings.java
index 921d66e..57132f8 100644
--- a/java/com/google/gerrit/server/patch/DiffMappings.java
+++ b/java/com/google/gerrit/server/patch/DiffMappings.java
@@ -15,12 +15,17 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.Range;
 import com.google.gerrit.server.patch.GitPositionTransformer.RangeMapping;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.gerrit.server.patch.filediff.FileEdits;
+import java.util.List;
 
 /** Mappings derived from diffs. */
 public class DiffMappings {
@@ -33,31 +38,47 @@
     return Mapping.create(fileMapping, rangeMappings);
   }
 
-  private static FileMapping toFileMapping(PatchListEntry patchListEntry) {
-    switch (patchListEntry.getChangeType()) {
+  public static Mapping toMapping(FileEdits fileEdits) {
+    FileMapping fileMapping = FileMapping.forFile(fileEdits.oldPath(), fileEdits.newPath());
+    ImmutableSet<RangeMapping> rangeMappings = toRangeMappings(fileEdits.edits());
+    return Mapping.create(fileMapping, rangeMappings);
+  }
+
+  private static FileMapping toFileMapping(PatchListEntry ple) {
+    return toFileMapping(ple.getChangeType(), ple.getOldName(), ple.getNewName());
+  }
+
+  private static FileMapping toFileMapping(
+      Patch.ChangeType changeType, String oldName, String newName) {
+    switch (changeType) {
       case ADDED:
-        return FileMapping.forAddedFile(patchListEntry.getNewName());
+        return FileMapping.forAddedFile(newName);
       case MODIFIED:
       case REWRITE:
-        return FileMapping.forModifiedFile(patchListEntry.getNewName());
+        return FileMapping.forModifiedFile(newName);
       case DELETED:
         // Name of deleted file is mentioned as newName.
-        return FileMapping.forDeletedFile(patchListEntry.getNewName());
+        return FileMapping.forDeletedFile(newName);
       case RENAMED:
       case COPIED:
-        return FileMapping.forRenamedFile(patchListEntry.getOldName(), patchListEntry.getNewName());
+        return FileMapping.forRenamedFile(oldName, newName);
       default:
-        throw new IllegalStateException("Unmapped diff type: " + patchListEntry.getChangeType());
+        throw new IllegalStateException("Unmapped diff type: " + changeType);
     }
   }
 
   private static ImmutableSet<RangeMapping> toRangeMappings(PatchListEntry patchListEntry) {
-    return patchListEntry.getEdits().stream()
+    return toRangeMappings(
+        patchListEntry.getEdits().stream().map(Edit::fromJGitEdit).collect(toList()));
+  }
+
+  private static ImmutableSet<RangeMapping> toRangeMappings(List<Edit> edits) {
+    return edits.stream()
         .map(
             edit ->
                 RangeMapping.create(
-                    Range.create(edit.getBeginA(), edit.getEndA()),
-                    Range.create(edit.getBeginB(), edit.getEndB())))
+                    Range.create(edit.beginA(), edit.endA()),
+                    Range.create(edit.beginB(), edit.endB())))
         .collect(toImmutableSet());
   }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
new file mode 100644
index 0000000..ea92a99
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
@@ -0,0 +1,38 @@
+//  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.patch;
+
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+
+/**
+ * Thrown by the diff caches - the {@link GitModifiedFilesCache} and the {@link ModifiedFilesCache},
+ * if the implementations failed to retrieve the modified files between the 2 commits.
+ */
+public class DiffNotAvailableException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public DiffNotAvailableException(Throwable cause) {
+    super(cause);
+  }
+
+  public DiffNotAvailableException(String message) {
+    super(message);
+  }
+
+  public DiffNotAvailableException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
new file mode 100644
index 0000000..93aefff
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -0,0 +1,121 @@
+//  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.patch;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * An interface for all file diff related operations. Clients should use this interface to request:
+ *
+ * <ul>
+ *   <li>The list of modified files between two commits.
+ *   <li>The list of modified files between a commit and its parent or the auto-merge.
+ *   <li>The detailed file diff for a single file path.
+ *   <li>The Intra-line diffs for a single file path (TODO:ghareeb).
+ * </ul>
+ */
+public interface DiffOperations {
+
+  /**
+   * Returns the list of added, deleted or modified files between a commit against its base. The
+   * {@link Patch#COMMIT_MSG} and {@link Patch#MERGE_LIST} (for merge commits) are also returned.
+   *
+   * <p>If parentNum is set, it is used as the old commit in the diff. Otherwise, if the {@code
+   * newCommit} has only one parent, it is used as base. If {@code newCommit} has two parents, the
+   * auto-merge commit is computed and used as base. The auto-merge for more than two parents is not
+   * supported.
+   *
+   * @param project a project name representing a git repository.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
+   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @return the list of modified files between the two commits.
+   * @throws DiffNotAvailableException if auto-merge is requested for a commit having more than two
+   *     parents, if the {@code newCommit} could not be parsed for extracting the base commit, or if
+   *     an internal error occurred in Git while evaluating the diff.
+   */
+  Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
+      Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
+      throws DiffNotAvailableException;
+
+  /**
+   * Returns the list of added, deleted or modified files between two commits (patchsets). The
+   * commit message and merge list (for merge commits) are also returned.
+   *
+   * @param project a project name representing a git repository.
+   * @param oldCommit 20 bytes SHA-1 of the old commit used in the diff.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @return the list of modified files between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff.
+   */
+  Map<String, FileDiffOutput> listModifiedFiles(
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      throws DiffNotAvailableException;
+
+  /**
+   * Returns the diff for a single file between a patchset commit against its parent or the
+   * auto-merge commit. For deleted files, the {@code fileName} parameter should contain the old
+   * name of the file. This method will return {@link FileDiffOutput#empty(String, ObjectId,
+   * ObjectId)} if the requested file identified by {@code fileName} has unchanged content or does
+   * not exist at both commits.
+   *
+   * @param project a project name representing a git repository.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
+   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @param fileName the file name for which the diff should be evaluated.
+   * @param whitespace preference controlling whitespace effect in diff computation.
+   * @return the diff for the single file between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff, or if an exception happened while parsing the base commit.
+   */
+  FileDiffOutput getModifiedFileAgainstParent(
+      Project.NameKey project,
+      ObjectId newCommit,
+      @Nullable Integer parentNum,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
+      throws DiffNotAvailableException;
+
+  /**
+   * Returns the diff for a single file between two patchset commits. For deleted files, the {@code
+   * fileName} parameter should contain the old name of the file. This method will return {@link
+   * FileDiffOutput#empty(String, ObjectId, ObjectId)} if the requested file identified by {@code
+   * fileName} has unchanged content or does not exist at both commits.
+   *
+   * @param project a project name representing a git repository.
+   * @param oldCommit 20 bytes SHA-1 of the old commit used in the diff.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param fileName the file name for which the diff should be evaluated.
+   * @param whitespace preference controlling whitespace effect in diff computation.
+   * @return the diff for the single file between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff.
+   */
+  FileDiffOutput getModifiedFile(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
+      throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
new file mode 100644
index 0000000..6217239
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -0,0 +1,411 @@
+//  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.patch;
+
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
+
+import com.google.auto.value.AutoValue;
+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.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Project;
+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.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+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.filediff.FileDiffCache;
+import com.google.gerrit.server.patch.filediff.FileDiffCacheImpl;
+import com.google.gerrit.server.patch.filediff.FileDiffCacheKey;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+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.inject.Inject;
+import com.google.inject.Module;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Provides different file diff operations. Uses the underlying Git/Gerrit caches to speed up the
+ * diff computation.
+ */
+public class DiffOperationsImpl implements DiffOperations {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final int RENAME_SCORE = 60;
+  private static final DiffAlgorithm DEFAULT_DIFF_ALGORITHM = DiffAlgorithm.HISTOGRAM;
+  private static final Whitespace DEFAULT_WHITESPACE = Whitespace.IGNORE_NONE;
+
+  private final ModifiedFilesCache modifiedFilesCache;
+  private final FileDiffCache fileDiffCache;
+  private final BaseCommitUtil baseCommitUtil;
+  private final long timeoutMillis;
+  private final ExecutorService diffExecutor;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(DiffOperations.class).to(DiffOperationsImpl.class);
+        install(GitModifiedFilesCacheImpl.module());
+        install(ModifiedFilesCacheImpl.module());
+        install(GitFileDiffCacheImpl.module());
+        install(FileDiffCacheImpl.module());
+      }
+    };
+  }
+
+  @Inject
+  public DiffOperationsImpl(
+      ModifiedFilesCache modifiedFilesCache,
+      FileDiffCache fileDiffCache,
+      BaseCommitUtil baseCommit,
+      @DiffExecutor ExecutorService executor,
+      @GerritServerConfig Config cfg) {
+    this.modifiedFilesCache = modifiedFilesCache;
+    this.fileDiffCache = fileDiffCache;
+    this.baseCommitUtil = baseCommit;
+    this.diffExecutor = executor;
+    this.timeoutMillis =
+        ConfigUtil.getTimeUnit(
+            cfg,
+            "cache",
+            "diff",
+            "timeout",
+            TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
+            TimeUnit.MILLISECONDS);
+  }
+
+  @Override
+  public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
+      Project.NameKey project, ObjectId newCommit, @Nullable Integer parent)
+      throws DiffNotAvailableException {
+    try {
+      DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
+      return listModifiedFilesWithTimeout(diffParams);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          "Failed to evaluate the parent/base commit for commit " + newCommit, e);
+    }
+  }
+
+  @Override
+  public Map<String, FileDiffOutput> listModifiedFiles(
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      throws DiffNotAvailableException {
+    DiffParameters params =
+        DiffParameters.builder()
+            .project(project)
+            .newCommit(newCommit)
+            .baseCommit(oldCommit)
+            .comparisonType(ComparisonType.againstOtherPatchSet())
+            .build();
+    return listModifiedFilesWithTimeout(params);
+  }
+
+  @Override
+  public FileDiffOutput getModifiedFileAgainstParent(
+      Project.NameKey project,
+      ObjectId newCommit,
+      @Nullable Integer parent,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
+      throws DiffNotAvailableException {
+    try {
+      DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
+      FileDiffCacheKey key =
+          createFileDiffCacheKey(project, diffParams.baseCommit(), newCommit, fileName, whitespace);
+      return getModifiedFileWithTimeout(key, diffParams);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          "Failed to evaluate the parent/base commit for commit " + newCommit, e);
+    }
+  }
+
+  @Override
+  public FileDiffOutput getModifiedFile(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      String fileName,
+      @Nullable DiffPreferencesInfo.Whitespace whitespace)
+      throws DiffNotAvailableException {
+    DiffParameters params = // used for logging only
+        DiffParameters.builder()
+            .project(project)
+            .baseCommit(oldCommit)
+            .newCommit(newCommit)
+            .comparisonType(ComparisonType.againstOtherPatchSet())
+            .build();
+    FileDiffCacheKey key =
+        createFileDiffCacheKey(project, oldCommit, newCommit, fileName, whitespace);
+    return getModifiedFileWithTimeout(key, params);
+  }
+
+  private Map<String, FileDiffOutput> listModifiedFilesWithTimeout(DiffParameters params)
+      throws DiffNotAvailableException {
+    Future<DiffResult> task =
+        diffExecutor.submit(
+            () -> {
+              ImmutableMap<String, FileDiffOutput> modifiedFiles = getModifiedFiles(params);
+              return DiffResult.create(null, modifiedFiles);
+            });
+    DiffResult diffResult = execDiffWithTimeout(task, params);
+    return diffResult.modifiedFiles();
+  }
+
+  private FileDiffOutput getModifiedFileWithTimeout(FileDiffCacheKey key, DiffParameters params)
+      throws DiffNotAvailableException {
+    Future<DiffResult> task =
+        diffExecutor.submit(
+            () -> {
+              Map<String, FileDiffOutput> diffList = getModifiedFilesForKeys(ImmutableList.of(key));
+              FileDiffOutput fileDiffOutput =
+                  diffList.containsKey(key.newFilePath())
+                      ? diffList.get(key.newFilePath())
+                      : FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
+              return DiffResult.create(fileDiffOutput, null);
+            });
+    DiffResult result = execDiffWithTimeout(task, params);
+    return result.fileDiff();
+  }
+
+  /** Executes a diff task by employing a timeout. */
+  private DiffResult execDiffWithTimeout(Future<DiffResult> task, DiffParameters params)
+      throws DiffNotAvailableException {
+    try {
+      return task.get(timeoutMillis, TimeUnit.MILLISECONDS);
+    } catch (InterruptedException | TimeoutException e) {
+      throw new DiffNotAvailableException(
+          String.format(
+              "Timeout reached while computing diff for project %s, old commit %s, new commit %s",
+              params.project(), params.baseCommit().name(), params.newCommit().name()),
+          e);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  private ImmutableMap<String, FileDiffOutput> getModifiedFiles(DiffParameters diffParams)
+      throws DiffNotAvailableException {
+    try {
+      Project.NameKey project = diffParams.project();
+      ObjectId newCommit = diffParams.newCommit();
+      ObjectId oldCommit = diffParams.baseCommit();
+      ComparisonType cmp = diffParams.comparisonType();
+
+      ImmutableList<ModifiedFile> modifiedFiles =
+          modifiedFilesCache.get(createModifiedFilesKey(project, oldCommit, newCommit));
+
+      List<FileDiffCacheKey> fileCacheKeys = new ArrayList<>();
+      fileCacheKeys.add(
+          createFileDiffCacheKey(
+              project, oldCommit, newCommit, COMMIT_MSG, /* whitespace= */ null));
+
+      if (cmp.isAgainstAutoMerge() || isMergeAgainstParent(cmp, project, newCommit)) {
+        fileCacheKeys.add(
+            createFileDiffCacheKey(
+                project, oldCommit, newCommit, MERGE_LIST, /*whitespace = */ null));
+      }
+
+      if (diffParams.skipFiles() == null) {
+        modifiedFiles.stream()
+            .map(
+                entity ->
+                    createFileDiffCacheKey(
+                        project,
+                        oldCommit,
+                        newCommit,
+                        entity.newPath().isPresent()
+                            ? entity.newPath().get()
+                            : entity.oldPath().get(),
+                        /* whitespace= */ null))
+            .forEach(fileCacheKeys::add);
+      }
+      return getModifiedFilesForKeys(fileCacheKeys);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  private ImmutableMap<String, FileDiffOutput> getModifiedFilesForKeys(List<FileDiffCacheKey> keys)
+      throws DiffNotAvailableException {
+    ImmutableMap.Builder<String, FileDiffOutput> files = ImmutableMap.builder();
+    ImmutableMap<FileDiffCacheKey, FileDiffOutput> fileDiffs = fileDiffCache.getAll(keys);
+
+    for (FileDiffOutput fileDiffOutput : fileDiffs.values()) {
+      if (fileDiffOutput.isEmpty() || allDueToRebase(fileDiffOutput)) {
+        continue;
+      }
+      if (fileDiffOutput.changeType() == ChangeType.DELETED) {
+        files.put(fileDiffOutput.oldPath().get(), fileDiffOutput);
+      } else {
+        files.put(fileDiffOutput.newPath().get(), fileDiffOutput);
+      }
+    }
+    return files.build();
+  }
+
+  private static boolean allDueToRebase(FileDiffOutput fileDiffOutput) {
+    return fileDiffOutput.allEditsDueToRebase()
+        && (!(fileDiffOutput.changeType() == ChangeType.RENAMED
+            || fileDiffOutput.changeType() == ChangeType.COPIED));
+  }
+
+  private boolean isMergeAgainstParent(ComparisonType cmp, Project.NameKey project, ObjectId commit)
+      throws IOException {
+    return (cmp.isAgainstParent() && baseCommitUtil.getNumParents(project, commit) > 1);
+  }
+
+  private static ModifiedFilesCacheKey createModifiedFilesKey(
+      Project.NameKey project, ObjectId aCommit, ObjectId bCommit) {
+    return ModifiedFilesCacheKey.builder()
+        .project(project)
+        .aCommit(aCommit)
+        .bCommit(bCommit)
+        .renameScore(RENAME_SCORE)
+        .build();
+  }
+
+  private static FileDiffCacheKey createFileDiffCacheKey(
+      Project.NameKey project,
+      ObjectId aCommit,
+      ObjectId bCommit,
+      String newPath,
+      @Nullable Whitespace whitespace) {
+    whitespace = whitespace == null ? DEFAULT_WHITESPACE : whitespace;
+    return FileDiffCacheKey.builder()
+        .project(project)
+        .oldCommit(aCommit)
+        .newCommit(bCommit)
+        .newFilePath(newPath)
+        .renameScore(RENAME_SCORE)
+        .diffAlgorithm(DEFAULT_DIFF_ALGORITHM)
+        .whitespace(whitespace)
+        .build();
+  }
+
+  /**
+   * All interface methods create their results using this class. This is used so that the timeout
+   * method {@link #execDiffWithTimeout(Future, DiffParameters)} could be reused by all interface
+   * methods.
+   */
+  @AutoValue
+  abstract static class DiffResult {
+    static DiffResult create(
+        @Nullable FileDiffOutput fileDiff,
+        @Nullable ImmutableMap<String, FileDiffOutput> modifiedFiles) {
+      return new AutoValue_DiffOperationsImpl_DiffResult(fileDiff, modifiedFiles);
+    }
+
+    @Nullable
+    abstract FileDiffOutput fileDiff();
+
+    @Nullable
+    abstract ImmutableMap<String, FileDiffOutput> modifiedFiles();
+  }
+
+  @AutoValue
+  abstract static class DiffParameters {
+    abstract Project.NameKey project();
+
+    abstract ObjectId newCommit();
+
+    abstract ObjectId baseCommit();
+
+    abstract ComparisonType comparisonType();
+
+    @Nullable
+    abstract Integer parent();
+
+    /** Compute the diff for {@value Patch#COMMIT_MSG} and {@link Patch#MERGE_LIST} only. */
+    @Nullable
+    abstract Boolean skipFiles();
+
+    static Builder builder() {
+      return new AutoValue_DiffOperationsImpl_DiffParameters.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+
+      abstract Builder project(Project.NameKey project);
+
+      abstract Builder newCommit(ObjectId newCommit);
+
+      abstract Builder baseCommit(ObjectId baseCommit);
+
+      abstract Builder parent(@Nullable Integer parent);
+
+      abstract Builder skipFiles(@Nullable Boolean skipFiles);
+
+      abstract Builder comparisonType(ComparisonType comparisonType);
+
+      public abstract DiffParameters build();
+    }
+  }
+
+  /** 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 {
+    DiffParameters.Builder result =
+        DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
+    if (parent != null) {
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
+      result.comparisonType(ComparisonType.againstParent(parent));
+      return result.build();
+    }
+    int numParents = baseCommitUtil.getNumParents(project, newCommit);
+    if (numParents == 1) {
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
+      result.comparisonType(ComparisonType.againstParent(1));
+      return result.build();
+    }
+    if (numParents > 2) {
+      logger.atFine().log(
+          "Diff against auto-merge for merge commits "
+              + "with more than two parents is not supported. Commit "
+              + newCommit
+              + " has "
+              + numParents
+              + " parents. Falling back to the diff against the first parent.");
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, 1).getId());
+      result.comparisonType(ComparisonType.againstParent(1));
+      result.skipFiles(true);
+    } else {
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, null));
+      result.comparisonType(ComparisonType.againstAutoMerge());
+    }
+    return result.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
new file mode 100644
index 0000000..1e88f9f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -0,0 +1,89 @@
+// 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.patch;
+
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A utility class used by the diff cache interfaces {@link GitModifiedFilesCache} and {@link
+ * ModifiedFilesCache}.
+ */
+public class DiffUtil {
+
+  /**
+   * Returns the Git tree object ID pointed to by the commitId parameter.
+   *
+   * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
+   * @param commitId 20 bytes commitId SHA-1 hash.
+   * @return Git tree object ID pointed to by the commitId.
+   */
+  public static ObjectId getTreeId(RevWalk rw, ObjectId commitId) throws IOException {
+    RevCommit current = rw.parseCommit(commitId);
+    return current.getTree().getId();
+  }
+
+  /**
+   * Returns the RevCommit object given the 20 bytes commitId SHA-1 hash.
+   *
+   * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
+   * @param commitId 20 bytes commitId SHA-1 hash
+   * @return The RevCommit representing the commit in Git
+   * @throws IOException a pack file or loose object could not be read while parsing the commits.
+   */
+  public static RevCommit getRevCommit(RevWalk rw, ObjectId commitId) throws IOException {
+    return rw.parseCommit(commitId);
+  }
+
+  /**
+   * Returns true if the commitA and commitB parameters are parent/child, if they have a common
+   * parent, or if any of them is a root or merge commit.
+   */
+  public static boolean areRelated(RevCommit commitA, RevCommit commitB) {
+    return commitA == null
+        || isRootOrMergeCommit(commitA)
+        || isRootOrMergeCommit(commitB)
+        || areParentAndChild(commitA, commitB)
+        || haveCommonParent(commitA, commitB);
+  }
+
+  public static int stringSize(String str) {
+    if (str != null) {
+      // each character in the string occupies 2 bytes. Ignoring the fixed overhead for the string
+      // (length, offset and hash code) since they are negligible and do not affect the comparison
+      // of 2 strings.
+      return str.length() * 2;
+    }
+    return 0;
+  }
+
+  private static boolean isRootOrMergeCommit(RevCommit commit) {
+    return commit.getParentCount() != 1;
+  }
+
+  private static boolean areParentAndChild(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.isEqual(commitA.getParent(0), commitB)
+        || ObjectId.isEqual(commitB.getParent(0), commitA);
+  }
+
+  private static boolean haveCommonParent(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.isEqual(commitA.getParent(0), commitB.getParent(0));
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/FilePathAdapter.java b/java/com/google/gerrit/server/patch/FilePathAdapter.java
new file mode 100644
index 0000000..ccd1466
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/FilePathAdapter.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.gerrit.entities.Patch.ChangeType;
+import java.util.Optional;
+
+/**
+ * Adapter for old/new paths of the new diff cache to the old diff cache representation. This is
+ * needed for backward compatibility with all old diff cache callers.
+ *
+ * <p>TODO(ghareeb): It's better to revisit this logic and update all diff cache callers to use the
+ * new diff cache output directly.
+ */
+public class FilePathAdapter {
+  private FilePathAdapter() {}
+
+  /**
+   * Converts the old file path of the new diff cache output to the old diff cache representation.
+   */
+  public static String getOldPath(Optional<String> oldName, ChangeType changeType) {
+    switch (changeType) {
+      case DELETED:
+      case ADDED:
+      case MODIFIED:
+      case REWRITE:
+        return null;
+      case COPIED:
+      case RENAMED:
+        return oldName.get();
+      default:
+        throw new IllegalArgumentException("Unsupported type " + changeType);
+    }
+  }
+
+  /**
+   * Converts the new file path of the new diff cache output to the old diff cache representation.
+   */
+  public static String getNewPath(
+      Optional<String> oldName, Optional<String> newName, ChangeType changeType) {
+    switch (changeType) {
+      case DELETED:
+        return oldName.get();
+      case ADDED:
+      case MODIFIED:
+      case REWRITE:
+      case COPIED:
+      case RENAMED:
+        return newName.get();
+      default:
+        throw new IllegalArgumentException("Unsupported type " + changeType);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/GitPositionTransformer.java b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
index d890bc2..f33d302 100644
--- a/java/com/google/gerrit/server/patch/GitPositionTransformer.java
+++ b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
@@ -330,6 +330,11 @@
       return new AutoValue_GitPositionTransformer_FileMapping(
           Optional.of(oldPath), Optional.of(newPath));
     }
+
+    /** Creates a {@link FileMapping} using the old and new paths. */
+    public static FileMapping forFile(Optional<String> oldPath, Optional<String> newPath) {
+      return new AutoValue_GitPositionTransformer_FileMapping(oldPath, newPath);
+    }
   }
 
   /**
@@ -514,6 +519,15 @@
     }
 
     /**
+     * Returns the original underlying entity.
+     *
+     * @return the original instance of {@code T}
+     */
+    public T getEntity() {
+      return entity;
+    }
+
+    /**
      * Returns an updated version of the entity to which the internally stored {@link Position} was
      * written back to.
      *
diff --git a/java/com/google/gerrit/server/patch/MagicFile.java b/java/com/google/gerrit/server/patch/MagicFile.java
index aa6b11f..e42dd8c 100644
--- a/java/com/google/gerrit/server/patch/MagicFile.java
+++ b/java/com/google/gerrit/server/patch/MagicFile.java
@@ -93,7 +93,7 @@
           }
         default:
           int uninterestingParent =
-              comparisonType.isAgainstParent() ? comparisonType.getParentNum() : 1;
+              comparisonType.isAgainstParent() ? comparisonType.getParentNum().get() : 1;
 
           b.append("Merge List:\n\n");
           for (RevCommit commit : MergeListBuilder.build(rw, c, uninterestingParent)) {
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index 28f61d3..cb95553 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -24,8 +24,10 @@
 import static org.eclipse.jgit.lib.ObjectIdSerializer.writeWithoutMarker;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -49,8 +51,42 @@
   static final Comparator<String> FILE_PATH_CMP =
       Comparator.comparing(Patch::isMagic).reversed().thenComparing(Comparator.naturalOrder());
 
+  /**
+   * We use the ChangeType comparator for a rare case when PatchList contains two entries for the
+   * same file, e.g. {ADDED, DELETED}. We return a single entry according to the following order.
+   * Check the following bug for an example case:
+   * https://bugs.chromium.org/p/gerrit/issues/detail?id=13914.
+   */
+  @VisibleForTesting
+  static class ChangeTypeCmp implements Comparator<ChangeType> {
+    static final List<ChangeType> order =
+        ImmutableList.of(
+            ChangeType.ADDED,
+            ChangeType.RENAMED,
+            ChangeType.MODIFIED,
+            ChangeType.COPIED,
+            ChangeType.REWRITE,
+            ChangeType.DELETED);
+
+    @Override
+    public int compare(ChangeType o1, ChangeType o2) {
+      int idx1 = priority(o1);
+      int idx2 = priority(o2);
+      return idx1 - idx2;
+    }
+
+    private int priority(ChangeType changeType) {
+      int idx = order.indexOf(changeType);
+      // Return least priority if the element is not in the order list.
+      return idx == -1 ? order.size() : idx;
+    }
+  }
+
+  @VisibleForTesting static final Comparator<ChangeType> CHANGE_TYPE_CMP = new ChangeTypeCmp();
+
   private static final Comparator<PatchListEntry> PATCH_CMP =
-      Comparator.comparing(PatchListEntry::getNewName, FILE_PATH_CMP);
+      Comparator.comparing(PatchListEntry::getNewName, FILE_PATH_CMP)
+          .thenComparing(PatchListEntry::getChangeType, CHANGE_TYPE_CMP);
 
   @Nullable private transient ObjectId oldId;
   private transient ObjectId newId;
@@ -121,12 +157,25 @@
 
   /** Find an entry by name, returning an empty entry if not present. */
   public PatchListEntry get(String fileName) {
-    final int index = search(fileName);
-    return 0 <= index ? patches[index] : PatchListEntry.empty(fileName);
+    int index = search(fileName);
+    if (index >= 0) {
+      return patches[index];
+    }
+    // If index is negative, it marks the insertion point of the object in the list.
+    // index = (-(insertion point) - 1).
+    // Since we use the ChangeType in the comparison, the object that we are using in the lookup
+    // (which has a ADDED ChangeType) may have a different ChangeType than the object in the list.
+    // For this reason, we look at the file name of the object at the insertion point and return it
+    // if it has the same name.
+    index = -1 * (index + 1);
+    if (index < patches.length && patches[index].getNewName().equals(fileName)) {
+      return patches[index];
+    }
+    return PatchListEntry.empty(fileName);
   }
 
   private int search(String fileName) {
-    PatchListEntry want = PatchListEntry.empty(fileName);
+    PatchListEntry want = PatchListEntry.empty(fileName, ChangeType.ADDED);
     return Arrays.binarySearch(patches, 0, patches.length, want, PATCH_CMP);
   }
 
diff --git a/java/com/google/gerrit/server/patch/PatchListCache.java b/java/com/google/gerrit/server/patch/PatchListCache.java
index 63cac0e..e60302a 100644
--- a/java/com/google/gerrit/server/patch/PatchListCache.java
+++ b/java/com/google/gerrit/server/patch/PatchListCache.java
@@ -21,10 +21,38 @@
 
 /** Provides a cached list of {@link PatchListEntry}. */
 public interface PatchListCache {
+  /**
+   * Returns the patch list - list of modified files - between two commits.
+   *
+   * @param key identifies the old / new commits.
+   * @param project name key identifying a specific git project (repository).
+   * @return patch list containing the modified files between two commits.
+   * @deprecated use {@link DiffOperations} instead.
+   */
+  @Deprecated
   PatchList get(PatchListKey key, Project.NameKey project) throws PatchListNotAvailableException;
 
+  /**
+   * Returns the patch list - list of modified files - between two commits.
+   *
+   * @param change entity containing all change data.
+   * @param patchSet single revision of a {@link Change}.
+   * @return patch list containing the modified files between two commits.
+   * @deprecated use {@link DiffOperations} instead.
+   */
+  @Deprecated
   PatchList get(Change change, PatchSet patchSet) throws PatchListNotAvailableException;
 
+  /**
+   * Returns the patch list - list of modified files - between two commits.
+   *
+   * @param change entity containing all change data.
+   * @param patchSet single revision of a {@link Change}.
+   * @param parentNum 1-based parent number when new commit used in comparison is a merge commit.
+   * @return patch list containing the modified files between two commits.
+   * @deprecated use {@link DiffOperations} instead.
+   */
+  @Deprecated
   ObjectId getOldId(Change change, PatchSet patchSet, Integer parentNum)
       throws PatchListNotAvailableException;
 
diff --git a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index b663b9d..a3e9a54 100644
--- a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.patch.filediff.PatchListLoader;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -37,7 +38,7 @@
 /** Provides a cached list of {@link PatchListEntry}. */
 @Singleton
 public class PatchListCacheImpl implements PatchListCache {
-  static final String FILE_NAME = "diff";
+  public static final String FILE_NAME = "diff";
   static final String INTRA_NAME = "diff_intraline";
   static final String DIFF_SUMMARY = "diff_summary";
 
diff --git a/java/com/google/gerrit/server/patch/PatchListEntry.java b/java/com/google/gerrit/server/patch/PatchListEntry.java
index c91355a..de292c3 100644
--- a/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -49,8 +49,12 @@
   private static final byte[] EMPTY_HEADER = {};
 
   static PatchListEntry empty(String fileName) {
+    return empty(fileName, ChangeType.MODIFIED);
+  }
+
+  static PatchListEntry empty(String fileName, ChangeType changeType) {
     return new PatchListEntry(
-        ChangeType.MODIFIED,
+        changeType,
         PatchType.UNIFIED,
         null,
         fileName,
@@ -77,7 +81,7 @@
   // Note: When adding new fields, the serialVersionUID in PatchListKey must be
   // incremented so that entries from the cache are automatically invalidated.
 
-  PatchListEntry(
+  public PatchListEntry(
       FileHeader hdr, List<Edit> editList, Set<Edit> editsDueToRebase, long size, long sizeDelta) {
     changeType = toChangeType(hdr);
     patchType = toPatchType(hdr);
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index c6f7acf..5998bba 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.patch;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+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;
@@ -31,6 +32,8 @@
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.patch.DiffContentCalculator.DiffCalculatorResult;
 import com.google.gerrit.server.patch.DiffContentCalculator.TextSource;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
 import com.google.inject.Inject;
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
@@ -68,7 +71,8 @@
     intralineDiffCalculator = calculator;
   }
 
-  PatchScript toPatchScript(Repository git, PatchList list, PatchListEntry content)
+  /** Convert into {@link PatchScript} using the old diff cache output. */
+  PatchScript toPatchScriptOld(Repository git, PatchList list, PatchListEntry content)
       throws IOException {
 
     PatchFileChange change =
@@ -87,6 +91,32 @@
     return build(sides.a, sides.b, change);
   }
 
+  /** Convert into {@link PatchScript} using the new diff cache output. */
+  PatchScript toPatchScriptNew(Repository git, FileDiffOutput content) throws IOException {
+    PatchFileChange change =
+        new PatchFileChange(
+            content.edits().stream().map(TaggedEdit::jgitEdit).collect(toImmutableList()),
+            content.edits().stream()
+                .filter(TaggedEdit::dueToRebase)
+                .map(TaggedEdit::jgitEdit)
+                .collect(toImmutableSet()),
+            content.headerLines(),
+            FilePathAdapter.getOldPath(content.oldPath(), content.changeType()),
+            FilePathAdapter.getNewPath(content.oldPath(), content.newPath(), content.changeType()),
+            content.changeType(),
+            content.patchType().orElse(null));
+    SidesResolver sidesResolver = new SidesResolver(git, content.comparisonType());
+    ResolvedSides sides =
+        resolveSides(
+            git,
+            sidesResolver,
+            oldName(change),
+            newName(change),
+            content.oldCommitId(),
+            content.newCommitId());
+    return build(sides.a, sides.b, change);
+  }
+
   private ResolvedSides resolveSides(
       Repository git,
       SidesResolver sidesResolver,
@@ -352,16 +382,8 @@
         byte[] srcContent;
         if (reuse) {
           srcContent = other.srcContent;
-
-        } else if (mode.getObjectType() == Constants.OBJ_BLOB) {
-          srcContent = Text.asByteArray(db.open(id, Constants.OBJ_BLOB));
-
-        } else if (mode.getObjectType() == Constants.OBJ_COMMIT) {
-          String strContent = "Subproject commit " + ObjectId.toString(id);
-          srcContent = strContent.getBytes(UTF_8);
-
         } else {
-          srcContent = Text.NO_BYTES;
+          srcContent = SrcContentResolver.getSourceContent(db, id, mode);
         }
         String mimeType = MimeUtil2.UNKNOWN_MIME_TYPE.toString();
         DisplayMethod displayMethod = DisplayMethod.DIFF;
@@ -405,12 +427,7 @@
       if (mode == FileMode.MISSING) {
         displayMethod = DisplayMethod.NONE;
       }
-      PatchScript.FileMode fileMode = PatchScript.FileMode.FILE;
-      if (mode == FileMode.SYMLINK) {
-        fileMode = PatchScript.FileMode.SYMLINK;
-      } else if (mode == FileMode.GITLINK) {
-        fileMode = PatchScript.FileMode.GITLINK;
-      }
+      PatchScript.FileMode fileMode = PatchScript.FileMode.fromJgitFileMode(mode);
       return new PatchSide(
           treeId, path, id, mode, srcContent, src, mimeType, displayMethod, fileMode);
     }
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 02f46df..885459a 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -24,16 +24,25 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchScriptBuilder.IntraLineDiffCalculatorResult;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -41,15 +50,23 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.lang.exception.ExceptionUtils;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
@@ -64,34 +81,64 @@
         String fileName,
         @Assisted("patchSetA") PatchSet.Id patchSetA,
         @Assisted("patchSetB") PatchSet.Id patchSetB,
-        DiffPreferencesInfo diffPrefs);
+        DiffPreferencesInfo diffPrefs,
+        CurrentUser currentUser);
 
     PatchScriptFactory create(
         ChangeNotes notes,
         String fileName,
         int parentNum,
         PatchSet.Id patchSetB,
-        DiffPreferencesInfo diffPrefs);
+        DiffPreferencesInfo diffPrefs,
+        CurrentUser currentUser);
+  }
+
+  /** These metrics are temporary for launching the new redesigned diff cache. */
+  @Singleton
+  static class Metrics {
+    final Counter1<String> diffs;
+    static final String MATCH = "match";
+    static final String MISMATCH = "mismatch";
+    static final String ERROR = "error";
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      diffs =
+          metricMaker.newCounter(
+              "diff/get_diff/dark_launch",
+              new Description(
+                      "Total number of matching, non-matching, or error in diffs in the old and new diff cache implementations.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("type", Metadata.Builder::eventType).build());
+    }
   }
 
   private final GitRepositoryManager repoManager;
   private final PatchSetUtil psUtil;
   private final Provider<PatchScriptBuilder> builderFactory;
   private final PatchListCache patchListCache;
+  private final Metrics metrics;
+  private final ExecutorService executor;
 
   private final String fileName;
   @Nullable private final PatchSet.Id psa;
   private final int parentNum;
   private final PatchSet.Id psb;
   private final DiffPreferencesInfo diffPrefs;
+  private final CurrentUser currentUser;
+
   private final ChangeEditUtil editReader;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
+  private final DiffOperations diffOperations;
 
   private final Change.Id changeId;
 
   private ChangeNotes notes;
 
+  private final boolean runNewDiffCache;
+
   @AssistedInject
   PatchScriptFactory(
       GitRepositoryManager grm,
@@ -101,11 +148,16 @@
       ChangeEditUtil editReader,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
+      DiffOperations diffOperations,
+      Metrics metrics,
+      @DiffExecutor ExecutorService executor,
+      @GerritServerConfig Config cfg,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted("patchSetA") @Nullable PatchSet.Id patchSetA,
       @Assisted("patchSetB") PatchSet.Id patchSetB,
-      @Assisted DiffPreferencesInfo diffPrefs) {
+      @Assisted DiffPreferencesInfo diffPrefs,
+      @Assisted CurrentUser currentUser) {
     this.repoManager = grm;
     this.psUtil = psUtil;
     this.builderFactory = builderFactory;
@@ -114,12 +166,18 @@
     this.editReader = editReader;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.diffOperations = diffOperations;
+    this.metrics = metrics;
+    this.executor = executor;
 
     this.fileName = fileName;
     this.psa = patchSetA;
     this.parentNum = -1;
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
+    this.currentUser = currentUser;
+
+    this.runNewDiffCache = cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
     changeId = patchSetB.changeId();
   }
@@ -133,11 +191,16 @@
       ChangeEditUtil editReader,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
+      DiffOperations diffOperations,
+      Metrics metrics,
+      @DiffExecutor ExecutorService executor,
+      @GerritServerConfig Config cfg,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted int parentNum,
       @Assisted PatchSet.Id patchSetB,
-      @Assisted DiffPreferencesInfo diffPrefs) {
+      @Assisted DiffPreferencesInfo diffPrefs,
+      @Assisted CurrentUser currentUser) {
     this.repoManager = grm;
     this.psUtil = psUtil;
     this.builderFactory = builderFactory;
@@ -146,12 +209,18 @@
     this.editReader = editReader;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.diffOperations = diffOperations;
+    this.metrics = metrics;
+    this.executor = executor;
 
     this.fileName = fileName;
     this.psa = null;
     this.parentNum = parentNum;
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
+    this.currentUser = currentUser;
+
+    this.runNewDiffCache = cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
     changeId = patchSetB.changeId();
     checkArgument(parentNum >= 0, "parentNum must be >= 0");
@@ -163,7 +232,7 @@
           PermissionBackendException {
 
     try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
+      permissionBackend.user(currentUser).change(notes).check(ChangePermission.READ);
     } catch (AuthException e) {
       throw new NoSuchChangeException(changeId, e);
     }
@@ -190,14 +259,19 @@
           }
           bId = edit.get().getEditCommit();
         }
-
-        final PatchList list = listFor(keyFor(aId, bId, diffPrefs.ignoreWhitespace));
-        final PatchScriptBuilder b = newBuilder();
-        final PatchListEntry content = list.get(fileName);
-
-        return b.toPatchScript(git, list, content);
+        if (runNewDiffCache) {
+          PatchScript patchScript = getPatchScriptWithNewDiffCache(git, aId, bId);
+          // TODO(ghareeb): remove the async run. This is temporarily used to keep sanity checking
+          // the results while rolling out the new diff cache.
+          runOldDiffCacheAsyncAndExportMetrics(git, aId, bId, patchScript);
+          return patchScript;
+        } else {
+          return getPatchScriptWithOldDiffCache(git, aId, bId);
+        }
       } catch (PatchListNotAvailableException e) {
         throw new NoSuchChangeException(changeId, e);
+      } catch (DiffNotAvailableException e) {
+        throw new StorageException(e);
       } catch (IOException e) {
         logger.atSevere().withCause(e).log("File content unavailable");
         throw new NoSuchChangeException(changeId, e);
@@ -213,6 +287,111 @@
     }
   }
 
+  private void runOldDiffCacheAsyncAndExportMetrics(
+      Repository git, ObjectId aId, ObjectId bId, PatchScript expected) {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        executor.submit(
+            () -> {
+              try {
+                PatchScript patchScript = getPatchScriptWithOldDiffCache(git, aId, bId);
+                if (areEqualPatchscripts(patchScript, expected)) {
+                  metrics.diffs.increment(Metrics.MATCH);
+                } else {
+                  metrics.diffs.increment(Metrics.MISMATCH);
+                  logger.atWarning().atMostEvery(10, TimeUnit.SECONDS).log(
+                      "Mismatching diff for change %s, old commit ID: %s, new commit ID: %s, file name: %s.",
+                      changeId.toString(), aId, bId, fileName);
+                }
+              } catch (PatchListNotAvailableException | IOException e) {
+                metrics.diffs.increment(Metrics.ERROR);
+                logger.atSevere().atMostEvery(10, TimeUnit.SECONDS).log(
+                    String.format(
+                            "Error computing new diff for change %s, old commit ID: %s, new commit ID: %s.\n",
+                            changeId.toString(), aId, bId)
+                        + ExceptionUtils.getStackTrace(e));
+              }
+            });
+  }
+
+  private PatchScript getPatchScriptWithOldDiffCache(Repository git, ObjectId aId, ObjectId bId)
+      throws IOException, PatchListNotAvailableException {
+    PatchScriptBuilder patchScriptBuilder = newBuilder();
+    PatchList list = listFor(keyFor(aId, bId, diffPrefs.ignoreWhitespace));
+    PatchListEntry content = list.get(fileName);
+    return patchScriptBuilder.toPatchScriptOld(git, list, content);
+  }
+
+  private PatchScript getPatchScriptWithNewDiffCache(Repository git, ObjectId aId, ObjectId bId)
+      throws IOException, DiffNotAvailableException {
+    FileDiffOutput fileDiffOutput =
+        aId == null
+            ? diffOperations.getModifiedFileAgainstParent(
+                notes.getProjectName(),
+                bId,
+                parentNum == -1 ? null : parentNum + 1,
+                fileName,
+                diffPrefs.ignoreWhitespace)
+            : diffOperations.getModifiedFile(
+                notes.getProjectName(), aId, bId, fileName, diffPrefs.ignoreWhitespace);
+    return newBuilder().toPatchScriptNew(git, fileDiffOutput);
+  }
+
+  /**
+   * The comparison is not exhaustive but is using the most important fields. Comparing all fields
+   * will require some work in {@link PatchScript} to, e.g., convert it to autovalue. This
+   * comparison method shall give a strong signal that both patchscripts are almost identical.
+   */
+  private static boolean areEqualPatchscripts(PatchScript ps1, PatchScript ps2) {
+    boolean equal = true;
+    if (!ps1.getChangeType().equals(ps2.getChangeType())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching change type: old = %s, new = %s.", ps1.getChangeType(), ps2.getChangeType());
+    }
+    if (!ps1.getPatchHeader().equals(ps2.getPatchHeader())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching patch header: old = %s, new = %s.",
+          ps1.getPatchHeader(), ps2.getPatchHeader());
+    }
+    if (!Objects.equals(ps1.getOldName(), ps2.getOldName())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching old name: old = %s, new = %s.", ps1.getOldName(), ps2.getOldName());
+    }
+    if (!Objects.equals(ps1.getNewName(), ps2.getNewName())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching new name: old = %s, new = %s.", ps1.getNewName(), ps2.getNewName());
+    }
+    if (!ps1.getEdits().containsAll(ps2.getEdits())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching edits: old = %s, new = %s.", ps1.getEdits(), ps2.getEdits());
+    }
+    if (!ps2.getEdits().containsAll(ps1.getEdits())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching edits: old = %s, new = %s.", ps1.getEdits(), ps2.getEdits());
+    }
+    if (!ps1.getEditsDueToRebase().equals(ps2.getEditsDueToRebase())) {
+      equal = false;
+      logger.atWarning().log(
+          "Mismatching edits due to rebase: old = %s, new = %s.",
+          ps1.getEditsDueToRebase(), ps2.getEditsDueToRebase());
+    }
+    if (!ps1.getA().equals(ps2.getA())) {
+      equal = false;
+      logger.atWarning().log("Mismatching sparse file content in old commit.");
+    }
+    if (!ps1.getB().equals(ps2.getB())) {
+      equal = false;
+      logger.atWarning().log("Mismatching sparse file content in new commit.");
+    }
+    return equal;
+  }
+
   private Optional<ObjectId> getAId() {
     if (psa == null) {
       return Optional.empty();
diff --git a/java/com/google/gerrit/server/patch/SrcContentResolver.java b/java/com/google/gerrit/server/patch/SrcContentResolver.java
new file mode 100644
index 0000000..9cd11d2
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/SrcContentResolver.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.IOException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+/** Resolver of the source content of a specific file */
+public class SrcContentResolver {
+
+  private SrcContentResolver() {}
+
+  /**
+   * Return the source content of a specific file.
+   *
+   * @param repo Git repository.
+   * @param id Git Object ID of the file blob.
+   * @param fileMode File mode of the underlying file as recognized by Git.
+   * @return byte[] source content of the underlying file if the {@code id} is of type blob, or a
+   *     textual representation of the file if it is a git submodule.
+   * @throws IOException the object ID does not exist in the repository or cannot be accessed.
+   */
+  public static byte[] getSourceContent(Repository repo, ObjectId id, FileMode fileMode)
+      throws IOException {
+    if (fileMode.getObjectType() == Constants.OBJ_BLOB) {
+      return Text.asByteArray(repo.open(id, Constants.OBJ_BLOB));
+    }
+    if (fileMode.getObjectType() == Constants.OBJ_COMMIT) {
+      String strContent = "Subproject commit " + ObjectId.toString(id);
+      return strContent.getBytes(UTF_8);
+    }
+    return Text.NO_BYTES;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
new file mode 100644
index 0000000..18d532b
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -0,0 +1,278 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.prettify.common.SparseFileContent;
+import com.google.gerrit.prettify.common.SparseFileContent.Accessor;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * This class is used on submit to compute the diff between the latest approved patch-set, and the
+ * current submitted patch-set.
+ *
+ * <p>Latest approved patch-set is defined by the latest patch-set which has Code-Review label voted
+ * with the maximum possible value.
+ *
+ * <p>If the latest approved patch-set is the same as the submitted patch-set, the diff will be
+ * empty.
+ *
+ * <p>We exclude the magic files from the returned diff to make it shorter and more concise.
+ */
+public class SubmitWithStickyApprovalDiff {
+  private final ProjectCache projectCache;
+  private final PatchScriptFactory.Factory patchScriptFactoryFactory;
+  private final PatchListCache patchListCache;
+  private final int maxCumulativeSize;
+
+  @Inject
+  SubmitWithStickyApprovalDiff(
+      ProjectCache projectCache,
+      PatchScriptFactory.Factory patchScriptFactoryFactory,
+      PatchListCache patchListCache,
+      @GerritServerConfig Config serverConfig) {
+    this.projectCache = projectCache;
+    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
+    this.patchListCache = patchListCache;
+    maxCumulativeSize =
+        serverConfig.getInt(
+            "change",
+            "cumulativeCommentSizeLimit",
+            CommentCumulativeSizeValidator.DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT);
+  }
+
+  public String apply(ChangeNotes notes, CurrentUser currentUser)
+      throws AuthException, IOException, PermissionBackendException,
+          InvalidChangeOperationException {
+    // In some submit strategies, the current patch-set doesn't exist yet as it's being created
+    // during the submit. Hence, we assign the current patch-set to be the last existing patch-set.
+    PatchSet currentPatchset =
+        notes.getPatchSets().values().stream()
+            .max((p1, p2) -> p1.id().get() - p2.id().get())
+            .orElseThrow(
+                () ->
+                    new IllegalStateException(
+                        String.format(
+                            "change %s can't load any patchset", notes.getChangeId().toString())));
+
+    PatchSet.Id latestApprovedPatchsetId = getLatestApprovedPatchsetId(notes);
+    if (latestApprovedPatchsetId.get() == currentPatchset.id().get()) {
+      // If the latest approved patchset is the current patchset, no need to return anything.
+      return "";
+    }
+    StringBuilder diff =
+        new StringBuilder(
+            String.format(
+                "\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get()));
+    PatchList patchList =
+        getPatchList(
+            notes.getProjectName(),
+            currentPatchset,
+            notes.getPatchSets().get(latestApprovedPatchsetId));
+
+    // To make the message a bit more concise, we skip the magic files.
+    List<PatchListEntry> patchListEntryList =
+        patchList.getPatches().stream()
+            .filter(p -> !Patch.isMagic(p.getNewName()))
+            .collect(Collectors.toList());
+
+    if (patchListEntryList.isEmpty()) {
+      diff.append(
+          "No files were changed between the latest approved patch-set and the submitted one.\n");
+      return diff.toString();
+    }
+
+    diff.append("The change was submitted with unreviewed changes in the following files:\n\n");
+
+    for (PatchListEntry patchListEntry : patchListEntryList) {
+      diff.append(
+          getDiffForFile(
+              notes, currentPatchset.id(), latestApprovedPatchsetId, patchListEntry, currentUser));
+    }
+    if (diff.length() > maxCumulativeSize) {
+      // The diff length is not counted as part of the limit (for technical reasons, since we'd
+      // have to call CommentCumulativeSizeValidator), but it's best not to post an extra large
+      // change message here.
+      return String.format(
+          "\n\n%d is the latest approved patch-set.\nThe change was submitted "
+              + "with many unreviewed changes (the diff is too large to show). Please review the "
+              + "diff.",
+          latestApprovedPatchsetId.get());
+    }
+    return diff.toString();
+  }
+
+  private String getDiffForFile(
+      ChangeNotes notes,
+      PatchSet.Id currentPatchsetId,
+      PatchSet.Id latestApprovedPatchsetId,
+      PatchListEntry patchListEntry,
+      CurrentUser currentUser)
+      throws AuthException, InvalidChangeOperationException, IOException,
+          PermissionBackendException {
+    StringBuilder diff =
+        new StringBuilder(
+            String.format(
+                "The name of the file: %s\nInsertions: %d, Deletions: %d.\n\n",
+                patchListEntry.getNewName(),
+                patchListEntry.getInsertions(),
+                patchListEntry.getDeletions()));
+    DiffPreferencesInfo diffPreferencesInfo = createDefaultDiffPreferencesInfo();
+    PatchScriptFactory patchScriptFactory =
+        patchScriptFactoryFactory.create(
+            notes,
+            patchListEntry.getNewName(),
+            latestApprovedPatchsetId,
+            currentPatchsetId,
+            diffPreferencesInfo,
+            currentUser);
+    PatchScript patchScript = null;
+    try {
+      patchScript = patchScriptFactory.call();
+    } catch (LargeObjectException exception) {
+      diff.append("The file content is too large for showing the full diff. \n\n");
+      return diff.toString();
+    }
+    if (patchScript.getChangeType() == ChangeType.RENAMED) {
+      diff.append(
+          String.format(
+              "The file %s was renamed to %s\n",
+              patchListEntry.getOldName(), patchListEntry.getNewName()));
+    }
+    SparseFileContent.Accessor fileA = patchScript.getA().createAccessor();
+    SparseFileContent.Accessor fileB = patchScript.getB().createAccessor();
+    boolean editsExist = false;
+    if (patchScript.getEdits().stream().anyMatch(e -> e.getType() != Edit.Type.EMPTY)) {
+      diff.append("```\n");
+      editsExist = true;
+    }
+    for (Edit edit : patchScript.getEdits()) {
+      diff.append(getDiffForEdit(fileA, fileB, edit));
+    }
+    if (editsExist) {
+      diff.append("```\n");
+    }
+    return diff.toString();
+  }
+
+  private String getDiffForEdit(Accessor fileA, Accessor fileB, Edit edit) {
+    StringBuilder diff = new StringBuilder();
+    Edit.Type type = edit.getType();
+    switch (type) {
+      case INSERT:
+        diff.append(String.format("@@ +%d:%d @@\n", edit.getBeginB(), edit.getEndB()));
+        diff.append(getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+'));
+        diff.append("\n");
+        break;
+      case DELETE:
+        diff.append(String.format("@@ -%d:%d @@\n", edit.getBeginA(), edit.getEndA()));
+        diff.append(getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-'));
+        diff.append("\n");
+        break;
+      case REPLACE:
+        diff.append(
+            String.format(
+                "@@ -%d:%d, +%d:%d @@\n",
+                edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB()));
+        diff.append(getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-'));
+        diff.append(getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+'));
+        diff.append("\n");
+        break;
+      case EMPTY:
+        // do nothing since there is no change here.
+    }
+    return diff.toString();
+  }
+
+  private String getModifiedLines(Accessor file, int begin, int end, char modificationType) {
+    StringBuilder diff = new StringBuilder();
+    for (int i = begin; i < end; i++) {
+      diff.append(String.format("%c  %s\n", modificationType, file.get(i)));
+    }
+    return diff.toString();
+  }
+
+  private DiffPreferencesInfo createDefaultDiffPreferencesInfo() {
+    DiffPreferencesInfo diffPreferencesInfo = new DiffPreferencesInfo();
+    diffPreferencesInfo.ignoreWhitespace = Whitespace.IGNORE_NONE;
+    diffPreferencesInfo.intralineDifference = true;
+    return diffPreferencesInfo;
+  }
+
+  private PatchSet.Id getLatestApprovedPatchsetId(ChangeNotes notes) {
+    ProjectState projectState =
+        projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
+    PatchSet.Id maxPatchSetId = PatchSet.id(notes.getChangeId(), 1);
+    for (PatchSetApproval patchSetApproval : notes.getApprovals().values()) {
+      if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
+        continue;
+      }
+      if (!projectState
+          .getLabelTypes(notes)
+          .byLabel(patchSetApproval.labelId())
+          .isMaxPositive(patchSetApproval)) {
+        continue;
+      }
+      if (patchSetApproval.patchSetId().get() > maxPatchSetId.get()) {
+        maxPatchSetId = patchSetApproval.patchSetId();
+      }
+    }
+    return maxPatchSetId;
+  }
+
+  /**
+   * Gets the {@link PatchList} between the two latest patch-sets. Can be used to compute difference
+   * in files between those two patch-sets .
+   */
+  private PatchList getPatchList(Project.NameKey project, PatchSet ps, PatchSet priorPatchSet) {
+    PatchListKey key =
+        PatchListKey.againstCommit(
+            priorPatchSet.commitId(), ps.commitId(), DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+    try {
+      return patchListCache.get(key, project);
+    } catch (PatchListNotAvailableException ex) {
+      throw new StorageException(
+          "failed to compute difference in files, so won't post diff messsage on submit although "
+              + "the latest approved patch-set was not the same as the submitted patch-set.",
+          ex);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
new file mode 100644
index 0000000..bcae238
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
@@ -0,0 +1,45 @@
+//  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.patch.diff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+
+/**
+ * A cache for the list of Git modified files between 2 commits (patchsets) with extra Gerrit logic.
+ *
+ * <p>The loader uses the underlying {@link GitModifiedFilesCacheImpl} to retrieve the git modified
+ * files.
+ *
+ * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
+ * and the result will be exactly the same as the caller can get from {@link
+ * GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
+ */
+public interface ModifiedFilesCache {
+
+  /**
+   * @param key used to identify two git commits and contains other attributes to control the diff
+   *     calculation.
+   * @return the list of {@link ModifiedFile}s between the 2 git commits identified by the key.
+   * @throws DiffNotAvailableException the supplied commits IDs of the key do no exist, are not IDs
+   *     of a commit, or an exception occurred while reading a pack file.
+   */
+  ImmutableList<ModifiedFile> get(ModifiedFilesCacheKey key) throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
new file mode 100644
index 0000000..6023c0e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -0,0 +1,206 @@
+//  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.patch.diff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+
+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;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+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 org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A cache for the list of Git modified files between 2 commits (patchsets) with extra Gerrit logic.
+ *
+ * <p>The loader of this cache wraps a {@link GitModifiedFilesCache} to retrieve the git modified
+ * files.
+ *
+ * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
+ * and the result will be exactly the same as the caller can get from {@link
+ * GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
+ */
+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;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(ModifiedFilesCache.class).to(ModifiedFilesCacheImpl.class);
+
+        // The documentation has some defaults and recommendations for setting the cache
+        // attributes:
+        // https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.
+        // The cache is using the default disk limit as per section cache.<name>.diskLimit
+        // in the cache documentation link.
+        persist(
+                ModifiedFilesCacheImpl.MODIFIED_FILES,
+                ModifiedFilesCacheKey.class,
+                new TypeLiteral<ImmutableList<ModifiedFile>>() {})
+            .keySerializer(ModifiedFilesCacheKey.Serializer.INSTANCE)
+            .valueSerializer(GitModifiedFilesCacheImpl.ValueSerializer.INSTANCE)
+            .maximumWeight(10 << 20)
+            .weigher(ModifiedFilesWeigher.class)
+            .version(1)
+            .loader(ModifiedFilesLoader.class);
+      }
+    };
+  }
+
+  @Inject
+  public ModifiedFilesCacheImpl(
+      @Named(ModifiedFilesCacheImpl.MODIFIED_FILES)
+          LoadingCache<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public ImmutableList<ModifiedFile> get(ModifiedFilesCacheKey key)
+      throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (Exception e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class ModifiedFilesLoader
+      extends CacheLoader<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+    private final GitModifiedFilesCache gitCache;
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    ModifiedFilesLoader(GitModifiedFilesCache gitCache, GitRepositoryManager repoManager) {
+      this.gitCache = gitCache;
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    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);
+      }
+    }
+
+    private ImmutableList<ModifiedFile> loadModifiedFiles(ModifiedFilesCacheKey key, RevWalk rw)
+        throws IOException, DiffNotAvailableException {
+      ObjectId aTree =
+          key.aCommit().equals(EMPTY_TREE_ID)
+              ? 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();
+      List<ModifiedFile> modifiedFiles = gitCache.get(gitKey);
+      if (key.aCommit().equals(EMPTY_TREE_ID)) {
+        return ImmutableList.copyOf(modifiedFiles);
+      }
+      RevCommit revCommitA = DiffUtil.getRevCommit(rw, key.aCommit());
+      RevCommit revCommitB = DiffUtil.getRevCommit(rw, key.bCommit());
+      if (DiffUtil.areRelated(revCommitA, revCommitB)) {
+        return ImmutableList.copyOf(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
new file mode 100644
index 0000000..2ac3f5e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
@@ -0,0 +1,108 @@
+// 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.patch.diff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFilesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Cache key for the {@link com.google.gerrit.server.patch.diff.ModifiedFilesCache} */
+@AutoValue
+public abstract class ModifiedFilesCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /** @return the old commit ID used in the git tree diff */
+  public abstract ObjectId aCommit();
+
+  /** @return the new commit ID used in the git tree diff */
+  public abstract ObjectId bCommit();
+
+  /**
+   * Percentage score used to identify a file as a "rename". A special value of -1 means that the
+   * computation will ignore renames and rename detection will be disabled.
+   */
+  public abstract int renameScore();
+
+  public boolean renameDetectionEnabled() {
+    return renameScore() != -1;
+  }
+
+  /** Returns the size of the object in bytes */
+  public int weight() {
+    return stringSize(project().get()) // project
+        + 20 * 2 // aCommit and bCommit
+        + 4; // renameScore
+  }
+
+  public static ModifiedFilesCacheKey.Builder builder() {
+    return new AutoValue_ModifiedFilesCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract ModifiedFilesCacheKey.Builder project(NameKey value);
+
+    public abstract ModifiedFilesCacheKey.Builder aCommit(ObjectId value);
+
+    public abstract ModifiedFilesCacheKey.Builder bCommit(ObjectId value);
+
+    public ModifiedFilesCacheKey.Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract ModifiedFilesCacheKey.Builder renameScore(int value);
+
+    public abstract ModifiedFilesCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<ModifiedFilesCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(ModifiedFilesCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          ModifiedFilesKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setACommit(idConverter.toByteString(key.aCommit()))
+              .setBCommit(idConverter.toByteString(key.bCommit()))
+              .setRenameScore(key.renameScore())
+              .build());
+    }
+
+    @Override
+    public ModifiedFilesCacheKey deserialize(byte[] in) {
+      ModifiedFilesKeyProto proto = Protos.parseUnchecked(ModifiedFilesKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return ModifiedFilesCacheKey.builder()
+          .project(NameKey.parse(proto.getProject()))
+          .aCommit(idConverter.fromByteString(proto.getACommit()))
+          .bCommit(idConverter.fromByteString(proto.getBCommit()))
+          .renameScore(proto.getRenameScore())
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
new file mode 100644
index 0000000..512da6f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
@@ -0,0 +1,31 @@
+//  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.patch.diff;
+
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+
+public class ModifiedFilesWeigher
+    implements Weigher<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+  @Override
+  public int weigh(ModifiedFilesCacheKey key, ImmutableList<ModifiedFile> modifiedFiles) {
+    int weight = key.weight();
+    for (ModifiedFile modifiedFile : modifiedFiles) {
+      weight += modifiedFile.weight();
+    }
+    return weight;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java b/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
new file mode 100644
index 0000000..12decc3
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
@@ -0,0 +1,218 @@
+// 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.patch.filediff;
+
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCache;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheKey;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A helper class that computes the four {@link GitFileDiff}s for a list of {@link
+ * FileDiffCacheKey}s:
+ *
+ * <ul>
+ *   <li>old commit vs. new commit
+ *   <li>old parent vs. old commit
+ *   <li>new parent vs. new commit
+ *   <li>old parent vs. new parent
+ * </ul>
+ *
+ * The four {@link GitFileDiff} are stored in the entity class {@link AllFileGitDiffs}. We use these
+ * diffs to identify the edits due to rebase using the {@link EditTransformer} class.
+ */
+class AllDiffsEvaluator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final RevWalk rw;
+  private final GitFileDiffCache gitCache;
+
+  interface Factory {
+    AllDiffsEvaluator create(RevWalk rw);
+  }
+
+  @Inject
+  private AllDiffsEvaluator(GitFileDiffCache gitCache, @Assisted RevWalk rw) {
+    this.gitCache = gitCache;
+    this.rw = rw;
+  }
+
+  Map<AugmentedFileDiffCacheKey, AllFileGitDiffs> execute(
+      List<AugmentedFileDiffCacheKey> augmentedKeys) throws DiffNotAvailableException {
+    ImmutableMap.Builder<AugmentedFileDiffCacheKey, AllFileGitDiffs> keyToAllDiffs =
+        ImmutableMap.builderWithExpectedSize(augmentedKeys.size());
+
+    List<AugmentedFileDiffCacheKey> keysWithRebaseEdits =
+        augmentedKeys.stream().filter(k -> !k.ignoreRebase()).collect(Collectors.toList());
+
+    // TODO(ghareeb): as an enhancement, you can batch these calls as follows.
+    // 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 =
+        computeGitFileDiffs(
+            createGitKeys(
+                augmentedKeys,
+                k -> k.key().oldCommit(),
+                k -> k.key().newCommit(),
+                k -> k.key().newFilePath()));
+
+    Map<FileDiffCacheKey, GitDiffEntity> oldVsParentDiffs =
+        computeGitFileDiffs(
+            createGitKeys(
+                keysWithRebaseEdits,
+                k -> k.oldParentId().get(), // oldParent is set for keysWithRebaseEdits
+                k -> k.key().oldCommit(),
+                k -> mainDiffs.get(k.key()).gitDiff().oldPath().orElse(null)));
+
+    Map<FileDiffCacheKey, GitDiffEntity> newVsParentDiffs =
+        computeGitFileDiffs(
+            createGitKeys(
+                keysWithRebaseEdits,
+                k -> k.newParentId().get(), // newParent is set for keysWithRebaseEdits
+                k -> k.key().newCommit(),
+                k -> k.key().newFilePath()));
+
+    Map<FileDiffCacheKey, GitDiffEntity> parentsDiffs =
+        computeGitFileDiffs(
+            createGitKeys(
+                keysWithRebaseEdits,
+                k -> k.oldParentId().get(),
+                k -> k.newParentId().get(),
+                k -> {
+                  GitFileDiff newVsParDiff = newVsParentDiffs.get(k.key()).gitDiff();
+                  // TODO(ghareeb): Follow up on replacing key.newFilePath as a fallback.
+                  // If the file was added between newParent and newCommit, we actually wouldn't
+                  // need to have to determine the oldParent vs. newParent diff as nothing in
+                  // that file could be an edit due to rebase anymore. Only if the returned diff
+                  // is empty, the oldParent vs. newParent diff becomes relevant again (e.g. to
+                  // identify a file deletion which was due to rebase. Check if the structure
+                  // can be improved to make this clearer. Can we maybe even skip the diff in
+                  // the first situation described?
+                  return newVsParDiff.oldPath().orElse(k.key().newFilePath());
+                }));
+
+    for (AugmentedFileDiffCacheKey augmentedKey : augmentedKeys) {
+      FileDiffCacheKey key = augmentedKey.key();
+      AllFileGitDiffs.Builder builder =
+          AllFileGitDiffs.builder().augmentedKey(augmentedKey).mainDiff(mainDiffs.get(key));
+
+      if (augmentedKey.ignoreRebase()) {
+        keyToAllDiffs.put(augmentedKey, builder.build());
+        continue;
+      }
+
+      if (oldVsParentDiffs.containsKey(key) && !oldVsParentDiffs.get(key).gitDiff().isEmpty()) {
+        builder.oldVsParentDiff(Optional.of(oldVsParentDiffs.get(key)));
+      }
+
+      if (newVsParentDiffs.containsKey(key) && !newVsParentDiffs.get(key).gitDiff().isEmpty()) {
+        builder.newVsParentDiff(Optional.of(newVsParentDiffs.get(key)));
+      }
+
+      if (parentsDiffs.containsKey(key) && !parentsDiffs.get(key).gitDiff().isEmpty()) {
+        builder.parentVsParentDiff(Optional.of(parentsDiffs.get(key)));
+      }
+
+      keyToAllDiffs.put(augmentedKey, builder.build());
+    }
+    return keyToAllDiffs.build();
+  }
+
+  /**
+   * 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(
+      Map<FileDiffCacheKey, GitFileDiffCacheKey> keys) throws DiffNotAvailableException {
+    ImmutableMap.Builder<FileDiffCacheKey, GitDiffEntity> result =
+        ImmutableMap.builderWithExpectedSize(keys.size());
+    ImmutableMap<GitFileDiffCacheKey, GitFileDiff> gitDiffs = gitCache.getAll(keys.values());
+    for (FileDiffCacheKey key : keys.keySet()) {
+      GitFileDiffCacheKey gitKey = keys.get(key);
+      GitFileDiff gitFileDiff = gitDiffs.get(gitKey);
+      result.put(key, GitDiffEntity.create(gitKey, gitFileDiff));
+    }
+    return result.build();
+  }
+
+  /**
+   * Convert a list of {@link AugmentedFileDiffCacheKey} to their corresponding {@link
+   * GitFileDiffCacheKey} which can be used to call the underlying {@link GitFileDiffCache}.
+   *
+   * @param keys a list of input {@link AugmentedFileDiffCacheKey}s.
+   * @param aCommitFn a function to compute the aCommit that will be used in the git diff.
+   * @param bCommitFn a function to compute the bCommit that will be used in the git diff.
+   * @param newPathFn a function to compute the new path of the git key.
+   * @return a map of the input {@link FileDiffCacheKey} to the {@link GitFileDiffCacheKey}.
+   */
+  private Map<FileDiffCacheKey, GitFileDiffCacheKey> createGitKeys(
+      List<AugmentedFileDiffCacheKey> keys,
+      Function<AugmentedFileDiffCacheKey, ObjectId> aCommitFn,
+      Function<AugmentedFileDiffCacheKey, ObjectId> bCommitFn,
+      Function<AugmentedFileDiffCacheKey, String> newPathFn) {
+    Map<FileDiffCacheKey, GitFileDiffCacheKey> result = new HashMap<>();
+    for (AugmentedFileDiffCacheKey key : keys) {
+      try {
+        String path = newPathFn.apply(key);
+        if (path != null) {
+          result.put(
+              key.key(),
+              createGitKey(key.key(), aCommitFn.apply(key), bCommitFn.apply(key), path, rw));
+        }
+      } catch (IOException e) {
+        // TODO(ghareeb): This implies that the output keys may not have the same size as the input.
+        // Check the caller's code path about the correctness of the computation in this case. If
+        // errors are rare, it may be better to throw an exception and fail the whole computation.
+        logger.atWarning().log("Failed to compute the git key for key %s: %s", key, e.getMessage());
+      }
+    }
+    return result;
+  }
+
+  /** Returns the {@link GitFileDiffCacheKey} for the {@code key} input parameter. */
+  private GitFileDiffCacheKey createGitKey(
+      FileDiffCacheKey key, ObjectId aCommit, ObjectId bCommit, String pathNew, RevWalk rw)
+      throws IOException {
+    ObjectId oldTreeId =
+        aCommit.equals(EMPTY_TREE_ID) ? EMPTY_TREE_ID : DiffUtil.getTreeId(rw, aCommit);
+    ObjectId newTreeId = DiffUtil.getTreeId(rw, bCommit);
+    return GitFileDiffCacheKey.builder()
+        .project(key.project())
+        .oldTree(oldTreeId)
+        .newTree(newTreeId)
+        .newFilePath(pathNew == null ? key.newFilePath() : pathNew)
+        .renameScore(key.renameScore())
+        .diffAlgorithm(key.diffAlgorithm())
+        .whitespace(key.whitespace())
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/AllFileGitDiffs.java b/java/com/google/gerrit/server/patch/filediff/AllFileGitDiffs.java
new file mode 100644
index 0000000..3b1886f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/AllFileGitDiffs.java
@@ -0,0 +1,61 @@
+// 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.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+import java.util.Optional;
+
+/**
+ * An entity containing the four git diffs for a {@link FileDiffCacheKey}:
+ *
+ * <ol>
+ *   <li>The old vs. new commit
+ *   <li>The old commit vs. the old parent
+ *   <li>The new commit vs. the new parent
+ *   <li>The old parent vs. the new parent
+ * </ol>
+ */
+@AutoValue
+abstract class AllFileGitDiffs {
+  abstract AugmentedFileDiffCacheKey augmentedKey();
+
+  abstract GitDiffEntity mainDiff();
+
+  abstract Optional<GitDiffEntity> oldVsParentDiff();
+
+  abstract Optional<GitDiffEntity> newVsParentDiff();
+
+  abstract Optional<GitDiffEntity> parentVsParentDiff();
+
+  static AllFileGitDiffs.Builder builder() {
+    return new AutoValue_AllFileGitDiffs.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder augmentedKey(AugmentedFileDiffCacheKey value);
+
+    public abstract Builder mainDiff(GitDiffEntity value);
+
+    public abstract Builder oldVsParentDiff(Optional<GitDiffEntity> value);
+
+    public abstract Builder newVsParentDiff(Optional<GitDiffEntity> value);
+
+    public abstract Builder parentVsParentDiff(Optional<GitDiffEntity> value);
+
+    public abstract AllFileGitDiffs build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/AugmentedFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/filediff/AugmentedFileDiffCacheKey.java
new file mode 100644
index 0000000..8e40452
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/AugmentedFileDiffCacheKey.java
@@ -0,0 +1,52 @@
+// 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.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * A wrapper entity to the {@link FileDiffCacheKey} that also includes the old parent commit ID, the
+ * new parent commit ID and if we should ignore computing the rebase edits for that key.
+ */
+@AutoValue
+abstract class AugmentedFileDiffCacheKey {
+  abstract FileDiffCacheKey key();
+
+  abstract boolean ignoreRebase();
+
+  abstract Optional<ObjectId> oldParentId();
+
+  abstract Optional<ObjectId> newParentId();
+
+  static Builder builder() {
+    return new AutoValue_AugmentedFileDiffCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder oldParentId(Optional<ObjectId> value);
+
+    public abstract Builder newParentId(Optional<ObjectId> value);
+
+    public abstract Builder ignoreRebase(boolean value);
+
+    public abstract Builder key(FileDiffCacheKey value);
+
+    public abstract AugmentedFileDiffCacheKey build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/Edit.java b/java/com/google/gerrit/server/patch/filediff/Edit.java
new file mode 100644
index 0000000..4a698a4
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/Edit.java
@@ -0,0 +1,54 @@
+//  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.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * A modified region between 2 versions of the same content. This is the Gerrit entity class
+ * corresponding to {@link org.eclipse.jgit.diff.Edit} and is needed to ensure immutability when
+ * included as fields of the diff persisted caches.
+ */
+@AutoValue
+public abstract class Edit {
+  public static Edit create(int beginA, int endA, int beginB, int endB) {
+    return new AutoValue_Edit(beginA, endA, beginB, endB);
+  }
+
+  public static Edit fromJGitEdit(org.eclipse.jgit.diff.Edit jgitEdit) {
+    return create(
+        jgitEdit.getBeginA(), jgitEdit.getEndA(), jgitEdit.getBeginB(), jgitEdit.getEndB());
+  }
+
+  public static org.eclipse.jgit.diff.Edit toJGitEdit(Edit e) {
+    return new org.eclipse.jgit.diff.Edit(e.beginA(), e.endA(), e.beginB(), e.endB());
+  }
+
+  public org.eclipse.jgit.diff.Edit asJGitEdit() {
+    return new org.eclipse.jgit.diff.Edit(beginA(), endA(), beginB(), endB());
+  }
+
+  /** Start of a region in sequence A. */
+  public abstract int beginA();
+
+  /** End of a region in sequence A. */
+  public abstract int endA();
+
+  /** Start of a region in sequence B. */
+  public abstract int beginB();
+
+  /** End of a region in sequence B. */
+  public abstract int endB();
+}
diff --git a/java/com/google/gerrit/server/patch/EditTransformer.java b/java/com/google/gerrit/server/patch/filediff/EditTransformer.java
similarity index 62%
rename from java/com/google/gerrit/server/patch/EditTransformer.java
rename to java/com/google/gerrit/server/patch/filediff/EditTransformer.java
index 6288270..55568e4 100644
--- a/java/com/google/gerrit/server/patch/EditTransformer.java
+++ b/java/com/google/gerrit/server/patch/filediff/EditTransformer.java
@@ -12,20 +12,21 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.patch;
+package com.google.gerrit.server.patch.filediff;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.collect.Multimaps.toMultimap;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.MoreObjects;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.server.patch.DiffMappings;
+import com.google.gerrit.server.patch.GitPositionTransformer;
 import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.OmitPositionOnConflict;
 import com.google.gerrit.server.patch.GitPositionTransformer.Position;
@@ -36,7 +37,6 @@
 import java.util.Optional;
 import java.util.function.Function;
 import java.util.stream.Stream;
-import org.eclipse.jgit.diff.Edit;
 
 /**
  * Transformer of edits regarding their base trees. An edit describes a difference between {@code
@@ -54,40 +54,42 @@
 
   /**
    * Creates a new {@code EditTransformer} for the edits contained in the specified {@code
-   * PatchListEntry}s.
+   * FileEdits}s.
    *
-   * @param patchListEntries a list of {@code PatchListEntry}s containing the edits
+   * @param fileEdits a list of {@code FileEdits}s containing the edits
    */
-  public EditTransformer(List<PatchListEntry> patchListEntries) {
-    edits = patchListEntries.stream().flatMap(EditTransformer::toEdits).collect(toImmutableList());
+  public EditTransformer(List<FileEdits> fileEdits) {
+    // TODO(ghareeb): Can we replace FileEdits with another entity from the new refactored
+    // diff cache implementation? e.g. one of the GitFileDiffCache entities
+    edits = fileEdits.stream().flatMap(EditTransformer::toEdits).collect(toImmutableList());
   }
 
   /**
    * Transforms the references of side A of the edits. If the edits describe differences between
-   * {@code treeA} and {@code treeB} and the specified {@code PatchListEntry}s define a
-   * transformation from {@code treeA} to {@code treeA'}, the resulting edits will be defined as
-   * differences between {@code treeA'} and {@code treeB}. Edits which can't be transformed due to
-   * conflicts with the transformation are omitted.
+   * {@code treeA} and {@code treeB} and the specified {@code FileEdits}s define a transformation
+   * from {@code treeA} to {@code treeA'}, the resulting edits will be defined as differences
+   * between {@code treeA'} and {@code treeB}. Edits which can't be transformed due to conflicts
+   * with the transformation are omitted.
    *
-   * @param transformationEntries a list of {@code PatchListEntry}s defining the transformation of
-   *     {@code treeA} to {@code treeA'}
+   * @param transformingEntries a list of {@code FileEdits}s defining the transformation of {@code
+   *     treeA} to {@code treeA'}
    */
-  public void transformReferencesOfSideA(List<PatchListEntry> transformationEntries) {
-    transformEdits(transformationEntries, SideAStrategy.INSTANCE);
+  public void transformReferencesOfSideA(ImmutableList<FileEdits> transformingEntries) {
+    transformEdits(transformingEntries, SideAStrategy.INSTANCE);
   }
 
   /**
    * Transforms the references of side B of the edits. If the edits describe differences between
-   * {@code treeA} and {@code treeB} and the specified {@code PatchListEntry}s define a
-   * transformation from {@code treeB} to {@code treeB'}, the resulting edits will be defined as
-   * differences between {@code treeA} and {@code treeB'}. Edits which can't be transformed due to
-   * conflicts with the transformation are omitted.
+   * {@code treeA} and {@code treeB} and the specified {@code FileEdits}s define a transformation
+   * from {@code treeB} to {@code treeB'}, the resulting edits will be defined as differences
+   * between {@code treeA} and {@code treeB'}. Edits which can't be transformed due to conflicts
+   * with the transformation are omitted.
    *
-   * @param transformationEntries a list of {@code PatchListEntry}s defining the transformation of
+   * @param transformingEntries a list of {@code PatchListEntry}s defining the transformation of
    *     {@code treeB} to {@code treeB'}
    */
-  public void transformReferencesOfSideB(List<PatchListEntry> transformationEntries) {
-    transformEdits(transformationEntries, SideBStrategy.INSTANCE);
+  public void transformReferencesOfSideB(ImmutableList<FileEdits> transformingEntries) {
+    transformEdits(transformingEntries, SideBStrategy.INSTANCE);
   }
 
   /**
@@ -99,25 +101,33 @@
     return edits.stream()
         .collect(
             toMultimap(
-                ContextAwareEdit::getNewFilePath, Function.identity(), ArrayListMultimap::create));
+                c -> {
+                  String path =
+                      c.getNewFilePath().isPresent()
+                          ? c.getNewFilePath().get()
+                          : c.getOldFilePath().get();
+                  return path;
+                },
+                Function.identity(),
+                ArrayListMultimap::create));
   }
 
-  public static Stream<ContextAwareEdit> toEdits(PatchListEntry patchListEntry) {
-    ImmutableList<Edit> edits = patchListEntry.getEdits();
+  public static Stream<ContextAwareEdit> toEdits(FileEdits in) {
+    List<Edit> edits = in.edits();
     if (edits.isEmpty()) {
-      return Stream.of(ContextAwareEdit.createForNoContentEdit(patchListEntry));
+      return Stream.of(ContextAwareEdit.createForNoContentEdit(in.oldPath(), in.newPath()));
     }
 
-    return edits.stream().map(edit -> ContextAwareEdit.create(patchListEntry, edit));
+    return edits.stream().map(edit -> ContextAwareEdit.create(in.oldPath(), in.newPath(), edit));
   }
 
-  private void transformEdits(List<PatchListEntry> transformingEntries, SideStrategy sideStrategy) {
+  private void transformEdits(List<FileEdits> inputs, SideStrategy sideStrategy) {
     ImmutableList<PositionedEntity<ContextAwareEdit>> positionedEdits =
         edits.stream()
             .map(edit -> toPositionedEntity(edit, sideStrategy))
             .collect(toImmutableList());
     ImmutableSet<Mapping> mappings =
-        transformingEntries.stream().map(DiffMappings::toMapping).collect(toImmutableSet());
+        inputs.stream().map(DiffMappings::toMapping).collect(toImmutableSet());
 
     edits =
         positionTransformer.transform(positionedEdits, mappings).stream()
@@ -133,41 +143,41 @@
 
   @AutoValue
   abstract static class ContextAwareEdit {
-    static ContextAwareEdit create(PatchListEntry patchListEntry, Edit edit) {
+    static ContextAwareEdit create(Optional<String> oldPath, Optional<String> newPath, Edit edit) {
+      // TODO(ghareeb): Look if the new FileEdits class is capable of representing renames/copies
+      // and in this case we can get rid of the ContextAwareEdit class.
       return create(
-          patchListEntry.getOldName(),
-          patchListEntry.getNewName(),
-          edit.getBeginA(),
-          edit.getEndA(),
-          edit.getBeginB(),
-          edit.getEndB(),
-          false);
+          oldPath, newPath, edit.beginA(), edit.endA(), edit.beginB(), edit.endB(), false);
     }
 
-    static ContextAwareEdit createForNoContentEdit(PatchListEntry patchListEntry) {
+    static ContextAwareEdit createForNoContentEdit(
+        Optional<String> oldPath, Optional<String> newPath) {
       // Remove the warning in createEditAtNewPosition() if we switch to an empty range instead of
       // (-1:-1, -1:-1) in the future.
-      return create(
-          patchListEntry.getOldName(), patchListEntry.getNewName(), -1, -1, -1, -1, false);
+      return create(oldPath, newPath, -1, -1, -1, -1, false);
     }
 
     static ContextAwareEdit create(
-        String oldFilePath,
-        String newFilePath,
+        Optional<String> oldFilePath,
+        Optional<String> newFilePath,
         int beginA,
         int endA,
         int beginB,
         int endB,
         boolean filePathAdjusted) {
-      String adjustedOldFilePath = MoreObjects.firstNonNull(oldFilePath, newFilePath);
-      boolean implicitRename = !Objects.equals(oldFilePath, newFilePath) && filePathAdjusted;
+      Optional<String> adjustedFilePath = oldFilePath.isPresent() ? oldFilePath : newFilePath;
+      boolean implicitRename =
+          newFilePath.isPresent()
+              && oldFilePath.isPresent()
+              && !Objects.equals(oldFilePath.get(), newFilePath.get())
+              && filePathAdjusted;
       return new AutoValue_EditTransformer_ContextAwareEdit(
-          adjustedOldFilePath, newFilePath, beginA, endA, beginB, endB, implicitRename);
+          adjustedFilePath, newFilePath, beginA, endA, beginB, endB, implicitRename);
     }
 
-    public abstract String getOldFilePath();
+    public abstract Optional<String> getOldFilePath();
 
-    public abstract String getNewFilePath();
+    public abstract Optional<String> getNewFilePath();
 
     public abstract int getBeginA();
 
@@ -180,12 +190,13 @@
     // Used for equals(), for which this value is important.
     public abstract boolean isImplicitRename();
 
-    public Optional<Edit> toEdit() {
+    public Optional<org.eclipse.jgit.diff.Edit> toEdit() {
       if (getBeginA() < 0) {
         return Optional.empty();
       }
 
-      return Optional.of(new Edit(getBeginA(), getEndA(), getBeginB(), getEndB()));
+      return Optional.of(
+          new org.eclipse.jgit.diff.Edit(getBeginA(), getEndA(), getBeginB(), getEndB()));
     }
   }
 
@@ -200,8 +211,12 @@
 
     @Override
     public Position extractPosition(ContextAwareEdit edit) {
+      String filePath =
+          edit.getOldFilePath().isPresent()
+              ? edit.getOldFilePath().get()
+              : edit.getNewFilePath().get();
       return Position.builder()
-          .filePath(edit.getOldFilePath())
+          .filePath(filePath)
           .lineRange(Range.create(edit.getBeginA(), edit.getEndA()))
           .build();
     }
@@ -227,13 +242,13 @@
             newPosition);
       }
       return ContextAwareEdit.create(
-          updatedFilePath,
+          Optional.of(updatedFilePath),
           edit.getNewFilePath(),
           updatedRange.start(),
           updatedRange.end(),
           edit.getBeginB(),
           edit.getEndB(),
-          !Objects.equals(edit.getOldFilePath(), updatedFilePath));
+          !Objects.equals(edit.getOldFilePath(), Optional.of(updatedFilePath)));
     }
   }
 
@@ -242,8 +257,12 @@
 
     @Override
     public Position extractPosition(ContextAwareEdit edit) {
+      String filePath =
+          edit.getNewFilePath().isPresent()
+              ? edit.getNewFilePath().get()
+              : edit.getOldFilePath().get();
       return Position.builder()
-          .filePath(edit.getNewFilePath())
+          .filePath(filePath)
           .lineRange(Range.create(edit.getBeginB(), edit.getEndB()))
           .build();
     }
@@ -255,7 +274,8 @@
       // in the future.
       Range updatedRange = newPosition.lineRange().orElseGet(() -> Range.create(-1, -1));
       // Same as far the range above. PATCHSET_LEVEL is a safe fallback.
-      String updatedFilePath = newPosition.filePath().orElse(Patch.PATCHSET_LEVEL);
+      Optional<String> updatedFilePath =
+          Optional.of(newPosition.filePath().orElse(Patch.PATCHSET_LEVEL));
       return ContextAwareEdit.create(
           edit.getOldFilePath(),
           updatedFilePath,
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
new file mode 100644
index 0000000..a9bcf03
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
@@ -0,0 +1,45 @@
+//  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.patch.filediff;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+
+/**
+ * This cache computes the git diff for a single file path and adds some extra logic, e.g. for
+ * identifying edits that are due to rebase.
+ */
+public interface FileDiffCache {
+  /**
+   * Returns the file diff for a single file path identified by its key.
+   *
+   * @param key identifies two git commits, a specific file path and other diff parameters.
+   * @return the file diff for a single file path identified by its key.
+   * @throws DiffNotAvailableException if the commit IDs of the key are invalid for this project or
+   *     if file contents could not be read.
+   */
+  FileDiffOutput get(FileDiffCacheKey key) throws DiffNotAvailableException;
+
+  /**
+   * Returns the file diff for a collection of file paths identified by their keys.
+   *
+   * @param keys identifying different file paths of different projects.
+   * @return a map of the input keys to their corresponding git file diffs.
+   * @throws DiffNotAvailableException if the diff failed to be evaluated for one or more of the
+   *     input keys due to invalid commit IDs or if file contents could not be read.
+   */
+  ImmutableMap<FileDiffCacheKey, FileDiffOutput> getAll(Iterable<FileDiffCacheKey> keys)
+      throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
new file mode 100644
index 0000000..395312f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -0,0 +1,544 @@
+//  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.patch.filediff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+
+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.common.collect.Iterables;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.patch.filediff.EditTransformer.ContextAwareEdit;
+import com.google.gerrit.server.patch.gitfilediff.FileHeaderUtil;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithmFactory;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.EditList;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.patch.FileHeader;
+import org.eclipse.jgit.patch.FileHeader.PatchType;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Cache for the single file diff between two commits for a single file path. This cache adds extra
+ * Gerrit logic such as identifying edits due to rebase.
+ *
+ * <p>If the {@link FileDiffCacheKey#oldCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the git diff will be evaluated against the empty
+ * tree.
+ */
+public class FileDiffCacheImpl implements FileDiffCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String DIFF = "gerrit_file_diff";
+
+  private final LoadingCache<FileDiffCacheKey, FileDiffOutput> cache;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(FileDiffCache.class).to(FileDiffCacheImpl.class);
+
+        factory(AllDiffsEvaluator.Factory.class);
+
+        persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
+            .maximumWeight(10 << 20)
+            .weigher(FileDiffWeigher.class)
+            .version(4)
+            .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
+            .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
+            .loader(FileDiffLoader.class);
+      }
+    };
+  }
+
+  private enum MagicPath {
+    COMMIT,
+    MERGE_LIST
+  }
+
+  @Inject
+  public FileDiffCacheImpl(@Named(DIFF) LoadingCache<FileDiffCacheKey, FileDiffOutput> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public FileDiffOutput get(FileDiffCacheKey key) throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  @Override
+  public ImmutableMap<FileDiffCacheKey, FileDiffOutput> getAll(Iterable<FileDiffCacheKey> keys)
+      throws DiffNotAvailableException {
+    try {
+      ImmutableMap<FileDiffCacheKey, FileDiffOutput> result = cache.getAll(keys);
+      if (result.size() != Iterables.size(keys)) {
+        throw new DiffNotAvailableException(
+            String.format(
+                "Failed to load the value for all %d keys. Returned "
+                    + "map contains only %d values",
+                Iterables.size(keys), result.size()));
+      }
+      return result;
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class FileDiffLoader extends CacheLoader<FileDiffCacheKey, FileDiffOutput> {
+    private final GitRepositoryManager repoManager;
+    private final AllDiffsEvaluator.Factory allDiffsEvaluatorFactory;
+
+    @Inject
+    FileDiffLoader(
+        AllDiffsEvaluator.Factory allDiffsEvaluatorFactory, GitRepositoryManager manager) {
+      this.allDiffsEvaluatorFactory = allDiffsEvaluatorFactory;
+      this.repoManager = manager;
+    }
+
+    @Override
+    public FileDiffOutput load(FileDiffCacheKey key) throws IOException, DiffNotAvailableException {
+      return loadAll(ImmutableList.of(key)).get(key);
+    }
+
+    @Override
+    public Map<FileDiffCacheKey, FileDiffOutput> loadAll(Iterable<? extends FileDiffCacheKey> keys)
+        throws DiffNotAvailableException {
+      ImmutableMap.Builder<FileDiffCacheKey, FileDiffOutput> result = ImmutableMap.builder();
+
+      Map<Project.NameKey, List<FileDiffCacheKey>> keysByProject =
+          Streams.stream(keys).distinct().collect(Collectors.groupingBy(FileDiffCacheKey::project));
+
+      for (Project.NameKey project : keysByProject.keySet()) {
+        List<FileDiffCacheKey> fileKeys = new ArrayList<>();
+
+        try (Repository repo = repoManager.openRepository(project);
+            ObjectReader reader = repo.newObjectReader();
+            RevWalk rw = new RevWalk(reader)) {
+
+          for (FileDiffCacheKey key : keysByProject.get(project)) {
+            if (key.newFilePath().equals(Patch.COMMIT_MSG)) {
+              result.put(key, createMagicPathEntry(key, reader, rw, MagicPath.COMMIT));
+            } else if (key.newFilePath().equals(Patch.MERGE_LIST)) {
+              result.put(key, createMagicPathEntry(key, reader, rw, MagicPath.MERGE_LIST));
+            } else {
+              fileKeys.add(key);
+            }
+          }
+          result.putAll(createFileEntries(reader, fileKeys, rw));
+        } catch (IOException e) {
+          logger.atWarning().log("Failed to open the repository %s: %s", project, e.getMessage());
+        }
+      }
+      return result.build();
+    }
+
+    private ComparisonType getComparisonType(
+        RevWalk rw, ObjectReader reader, ObjectId oldCommitId, ObjectId newCommitId)
+        throws IOException {
+      RevCommit oldCommit = DiffUtil.getRevCommit(rw, oldCommitId);
+      RevCommit newCommit = DiffUtil.getRevCommit(rw, newCommitId);
+      for (int i = 0; i < newCommit.getParentCount(); i++) {
+        if (newCommit.getParent(i).equals(oldCommit)) {
+          return ComparisonType.againstParent(i + 1);
+        }
+      }
+      // TODO(ghareeb): it's not trivial to distinguish if diff with old commit is against another
+      // patchset or auto-merge. Looking at the commit message of old commit gives a strong
+      // signal that we are diffing against auto-merge, though not 100% accurate (e.g. if old commit
+      // has the auto-merge prefix in the commit message). A better resolution would be to move the
+      // COMMIT_MSG and MERGE_LIST evaluations outside of the diff cache. For more details, see
+      // discussion in
+      // https://gerrit-review.googlesource.com/c/gerrit/+/280519/6..18/java/com/google/gerrit/server/patch/FileDiffCache.java#b540
+      String oldCommitMsgTxt = new String(Text.forCommit(reader, oldCommit).getContent(), UTF_8);
+      if (oldCommitMsgTxt.contains(AutoMerger.AUTO_MERGE_MSG_PREFIX)) {
+        return ComparisonType.againstAutoMerge();
+      }
+      return ComparisonType.againstOtherPatchSet();
+    }
+
+    /**
+     * Creates a {@link FileDiffOutput} entry for the "Commit message" and "Merge list" file paths.
+     */
+    private FileDiffOutput createMagicPathEntry(
+        FileDiffCacheKey key, ObjectReader reader, RevWalk rw, MagicPath magicPath) {
+      try {
+        RawTextComparator cmp = comparatorFor(key.whitespace());
+        ComparisonType comparisonType =
+            getComparisonType(rw, reader, key.oldCommit(), key.newCommit());
+        RevCommit aCommit = DiffUtil.getRevCommit(rw, key.oldCommit());
+        RevCommit bCommit = DiffUtil.getRevCommit(rw, key.newCommit());
+        return magicPath == MagicPath.COMMIT
+            ? createCommitEntry(reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm())
+            : createMergeListEntry(
+                reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm());
+      } catch (IOException e) {
+        logger.atWarning().log("Failed to compute commit entry for key %s", key);
+      }
+      return FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
+    }
+
+    private static RawTextComparator comparatorFor(Whitespace ws) {
+      switch (ws) {
+        case IGNORE_ALL:
+          return RawTextComparator.WS_IGNORE_ALL;
+
+        case IGNORE_TRAILING:
+          return RawTextComparator.WS_IGNORE_TRAILING;
+
+        case IGNORE_LEADING_AND_TRAILING:
+          return RawTextComparator.WS_IGNORE_CHANGE;
+
+        case IGNORE_NONE:
+        default:
+          return RawTextComparator.DEFAULT;
+      }
+    }
+
+    private FileDiffOutput createCommitEntry(
+        ObjectReader reader,
+        RevCommit oldCommit,
+        RevCommit newCommit,
+        ComparisonType comparisonType,
+        RawTextComparator rawTextComparator,
+        GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
+        throws IOException {
+      Text aText =
+          comparisonType.isAgainstParentOrAutoMerge()
+              ? Text.EMPTY
+              : Text.forCommit(reader, oldCommit);
+      Text bText = Text.forCommit(reader, newCommit);
+      return createMagicFileDiffOutput(
+          oldCommit,
+          newCommit,
+          comparisonType,
+          rawTextComparator,
+          aText,
+          bText,
+          Patch.COMMIT_MSG,
+          diffAlgorithm);
+    }
+
+    private FileDiffOutput createMergeListEntry(
+        ObjectReader reader,
+        RevCommit oldCommit,
+        RevCommit newCommit,
+        ComparisonType comparisonType,
+        RawTextComparator rawTextComparator,
+        GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
+        throws IOException {
+      Text aText =
+          comparisonType.isAgainstParentOrAutoMerge()
+              ? Text.EMPTY
+              : Text.forMergeList(comparisonType, reader, oldCommit);
+      Text bText = Text.forMergeList(comparisonType, reader, newCommit);
+      return createMagicFileDiffOutput(
+          oldCommit,
+          newCommit,
+          comparisonType,
+          rawTextComparator,
+          aText,
+          bText,
+          Patch.MERGE_LIST,
+          diffAlgorithm);
+    }
+
+    private static FileDiffOutput createMagicFileDiffOutput(
+        ObjectId oldCommit,
+        ObjectId newCommit,
+        ComparisonType comparisonType,
+        RawTextComparator rawTextComparator,
+        Text aText,
+        Text bText,
+        String fileName,
+        GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm) {
+      byte[] rawHdr = getRawHeader(!comparisonType.isAgainstParentOrAutoMerge(), fileName);
+      byte[] aContent = aText.getContent();
+      byte[] bContent = bText.getContent();
+      long size = bContent.length;
+      long sizeDelta = size - aContent.length;
+      RawText aRawText = new RawText(aContent);
+      RawText bRawText = new RawText(bContent);
+      EditList edits =
+          DiffAlgorithmFactory.create(diffAlgorithm).diff(rawTextComparator, aRawText, bRawText);
+      FileHeader fileHeader = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
+      Patch.ChangeType changeType = FileHeaderUtil.getChangeType(fileHeader);
+      return FileDiffOutput.builder()
+          .oldCommitId(oldCommit)
+          .newCommitId(newCommit)
+          .comparisonType(comparisonType)
+          .oldPath(FileHeaderUtil.getOldPath(fileHeader))
+          .newPath(FileHeaderUtil.getNewPath(fileHeader))
+          .changeType(changeType)
+          .patchType(Optional.of(FileHeaderUtil.getPatchType(fileHeader)))
+          .headerLines(FileHeaderUtil.getHeaderLines(fileHeader))
+          .edits(
+              asTaggedEdits(
+                  edits.stream().map(Edit::fromJGitEdit).collect(Collectors.toList()),
+                  ImmutableList.of()))
+          .size(size)
+          .sizeDelta(sizeDelta)
+          .build();
+    }
+
+    private static byte[] getRawHeader(boolean hasA, String fileName) {
+      StringBuilder hdr = new StringBuilder();
+      hdr.append("diff --git");
+      if (hasA) {
+        hdr.append(" a/").append(fileName);
+      } else {
+        hdr.append(" ").append(FileHeader.DEV_NULL);
+      }
+      hdr.append(" b/").append(fileName);
+      hdr.append("\n");
+
+      if (hasA) {
+        hdr.append("--- a/").append(fileName).append("\n");
+      } else {
+        hdr.append("--- ").append(FileHeader.DEV_NULL).append("\n");
+      }
+      hdr.append("+++ b/").append(fileName).append("\n");
+      return hdr.toString().getBytes(UTF_8);
+    }
+
+    private Map<FileDiffCacheKey, FileDiffOutput> createFileEntries(
+        ObjectReader reader, List<FileDiffCacheKey> keys, RevWalk rw)
+        throws DiffNotAvailableException, IOException {
+      Map<AugmentedFileDiffCacheKey, AllFileGitDiffs> allFileDiffs =
+          allDiffsEvaluatorFactory.create(rw).execute(wrapKeys(keys, rw));
+
+      Map<FileDiffCacheKey, FileDiffOutput> result = new HashMap<>();
+
+      for (AugmentedFileDiffCacheKey augmentedKey : allFileDiffs.keySet()) {
+        AllFileGitDiffs allDiffs = allFileDiffs.get(augmentedKey);
+
+        FileEdits rebaseFileEdits = FileEdits.empty();
+        if (!augmentedKey.ignoreRebase()) {
+          rebaseFileEdits = computeRebaseEdits(allDiffs);
+        }
+        List<Edit> rebaseEdits = rebaseFileEdits.edits();
+
+        RevTree aTree = rw.parseTree(allDiffs.mainDiff().gitKey().oldTree());
+        RevTree bTree = rw.parseTree(allDiffs.mainDiff().gitKey().newTree());
+        GitFileDiff mainGitDiff = allDiffs.mainDiff().gitDiff();
+
+        Long oldSize =
+            mainGitDiff.oldMode().isPresent() && mainGitDiff.oldPath().isPresent()
+                ? new FileSizeEvaluator(reader, aTree)
+                    .compute(
+                        mainGitDiff.oldId(),
+                        mainGitDiff.oldMode().get(),
+                        mainGitDiff.oldPath().get())
+                : 0;
+        Long newSize =
+            mainGitDiff.newMode().isPresent() && mainGitDiff.newPath().isPresent()
+                ? new FileSizeEvaluator(reader, bTree)
+                    .compute(
+                        mainGitDiff.newId(),
+                        mainGitDiff.newMode().get(),
+                        mainGitDiff.newPath().get())
+                : 0;
+
+        ObjectId oldCommit = augmentedKey.key().oldCommit();
+        ObjectId newCommit = augmentedKey.key().newCommit();
+        FileDiffOutput fileDiff =
+            FileDiffOutput.builder()
+                .oldCommitId(oldCommit)
+                .newCommitId(newCommit)
+                .comparisonType(getComparisonType(rw, reader, oldCommit, newCommit))
+                .changeType(mainGitDiff.changeType())
+                .patchType(mainGitDiff.patchType())
+                .oldPath(mainGitDiff.oldPath())
+                .newPath(mainGitDiff.newPath())
+                .headerLines(FileHeaderUtil.getHeaderLines(mainGitDiff.fileHeader()))
+                .edits(asTaggedEdits(mainGitDiff.edits(), rebaseEdits))
+                .size(newSize)
+                .sizeDelta(newSize - oldSize)
+                .build();
+
+        result.put(augmentedKey.key(), fileDiff);
+      }
+
+      return result;
+    }
+
+    /**
+     * Convert the list of input keys {@link FileDiffCacheKey} to a list of {@link
+     * AugmentedFileDiffCacheKey} that also include the old and new parent commit IDs, and a boolean
+     * that indicates whether we should include the rebase edits for each key.
+     *
+     * <p>The output list is expected to have the same size of the input list, i.e. we map all keys.
+     */
+    private List<AugmentedFileDiffCacheKey> wrapKeys(List<FileDiffCacheKey> keys, RevWalk rw) {
+      List<AugmentedFileDiffCacheKey> result = new ArrayList<>();
+      for (FileDiffCacheKey key : keys) {
+        if (key.oldCommit().equals(EMPTY_TREE_ID)) {
+          result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
+          continue;
+        }
+        try {
+          RevCommit oldRevCommit = DiffUtil.getRevCommit(rw, key.oldCommit());
+          RevCommit newRevCommit = DiffUtil.getRevCommit(rw, key.newCommit());
+          if (!DiffUtil.areRelated(oldRevCommit, newRevCommit)) {
+            result.add(
+                AugmentedFileDiffCacheKey.builder()
+                    .key(key)
+                    .oldParentId(Optional.of(oldRevCommit.getParent(0).getId()))
+                    .newParentId(Optional.of(newRevCommit.getParent(0).getId()))
+                    .ignoreRebase(false)
+                    .build());
+          } else {
+            result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
+          }
+        } catch (IOException e) {
+          logger.atWarning().log(
+              "Failed to evaluate commits relation for key "
+                  + key
+                  + ". Skipping this key: "
+                  + e.getMessage(),
+              e);
+          result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
+        }
+      }
+      return result;
+    }
+
+    private static ImmutableList<TaggedEdit> asTaggedEdits(
+        List<Edit> normalEdits, List<Edit> rebaseEdits) {
+      Set<Edit> rebaseEditsSet = new HashSet<>(rebaseEdits);
+      ImmutableList.Builder<TaggedEdit> result =
+          ImmutableList.builderWithExpectedSize(normalEdits.size());
+      for (Edit e : normalEdits) {
+        result.add(TaggedEdit.create(e, rebaseEditsSet.contains(e)));
+      }
+      return result.build();
+    }
+
+    /**
+     * Computes the subset of edits that are due to rebase between 2 commits.
+     *
+     * <p>The input parameter {@link AllFileGitDiffs#mainDiff} contains all the edits in
+     * consideration. Of those, we identify the edits due to rebase as a function of:
+     *
+     * <ol>
+     *   <li>The edits between the old commit and its parent {@link
+     *       AllFileGitDiffs#oldVsParentDiff}.
+     *   <li>The edits between the new commit and its parent {@link
+     *       AllFileGitDiffs#newVsParentDiff}.
+     *   <li>The edits between the parents of the old commit and new commits {@link
+     *       AllFileGitDiffs#parentVsParentDiff}.
+     * </ol>
+     *
+     * @param diffs an entity containing 4 sets of edits: those between the old and new commit,
+     *     between the old and new commits vs. their parents, and between the old and new parents.
+     * @return the list of edits that are due to rebase.
+     */
+    private FileEdits computeRebaseEdits(AllFileGitDiffs diffs) {
+      if (!diffs.parentVsParentDiff().isPresent()) {
+        return FileEdits.empty();
+      }
+
+      GitFileDiff parentVsParentDiff = diffs.parentVsParentDiff().get().gitDiff();
+
+      EditTransformer editTransformer =
+          new EditTransformer(
+              ImmutableList.of(
+                  FileEdits.create(
+                      parentVsParentDiff.edits().stream().collect(toImmutableList()),
+                      parentVsParentDiff.oldPath(),
+                      parentVsParentDiff.newPath())));
+
+      if (diffs.oldVsParentDiff().isPresent()) {
+        GitFileDiff oldVsParDiff = diffs.oldVsParentDiff().get().gitDiff();
+        editTransformer.transformReferencesOfSideA(
+            ImmutableList.of(
+                FileEdits.create(
+                    oldVsParDiff.edits().stream().collect(toImmutableList()),
+                    oldVsParDiff.oldPath(),
+                    oldVsParDiff.newPath())));
+      }
+
+      if (diffs.newVsParentDiff().isPresent()) {
+        GitFileDiff newVsParDiff = diffs.newVsParentDiff().get().gitDiff();
+        editTransformer.transformReferencesOfSideB(
+            ImmutableList.of(
+                FileEdits.create(
+                    newVsParDiff.edits().stream().collect(toImmutableList()),
+                    newVsParDiff.oldPath(),
+                    newVsParDiff.newPath())));
+      }
+
+      Multimap<String, ContextAwareEdit> editsPerFilePath = editTransformer.getEditsPerFilePath();
+
+      if (editsPerFilePath.isEmpty()) {
+        return FileEdits.empty();
+      }
+
+      // editsPerFilePath is expected to have a single item representing the file
+      String filePath = editsPerFilePath.keys().iterator().next();
+      Collection<ContextAwareEdit> edits = editsPerFilePath.get(filePath);
+      return FileEdits.create(
+          edits.stream()
+              .map(ContextAwareEdit::toEdit)
+              .filter(Optional::isPresent)
+              .map(Optional::get)
+              .map(Edit::fromJGitEdit)
+              .collect(toImmutableList()),
+          edits.iterator().next().getOldFilePath(),
+          edits.iterator().next().getNewFilePath());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
new file mode 100644
index 0000000..a478fcf
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
@@ -0,0 +1,130 @@
+// 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.patch.filediff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.FileDiffKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Cache key for the {@link FileDiffCache}. */
+@AutoValue
+public abstract class FileDiffCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /** The 20 bytes SHA-1 commit ID of the old commit used in the diff. */
+  public abstract ObjectId oldCommit();
+
+  /** The 20 bytes SHA-1 commit ID of the new commit used in the diff. */
+  public abstract ObjectId newCommit();
+
+  /** File path identified by its name. */
+  public abstract String newFilePath();
+
+  /**
+   * Percentage score used to identify a file as a "rename". A special value of -1 means that the
+   * computation will ignore renames and rename detection will be disabled.
+   */
+  public abstract int renameScore();
+
+  /** The diff algorithm that should be used in the computation. */
+  public abstract DiffAlgorithm diffAlgorithm();
+
+  public abstract DiffPreferencesInfo.Whitespace whitespace();
+
+  /** Number of bytes that this entity occupies. */
+  public int weight() {
+    return stringSize(project().get())
+        + 20 * 2 // old and new commits
+        + stringSize(newFilePath())
+        + 4 // renameScore
+        + 4 // diffAlgorithm
+        + 4; // whitespace
+  }
+
+  public static FileDiffCacheKey.Builder builder() {
+    return new AutoValue_FileDiffCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract FileDiffCacheKey.Builder project(NameKey value);
+
+    public abstract FileDiffCacheKey.Builder oldCommit(ObjectId value);
+
+    public abstract FileDiffCacheKey.Builder newCommit(ObjectId value);
+
+    public abstract FileDiffCacheKey.Builder newFilePath(String value);
+
+    public abstract FileDiffCacheKey.Builder renameScore(int value);
+
+    public FileDiffCacheKey.Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract FileDiffCacheKey.Builder diffAlgorithm(DiffAlgorithm value);
+
+    public abstract FileDiffCacheKey.Builder whitespace(Whitespace value);
+
+    public abstract FileDiffCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<FileDiffCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(FileDiffCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          FileDiffKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setOldCommit(idConverter.toByteString(key.oldCommit()))
+              .setNewCommit(idConverter.toByteString(key.newCommit()))
+              .setFilePath(key.newFilePath())
+              .setRenameScore(key.renameScore())
+              .setDiffAlgorithm(key.diffAlgorithm().name())
+              .setWhitespace(key.whitespace().name())
+              .build());
+    }
+
+    @Override
+    public FileDiffCacheKey deserialize(byte[] in) {
+      FileDiffKeyProto proto = Protos.parseUnchecked(FileDiffKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return FileDiffCacheKey.builder()
+          .project(Project.nameKey(proto.getProject()))
+          .oldCommit(idConverter.fromByteString(proto.getOldCommit()))
+          .newCommit(idConverter.fromByteString(proto.getNewCommit()))
+          .newFilePath(proto.getFilePath())
+          .renameScore(proto.getRenameScore())
+          .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
+          .whitespace(Whitespace.valueOf(proto.getWhitespace()))
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
new file mode 100644
index 0000000..e7f47ef
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -0,0 +1,278 @@
+//  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.patch.filediff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.FileDiffOutputProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import java.io.Serializable;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** File diff for a single file path. Produced as output of the {@link FileDiffCache}. */
+@AutoValue
+public abstract class FileDiffOutput implements Serializable {
+  private static final long serialVersionUID = 1L;
+
+  /** The 20 bytes SHA-1 object ID of the old git commit used in the diff. */
+  public abstract ObjectId oldCommitId();
+
+  /** The 20 bytes SHA-1 object ID of the new git commit used in the diff. */
+  public abstract ObjectId newCommitId();
+
+  /** Comparison type of old and new commits: against another patchset, parent or auto-merge. */
+  public abstract ComparisonType comparisonType();
+
+  /**
+   * The file path at the old commit. Returns an empty Optional if {@link #changeType()} is equal to
+   * {@link ChangeType#ADDED}.
+   */
+  public abstract Optional<String> oldPath();
+
+  /**
+   * The file path at the new commit. Returns an empty optional if {@link #changeType()} is equal to
+   * {@link ChangeType#DELETED}.
+   */
+  public abstract Optional<String> newPath();
+
+  /** The change type of the underlying file, e.g. added, deleted, renamed, etc... */
+  public abstract Patch.ChangeType changeType();
+
+  /** The patch type of the underlying file, e.g. unified, binary , etc... */
+  public abstract Optional<Patch.PatchType> patchType();
+
+  /**
+   * A list of strings representation of the header lines of the {@link
+   * org.eclipse.jgit.patch.FileHeader} that is produced as output of the diff.
+   */
+  public abstract ImmutableList<String> headerLines();
+
+  /** The list of edits resulting from the diff hunks of the file. */
+  public abstract ImmutableList<TaggedEdit> edits();
+
+  /** The file size at the new commit. */
+  public abstract long size();
+
+  /** Difference in file size between the old and new commits. */
+  public abstract long sizeDelta();
+
+  /** A boolean indicating if all underlying edits of the file diff are due to rebase. */
+  public boolean allEditsDueToRebase() {
+    return !edits().isEmpty() && edits().stream().allMatch(TaggedEdit::dueToRebase);
+  }
+
+  /** Returns the number of inserted lines for the file diff. */
+  public int insertions() {
+    int ins = 0;
+    for (TaggedEdit e : edits()) {
+      if (!e.dueToRebase()) {
+        ins += e.edit().endB() - e.edit().beginB();
+      }
+    }
+    return ins;
+  }
+
+  /** Returns the number of deleted lines for the file diff. */
+  public int deletions() {
+    int del = 0;
+    for (TaggedEdit e : edits()) {
+      if (!e.dueToRebase()) {
+        del += e.edit().endA() - e.edit().beginA();
+      }
+    }
+    return del;
+  }
+
+  /** Returns an entity representing an unchanged file between two commits. */
+  public static FileDiffOutput empty(String filePath, ObjectId oldCommitId, ObjectId newCommitId) {
+    return builder()
+        .oldCommitId(oldCommitId)
+        .newCommitId(newCommitId)
+        .comparisonType(ComparisonType.againstOtherPatchSet()) // not important
+        .oldPath(Optional.empty())
+        .newPath(Optional.of(filePath))
+        .changeType(ChangeType.MODIFIED)
+        .headerLines(ImmutableList.of())
+        .edits(ImmutableList.of())
+        .size(0)
+        .sizeDelta(0)
+        .build();
+  }
+
+  /** Returns true if this entity represents an unchanged file between two commits. */
+  public boolean isEmpty() {
+    return headerLines().isEmpty() && edits().isEmpty();
+  }
+
+  public static Builder builder() {
+    return new AutoValue_FileDiffOutput.Builder();
+  }
+
+  public int weight() {
+    int result = 0;
+    if (oldPath().isPresent()) {
+      result += stringSize(oldPath().get());
+    }
+    if (newPath().isPresent()) {
+      result += stringSize(newPath().get());
+    }
+    result += 20 + 20; // old and new commit IDs
+    result += 4; // comparison type
+    result += 4; // changeType
+    if (patchType().isPresent()) {
+      result += 4;
+    }
+    result += 4 + 4; // insertions and deletions
+    result += 4 + 4; // size and size delta
+    result += 20 * edits().size(); // each edit is 4 Integers + boolean = 4 * 4 + 4 = 20
+    for (String s : headerLines()) {
+      s += stringSize(s);
+    }
+    return result;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder oldCommitId(ObjectId value);
+
+    public abstract Builder newCommitId(ObjectId value);
+
+    public abstract Builder comparisonType(ComparisonType value);
+
+    public abstract Builder oldPath(Optional<String> value);
+
+    public abstract Builder newPath(Optional<String> value);
+
+    public abstract Builder changeType(ChangeType value);
+
+    public abstract Builder patchType(Optional<PatchType> value);
+
+    public abstract Builder headerLines(ImmutableList<String> value);
+
+    public abstract Builder edits(ImmutableList<TaggedEdit> value);
+
+    public abstract Builder size(long value);
+
+    public abstract Builder sizeDelta(long value);
+
+    public abstract FileDiffOutput build();
+  }
+
+  public enum Serializer implements CacheSerializer<FileDiffOutput> {
+    INSTANCE;
+
+    private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(1);
+
+    private static final FieldDescriptor NEW_PATH_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(2);
+
+    private static final FieldDescriptor PATCH_TYPE_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(4);
+
+    @Override
+    public byte[] serialize(FileDiffOutput fileDiff) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      FileDiffOutputProto.Builder builder =
+          FileDiffOutputProto.newBuilder()
+              .setOldCommit(idConverter.toByteString(fileDiff.oldCommitId().toObjectId()))
+              .setNewCommit(idConverter.toByteString(fileDiff.newCommitId().toObjectId()))
+              .setComparisonType(fileDiff.comparisonType().toProto())
+              .setSize(fileDiff.size())
+              .setSizeDelta(fileDiff.sizeDelta())
+              .addAllHeaderLines(fileDiff.headerLines())
+              .setChangeType(fileDiff.changeType().name())
+              .addAllEdits(
+                  fileDiff.edits().stream()
+                      .map(
+                          e ->
+                              FileDiffOutputProto.TaggedEdit.newBuilder()
+                                  .setEdit(
+                                      FileDiffOutputProto.Edit.newBuilder()
+                                          .setBeginA(e.edit().beginA())
+                                          .setEndA(e.edit().endA())
+                                          .setBeginB(e.edit().beginB())
+                                          .setEndB(e.edit().endB())
+                                          .build())
+                                  .setDueToRebase(e.dueToRebase())
+                                  .build())
+                      .collect(Collectors.toList()));
+
+      if (fileDiff.oldPath().isPresent()) {
+        builder.setOldPath(fileDiff.oldPath().get());
+      }
+
+      if (fileDiff.newPath().isPresent()) {
+        builder.setNewPath(fileDiff.newPath().get());
+      }
+
+      if (fileDiff.patchType().isPresent()) {
+        builder.setPatchType(fileDiff.patchType().get().name());
+      }
+
+      return Protos.toByteArray(builder.build());
+    }
+
+    @Override
+    public FileDiffOutput deserialize(byte[] in) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      FileDiffOutputProto proto = Protos.parseUnchecked(FileDiffOutputProto.parser(), in);
+      FileDiffOutput.Builder builder = FileDiffOutput.builder();
+      builder
+          .oldCommitId(idConverter.fromByteString(proto.getOldCommit()))
+          .newCommitId(idConverter.fromByteString(proto.getNewCommit()))
+          .comparisonType(ComparisonType.fromProto(proto.getComparisonType()))
+          .size(proto.getSize())
+          .sizeDelta(proto.getSizeDelta())
+          .headerLines(proto.getHeaderLinesList().stream().collect(ImmutableList.toImmutableList()))
+          .changeType(ChangeType.valueOf(proto.getChangeType()))
+          .edits(
+              proto.getEditsList().stream()
+                  .map(
+                      e ->
+                          TaggedEdit.create(
+                              Edit.create(
+                                  e.getEdit().getBeginA(),
+                                  e.getEdit().getEndA(),
+                                  e.getEdit().getBeginB(),
+                                  e.getEdit().getEndB()),
+                              e.getDueToRebase()))
+                  .collect(ImmutableList.toImmutableList()));
+
+      if (proto.hasField(OLD_PATH_DESCRIPTOR)) {
+        builder.oldPath(Optional.of(proto.getOldPath()));
+      }
+      if (proto.hasField(NEW_PATH_DESCRIPTOR)) {
+        builder.newPath(Optional.of(proto.getNewPath()));
+      }
+      if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
+        builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
+      }
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java b/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
new file mode 100644
index 0000000..8eda234
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
@@ -0,0 +1,29 @@
+//  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.patch.filediff;
+
+import com.google.common.cache.Weigher;
+
+/**
+ * A weigher for the {@link FileDiffCache} key and value. This is used by the cache backend to
+ * assign weights for cache entries and is used for evictions.
+ */
+public class FileDiffWeigher implements Weigher<FileDiffCacheKey, FileDiffOutput> {
+
+  @Override
+  public int weigh(FileDiffCacheKey key, FileDiffOutput fileDiffOutput) {
+    return key.weight() + fileDiffOutput.weight();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileEdits.java b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
new file mode 100644
index 0000000..a009a02
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
@@ -0,0 +1,51 @@
+//  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.patch.filediff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.Optional;
+
+/**
+ * An entity class containing the list of edits between two commits for a file, and the old and new
+ * paths.
+ */
+@AutoValue
+public abstract class FileEdits {
+  public static FileEdits create(
+      ImmutableList<Edit> edits, Optional<String> oldPath, Optional<String> newPath) {
+    return new AutoValue_FileEdits(edits, oldPath, newPath);
+  }
+
+  public static FileEdits createFromJgitEdits(
+      ImmutableList<org.eclipse.jgit.diff.Edit> jgitEdits,
+      Optional<String> oldPath,
+      Optional<String> newPath) {
+    return new AutoValue_FileEdits(
+        jgitEdits.stream().map(Edit::fromJGitEdit).collect(toImmutableList()), oldPath, newPath);
+  }
+
+  public abstract ImmutableList<Edit> edits();
+
+  public abstract Optional<String> oldPath();
+
+  public abstract Optional<String> newPath();
+
+  public static FileEdits empty() {
+    return new AutoValue_FileEdits(ImmutableList.of(), Optional.empty(), Optional.empty());
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/FileSizeEvaluator.java b/java/com/google/gerrit/server/patch/filediff/FileSizeEvaluator.java
new file mode 100644
index 0000000..e2c1bc5
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/FileSizeEvaluator.java
@@ -0,0 +1,97 @@
+// 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.patch.filediff;
+
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.FileMode;
+import com.google.gerrit.exceptions.StorageException;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Optional;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/** Helper class for computing the size of a file in a given git tree. */
+class FileSizeEvaluator {
+  private final ObjectReader reader;
+  private final RevTree tree;
+
+  FileSizeEvaluator(ObjectReader reader, RevTree tree) {
+    this.reader = reader;
+    this.tree = tree;
+  }
+
+  /**
+   * Computes the file size identified by the {@code path} parameter at the given git tree
+   * identified by {@code gitTreeId}.
+   */
+  long compute(AbbreviatedObjectId gitTreeId, Patch.FileMode mode, String path) throws IOException {
+    if (!isBlob(mode)) {
+      return 0;
+    }
+    ObjectId fileId =
+        toObjectId(reader, gitTreeId).orElseGet(() -> lookupObjectId(reader, path, tree));
+    if (ObjectId.zeroId().equals(fileId)) {
+      return 0;
+    }
+    return reader.getObjectSize(fileId, OBJ_BLOB);
+  }
+
+  private static ObjectId lookupObjectId(ObjectReader reader, String path, RevTree tree) {
+    // This variant is very expensive.
+    try (TreeWalk treeWalk = TreeWalk.forPath(reader, path, tree)) {
+      return treeWalk != null ? treeWalk.getObjectId(0) : ObjectId.zeroId();
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private static Optional<ObjectId> toObjectId(
+      ObjectReader reader, @Nullable AbbreviatedObjectId abbreviatedId) throws IOException {
+    if (abbreviatedId == null) {
+      // In theory, DiffEntry#getOldId or DiffEntry#getNewId can be null for pure renames or pure
+      // mode changes (e.g. DiffEntry#modify doesn't set the IDs). However, the method we call for
+      // diffs (DiffFormatter#scan) seems to always produce DiffEntries with set IDs, even for pure
+      // renames.
+      return Optional.empty();
+    }
+    if (abbreviatedId.isComplete()) {
+      // With the current JGit version and the method we call for diffs (DiffFormatter#scan),
+      // this
+      // is the only code path taken right now.
+      return Optional.ofNullable(abbreviatedId.toObjectId());
+    }
+    Collection<ObjectId> objectIds = reader.resolve(abbreviatedId);
+    // It seems very unlikely that an ObjectId which was just abbreviated by the diff
+    // computation
+    // now can't be resolved to exactly one ObjectId. The API allows this possibility, though.
+    return objectIds.size() == 1
+        ? Optional.of(Iterables.getOnlyElement(objectIds))
+        : Optional.empty();
+  }
+
+  private static boolean isBlob(Patch.FileMode mode) {
+    return mode.equals(FileMode.REGULAR_FILE)
+        || mode.equals(FileMode.EXECUTABLE_FILE)
+        || mode.equals(FileMode.SYMLINK);
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/filediff/GitDiffEntity.java b/java/com/google/gerrit/server/patch/filediff/GitDiffEntity.java
new file mode 100644
index 0000000..2ca8fa6
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/GitDiffEntity.java
@@ -0,0 +1,31 @@
+// 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.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheKey;
+
+/** An entity containing a {@link GitFileDiffCacheKey} and its loaded value {@link GitFileDiff}. */
+@AutoValue
+abstract class GitDiffEntity {
+  public static GitDiffEntity create(GitFileDiffCacheKey gitKey, GitFileDiff gitDiff) {
+    return new AutoValue_GitDiffEntity(gitKey, gitDiff);
+  }
+
+  abstract GitFileDiffCacheKey gitKey();
+
+  abstract GitFileDiff gitDiff();
+}
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
similarity index 88%
rename from java/com/google/gerrit/server/patch/PatchListLoader.java
rename to java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
index be0895b..017e276 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
@@ -1,19 +1,21 @@
-// Copyright (C) 2009 The Android Open Source Project
+//  Copyright (C) 2020 The Android Open Source Project
 //
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
 //
-// http://www.apache.org/licenses/LICENSE-2.0
+//  http://www.apache.org/licenses/LICENSE-2.0
 //
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+//
+//
 //
 
-package com.google.gerrit.server.patch;
+package com.google.gerrit.server.patch.filediff;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.collect.ImmutableList.toImmutableList;
@@ -38,7 +40,17 @@
 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.EditTransformer.ContextAwareEdit;
+import com.google.gerrit.server.patch.AutoMerger;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.DiffExecutor;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListCacheImpl;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.patch.filediff.EditTransformer.ContextAwareEdit;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -81,7 +93,7 @@
 import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 public class PatchListLoader implements Callable<PatchList> {
-  static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
     PatchListLoader create(PatchListKey key, Project.NameKey project);
@@ -95,7 +107,6 @@
   private final PatchListKey key;
   private final Project.NameKey project;
   private final long timeoutMillis;
-  private final boolean save;
 
   @Inject
   PatchListLoader(
@@ -121,13 +132,12 @@
             "timeout",
             TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
             TimeUnit.MILLISECONDS);
-    save = AutoMerger.cacheAutomerge(cfg);
   }
 
   @Override
   public PatchList call() throws IOException, PatchListNotAvailableException {
     try (Repository repo = repoManager.openRepository(project);
-        ObjectInserter ins = newInserter(repo);
+        InMemoryInserter ins = new InMemoryInserter(repo);
         ObjectReader reader = ins.newReader();
         RevWalk rw = new RevWalk(reader)) {
       return readPatchList(repo, rw, ins);
@@ -151,11 +161,7 @@
     }
   }
 
-  private ObjectInserter newInserter(Repository repo) {
-    return save ? repo.newObjectInserter() : new InMemoryInserter(repo);
-  }
-
-  private PatchList readPatchList(Repository repo, RevWalk rw, ObjectInserter ins)
+  private PatchList readPatchList(Repository repo, RevWalk rw, InMemoryInserter ins)
       throws IOException, PatchListNotAvailableException {
     ObjectReader reader = rw.getObjectReader();
     checkArgument(reader.getCreatedFromInserter() == ins);
@@ -306,13 +312,39 @@
         getRelevantPatchListEntries(
             parentDiffEntries, parentCommitA, parentCommitB, touchedFilePaths, df);
 
-    EditTransformer editTransformer = new EditTransformer(parentPatchListEntries);
-    editTransformer.transformReferencesOfSideA(oldPatches);
-    editTransformer.transformReferencesOfSideB(newPatches);
+    EditTransformer editTransformer = new EditTransformer(toFileEditsList(parentPatchListEntries));
+    editTransformer.transformReferencesOfSideA(toFileEditsList(oldPatches));
+    editTransformer.transformReferencesOfSideB(toFileEditsList(newPatches));
     return EditsDueToRebaseResult.create(
         relevantDiffEntries, editTransformer.getEditsPerFilePath());
   }
 
+  private ImmutableList<FileEdits> toFileEditsList(List<PatchListEntry> entries) {
+    return entries.stream().map(PatchListLoader::toFileEdits).collect(toImmutableList());
+  }
+
+  private static FileEdits toFileEdits(PatchListEntry patchListEntry) {
+    Optional<String> oldName = Optional.empty();
+    Optional<String> newName = Optional.empty();
+    switch (patchListEntry.getChangeType()) {
+      case DELETED:
+        oldName = Optional.of(patchListEntry.getNewName());
+        break;
+      case ADDED:
+      case MODIFIED:
+      case REWRITE:
+        newName = Optional.of(patchListEntry.getNewName());
+        break;
+
+      case COPIED:
+      case RENAMED:
+        oldName = Optional.of(patchListEntry.getOldName());
+        newName = Optional.of(patchListEntry.getNewName());
+        break;
+    }
+    return FileEdits.createFromJgitEdits(patchListEntry.getEdits(), oldName, newName);
+  }
+
   private static boolean isRootOrMergeCommit(RevCommit commit) {
     return commit.getParentCount() != 1;
   }
@@ -405,7 +437,7 @@
     PatchListEntry patchListEntry =
         newEntry(treeA, fileHeader, contentEditsDueToRebase, newSize, newSize - oldSize);
     // All edits in a file are due to rebase -> exclude the file from the diff.
-    if (EditTransformer.toEdits(patchListEntry).allMatch(editsDueToRebase::contains)) {
+    if (EditTransformer.toEdits(toFileEdits(patchListEntry)).allMatch(editsDueToRebase::contains)) {
       return Optional.empty();
     }
     return Optional.of(patchListEntry);
@@ -596,7 +628,7 @@
   }
 
   private RevObject aFor(
-      PatchListKey key, Repository repo, RevWalk rw, ObjectInserter ins, RevCommit b)
+      PatchListKey key, Repository repo, RevWalk rw, InMemoryInserter ins, RevCommit b)
       throws IOException {
     if (key.getOldId() != null) {
       return rw.parseAny(key.getOldId());
@@ -611,15 +643,16 @@
           rw.parseBody(r);
           return r;
         }
-      case 2:
+      default:
         if (key.getParentNum() != null) {
           RevCommit r = b.getParent(key.getParentNum() - 1);
           rw.parseBody(r);
           return r;
         }
-        return autoMerger.merge(repo, rw, ins, b, mergeStrategy);
-      default:
-        // TODO(sop) handle an octopus merge.
+        // Only support auto-merge for 2 parents, not octopus merges
+        if (b.getParentCount() == 2) {
+          return autoMerger.lookupFromGitOrMergeInMemory(repo, rw, ins, b, mergeStrategy);
+        }
         return null;
     }
   }
diff --git a/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
new file mode 100644
index 0000000..3720680
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
@@ -0,0 +1,37 @@
+//  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.patch.filediff;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * An entity class encapsulating a JGit {@link Edit} along with extra attributes (e.g. identifying a
+ * rebase edit).
+ */
+@AutoValue
+public abstract class TaggedEdit {
+
+  public static TaggedEdit create(Edit edit, boolean dueToRebase) {
+    return new AutoValue_TaggedEdit(edit, dueToRebase);
+  }
+
+  public abstract Edit edit();
+
+  public org.eclipse.jgit.diff.Edit jgitEdit() {
+    return Edit.toJGitEdit(edit());
+  }
+
+  public abstract boolean dueToRebase();
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
new file mode 100644
index 0000000..d178f22
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
@@ -0,0 +1,40 @@
+//  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.patch.gitdiff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+
+/**
+ * A cache interface for identifying the list of Git modified files between 2 different git trees.
+ * This cache does not read the actual file contents, nor does it include the edits (modified
+ * regions) of the file.
+ *
+ * <p>The other {@link ModifiedFilesCache} is similar to this cache, and includes other extra Gerrit
+ * logic that we need to add with the list of modified files.
+ */
+public interface GitModifiedFilesCache {
+
+  /**
+   * Computes the list of of {@link ModifiedFile}s between the 2 git trees.
+   *
+   * @param key used to identify two git trees and contains other attributes to control the diff
+   *     calculation.
+   * @return the list of {@link ModifiedFile}s between the 2 git trees identified by the key.
+   * @throws DiffNotAvailableException trees cannot be read or file contents cannot be read.
+   */
+  ImmutableList<ModifiedFile> get(GitModifiedFilesCacheKey key) throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
new file mode 100644
index 0000000..b3b82bb
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
@@ -0,0 +1,177 @@
+//  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.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;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+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.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+
+/** Implementation of the {@link GitModifiedFilesCache} */
+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;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(GitModifiedFilesCache.class).to(GitModifiedFilesCacheImpl.class);
+
+        persist(
+                GIT_MODIFIED_FILES,
+                GitModifiedFilesCacheKey.class,
+                new TypeLiteral<ImmutableList<ModifiedFile>>() {})
+            .keySerializer(GitModifiedFilesCacheKey.Serializer.INSTANCE)
+            .valueSerializer(ValueSerializer.INSTANCE)
+            // The documentation has some defaults and recommendations for setting the cache
+            // attributes:
+            // https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.
+            .maximumWeight(10 << 20)
+            .weigher(GitModifiedFilesWeigher.class)
+            // The cache is using the default disk limit as per section cache.<name>.diskLimit
+            // in the cache documentation link.
+            .version(1)
+            .loader(GitModifiedFilesCacheImpl.Loader.class);
+      }
+    };
+  }
+
+  @Inject
+  public GitModifiedFilesCacheImpl(
+      @Named(GIT_MODIFIED_FILES)
+          LoadingCache<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public ImmutableList<ModifiedFile> get(GitModifiedFilesCacheKey key)
+      throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class Loader extends CacheLoader<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    Loader(GitRepositoryManager repoManager) {
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    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());
+        if (key.renameDetection()) {
+          df.setDetectRenames(true);
+          df.getRenameDetector().setRenameScore(key.renameScore());
+        }
+        // The scan method only returns the file paths that are different. Callers may choose to
+        // format these paths themselves.
+        return df.scan(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>> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(ImmutableList<ModifiedFile> modifiedFiles) {
+      ModifiedFilesProto.Builder builder = ModifiedFilesProto.newBuilder();
+      modifiedFiles.forEach(
+          f -> builder.addModifiedFile(ModifiedFile.Serializer.INSTANCE.toProto(f)));
+      return Protos.toByteArray(builder.build());
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> deserialize(byte[] in) {
+      ImmutableList.Builder<ModifiedFile> modifiedFiles = ImmutableList.builder();
+      ModifiedFilesProto modifiedFilesProto =
+          Protos.parseUnchecked(ModifiedFilesProto.parser(), in);
+      modifiedFilesProto
+          .getModifiedFileList()
+          .forEach(f -> modifiedFiles.add(ModifiedFile.Serializer.INSTANCE.fromProto(f)));
+      return modifiedFiles.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
new file mode 100644
index 0000000..fb8fce1
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
@@ -0,0 +1,129 @@
+// 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.patch.gitdiff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.GitModifiedFilesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.DiffUtil;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Cache key for the {@link GitModifiedFilesCache}. */
+@AutoValue
+public abstract class GitModifiedFilesCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /**
+   * The git SHA-1 {@link ObjectId} of the first git tree object for which the diff should be
+   * computed.
+   */
+  public abstract ObjectId aTree();
+
+  /**
+   * The git SHA-1 {@link ObjectId} of the second git tree object for which the diff should be
+   * computed.
+   */
+  public abstract ObjectId bTree();
+
+  /**
+   * Percentage score used to identify a file as a rename. This value is only available if {@link
+   * #renameDetection()} is true. Otherwise, this method will return -1.
+   *
+   * <p>This value will be used to set the rename score of {@link
+   * org.eclipse.jgit.diff.DiffFormatter#getRenameDetector()}.
+   */
+  public abstract int renameScore();
+
+  /** Returns true if rename detection was set for this key. */
+  public boolean renameDetection() {
+    return renameScore() != -1;
+  }
+
+  public static GitModifiedFilesCacheKey create(
+      Project.NameKey project, ObjectId aCommit, ObjectId bCommit, int renameScore, RevWalk rw)
+      throws IOException {
+    ObjectId aTree = DiffUtil.getTreeId(rw, aCommit);
+    ObjectId bTree = DiffUtil.getTreeId(rw, bCommit);
+    return builder().project(project).aTree(aTree).bTree(bTree).renameScore(renameScore).build();
+  }
+
+  public static Builder builder() {
+    return new AutoValue_GitModifiedFilesCacheKey.Builder();
+  }
+
+  /** Returns the size of the object in bytes */
+  public int weight() {
+    return stringSize(project().get())
+        + 20 * 2 // old and new tree IDs
+        + 4; // rename score
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder project(NameKey value);
+
+    public abstract Builder aTree(ObjectId value);
+
+    public abstract Builder bTree(ObjectId value);
+
+    public abstract Builder renameScore(int value);
+
+    public Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract GitModifiedFilesCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<GitModifiedFilesCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(GitModifiedFilesCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          GitModifiedFilesKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setATree(idConverter.toByteString(key.aTree()))
+              .setBTree(idConverter.toByteString(key.bTree()))
+              .setRenameScore(key.renameScore())
+              .build());
+    }
+
+    @Override
+    public GitModifiedFilesCacheKey deserialize(byte[] in) {
+      GitModifiedFilesKeyProto proto = Protos.parseUnchecked(GitModifiedFilesKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return GitModifiedFilesCacheKey.builder()
+          .project(NameKey.parse(proto.getProject()))
+          .aTree(idConverter.fromByteString(proto.getATree()))
+          .bTree(idConverter.fromByteString(proto.getBTree()))
+          .renameScore(proto.getRenameScore())
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
new file mode 100644
index 0000000..a678379
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
@@ -0,0 +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.patch.gitdiff;
+
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+
+public class GitModifiedFilesWeigher
+    implements Weigher<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+  @Override
+  public int weigh(GitModifiedFilesCacheKey key, ImmutableList<ModifiedFile> modifiedFiles) {
+    return key.weight() + modifiedFiles.stream().mapToInt(ModifiedFile::weight).sum();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
new file mode 100644
index 0000000..9512094
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -0,0 +1,123 @@
+//  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.patch.gitdiff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFileProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import java.util.Optional;
+
+/**
+ * An entity representing a Modified file due to a diff between 2 git trees. This entity contains
+ * the change type and the old & new paths, but does not include any actual content diff of the
+ * file.
+ */
+@AutoValue
+public abstract class ModifiedFile {
+  /**
+   * Returns the change type (i.e. add, delete, modify, rename, etc...) associated with this
+   * modified file.
+   */
+  public abstract ChangeType changeType();
+
+  /**
+   * Returns the old name associated with this file. An empty optional is returned if {@link
+   * #changeType()} is equal to {@link ChangeType#ADDED}.
+   */
+  public abstract Optional<String> oldPath();
+
+  /**
+   * Returns the new name associated with this file. An empty optional is returned if {@link
+   * #changeType()} is equal to {@link ChangeType#DELETED}
+   */
+  public abstract Optional<String> newPath();
+
+  public static Builder builder() {
+    return new AutoValue_ModifiedFile.Builder();
+  }
+
+  /** Computes this object's weight, which is its size in bytes. */
+  public int weight() {
+    int weight = 1; // the changeType field
+    if (oldPath().isPresent()) {
+      weight += oldPath().get().length();
+    }
+    if (newPath().isPresent()) {
+      weight += newPath().get().length();
+    }
+    return weight;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder changeType(ChangeType value);
+
+    public abstract Builder oldPath(Optional<String> value);
+
+    public abstract Builder newPath(Optional<String> value);
+
+    public abstract ModifiedFile build();
+  }
+
+  enum Serializer implements CacheSerializer<ModifiedFile> {
+    INSTANCE;
+
+    private static final FieldDescriptor oldPathDescriptor =
+        ModifiedFileProto.getDescriptor().findFieldByNumber(2);
+
+    private static final FieldDescriptor newPathDescriptor =
+        ModifiedFileProto.getDescriptor().findFieldByNumber(3);
+
+    @Override
+    public byte[] serialize(ModifiedFile modifiedFile) {
+      return Protos.toByteArray(toProto(modifiedFile));
+    }
+
+    public ModifiedFileProto toProto(ModifiedFile modifiedFile) {
+      ModifiedFileProto.Builder builder = ModifiedFileProto.newBuilder();
+      builder.setChangeType(modifiedFile.changeType().toString());
+      if (modifiedFile.oldPath().isPresent()) {
+        builder.setOldPath(modifiedFile.oldPath().get());
+      }
+      if (modifiedFile.newPath().isPresent()) {
+        builder.setNewPath(modifiedFile.newPath().get());
+      }
+      return builder.build();
+    }
+
+    @Override
+    public ModifiedFile deserialize(byte[] in) {
+      ModifiedFileProto modifiedFileProto = Protos.parseUnchecked(ModifiedFileProto.parser(), in);
+      return fromProto(modifiedFileProto);
+    }
+
+    public ModifiedFile fromProto(ModifiedFileProto modifiedFileProto) {
+      ModifiedFile.Builder builder = ModifiedFile.builder();
+      builder.changeType(ChangeType.valueOf(modifiedFileProto.getChangeType()));
+
+      if (modifiedFileProto.hasField(oldPathDescriptor)) {
+        builder.oldPath(Optional.of(modifiedFileProto.getOldPath()));
+      }
+      if (modifiedFileProto.hasField(newPathDescriptor)) {
+        builder.newPath(Optional.of(modifiedFileProto.getNewPath()));
+      }
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
new file mode 100644
index 0000000..7454f81
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
@@ -0,0 +1,198 @@
+//  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.patch.gitfilediff;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.PatchType;
+import java.util.Optional;
+import org.eclipse.jgit.patch.CombinedFileHeader;
+import org.eclipse.jgit.patch.FileHeader;
+import org.eclipse.jgit.util.IntList;
+import org.eclipse.jgit.util.RawParseUtils;
+
+/** A utility class for the {@link FileHeader} JGit object */
+public class FileHeaderUtil {
+  private static final Byte NUL = '\0';
+
+  /**
+   * The maximum number of characters to lookup in the binary file {@link FileHeader}. This is used
+   * to scan the file header for the occurrence of the {@link #NUL} character.
+   *
+   * <p>This limit assumes a uniform distribution of all characters, hence the probability of the
+   * occurrence of each character = (1 / 256). We want to find the limit that makes the prob. of
+   * finding {@link #NUL} > 0.999. 1 - (255 / 256) ^ N > 0.999 yields N = 1766. We set the limit to
+   * this value multiplied by 10 for more confidence.
+   */
+  private static final int BIN_FILE_MAX_SCAN_LIMIT = 20000;
+
+  /** Converts the {@link FileHeader} parameter to a String representation. */
+  static String toString(FileHeader header) {
+    return new String(FileHeaderUtil.toByteArray(header), UTF_8);
+  }
+
+  /** Converts the {@link FileHeader} parameter to a byte array. */
+  static byte[] toByteArray(FileHeader header) {
+    int end = getEndOffset(header);
+    if (header.getStartOffset() == 0 && end == header.getBuffer().length) {
+      return header.getBuffer();
+    }
+
+    final byte[] buf = new byte[end - header.getStartOffset()];
+    System.arraycopy(header.getBuffer(), header.getStartOffset(), buf, 0, buf.length);
+    return buf;
+  }
+
+  /** Splits the {@code FileHeader} string to a list of strings, one string per header line. */
+  public static ImmutableList<String> getHeaderLines(FileHeader fileHeader) {
+    String fileHeaderString = toString(fileHeader);
+    return getHeaderLines(fileHeaderString);
+  }
+
+  public static ImmutableList<String> getHeaderLines(String header) {
+    return getHeaderLines(header.getBytes(UTF_8));
+  }
+
+  static ImmutableList<String> getHeaderLines(byte[] header) {
+    final IntList lineStartOffsets = RawParseUtils.lineMap(header, 0, header.length);
+    final ImmutableList.Builder<String> headerLines =
+        ImmutableList.builderWithExpectedSize(lineStartOffsets.size() - 1);
+    for (int i = 1; i < lineStartOffsets.size() - 1; i++) {
+      final int b = lineStartOffsets.get(i);
+      int e = lineStartOffsets.get(i + 1);
+      if (header[e - 1] == '\n') {
+        e--;
+      }
+      headerLines.add(RawParseUtils.decode(UTF_8, header, b, e));
+    }
+    return headerLines.build();
+  }
+
+  /**
+   * Returns the old file path associated with the {@link FileHeader}, or empty if the file is
+   * {@link com.google.gerrit.entities.Patch.ChangeType#ADDED} or {@link
+   * com.google.gerrit.entities.Patch.ChangeType#REWRITE}.
+   */
+  public static Optional<String> getOldPath(FileHeader header) {
+    Patch.ChangeType changeType = getChangeType(header);
+    switch (changeType) {
+      case DELETED:
+      case COPIED:
+      case RENAMED:
+      case MODIFIED:
+        return Optional.of(header.getOldPath());
+
+      case ADDED:
+      case REWRITE:
+        return Optional.empty();
+    }
+    return Optional.empty();
+  }
+
+  /**
+   * Returns the new file path associated with the {@link FileHeader}, or empty if the file is
+   * {@link com.google.gerrit.entities.Patch.ChangeType#DELETED}.
+   */
+  public static Optional<String> getNewPath(FileHeader header) {
+    Patch.ChangeType changeType = getChangeType(header);
+    switch (changeType) {
+      case DELETED:
+        return Optional.empty();
+
+      case ADDED:
+      case MODIFIED:
+      case REWRITE:
+      case COPIED:
+      case RENAMED:
+        return Optional.of(header.getNewPath());
+    }
+    return Optional.empty();
+  }
+
+  /** Returns the change type associated with the file header. */
+  public static Patch.ChangeType getChangeType(FileHeader header) {
+    // In Gerrit, we define our own entities  of the JGit entities, so that we have full control
+    // over their behaviors (e.g. making sure that these entities are immutable so that we can add
+    // them as fields of keys / values of persisted caches).
+
+    // TODO(ghareeb): remove the dead code of the value REWRITE and all its handling
+    switch (header.getChangeType()) {
+      case ADD:
+        return Patch.ChangeType.ADDED;
+      case MODIFY:
+        return Patch.ChangeType.MODIFIED;
+      case DELETE:
+        return Patch.ChangeType.DELETED;
+      case RENAME:
+        return Patch.ChangeType.RENAMED;
+      case COPY:
+        return Patch.ChangeType.COPIED;
+      default:
+        throw new IllegalArgumentException("Unsupported type " + header.getChangeType());
+    }
+  }
+
+  public static PatchType getPatchType(FileHeader header) {
+    PatchType patchType;
+
+    switch (header.getPatchType()) {
+      case UNIFIED:
+        patchType = Patch.PatchType.UNIFIED;
+        break;
+      case GIT_BINARY:
+      case BINARY:
+        patchType = Patch.PatchType.BINARY;
+        break;
+      default:
+        throw new IllegalArgumentException("Unsupported type " + header.getPatchType());
+    }
+
+    if (patchType != PatchType.BINARY) {
+      byte[] buf = header.getBuffer();
+      // TODO(ghareeb): should we adjust the max limit threshold?
+      // JGit sometimes misses the detection of binary files. In this case we look into the file
+      // header for the occurrence of NUL characters, which is a definite signal that the file is
+      // binary. We limit the number of characters to lookup to avoid performance bottlenecks.
+      for (int ptr = header.getStartOffset();
+          ptr < Math.min(header.getEndOffset(), BIN_FILE_MAX_SCAN_LIMIT);
+          ptr++) {
+        if (buf[ptr] == NUL) {
+          // It's really binary, but Git couldn't see the nul early enough to realize its binary,
+          // and instead produced the diff.
+          //
+          // Force it to be a binary; it really should have been that.
+          return PatchType.BINARY;
+        }
+      }
+    }
+    return patchType;
+  }
+
+  /**
+   * Returns the end offset of the diff header line of the {@code FileHeader parameter} before the
+   * appearance of any file edits (diff hunks).
+   */
+  private static int getEndOffset(FileHeader fileHeader) {
+    if (fileHeader instanceof CombinedFileHeader) {
+      return fileHeader.getEndOffset();
+    }
+    if (!fileHeader.getHunks().isEmpty()) {
+      return fileHeader.getHunks().get(0).getStartOffset();
+    }
+    return fileHeader.getEndOffset();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
new file mode 100644
index 0000000..e1af81d
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -0,0 +1,285 @@
+//  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.patch.gitfilediff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.GitFileDiffProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.patch.FileHeader;
+
+/**
+ * Entity representing a modified file (added, deleted, modified, renamed, etc...) between two
+ * different git commits.
+ */
+@AutoValue
+public abstract class GitFileDiff {
+  private static final Map<FileMode, Patch.FileMode> fileModeMap =
+      ImmutableMap.<FileMode, Patch.FileMode>builder()
+          .put(FileMode.TREE, Patch.FileMode.TREE)
+          .put(FileMode.SYMLINK, Patch.FileMode.SYMLINK)
+          .put(FileMode.GITLINK, Patch.FileMode.GITLINK)
+          .put(FileMode.REGULAR_FILE, Patch.FileMode.REGULAR_FILE)
+          .put(FileMode.EXECUTABLE_FILE, Patch.FileMode.EXECUTABLE_FILE)
+          .put(FileMode.MISSING, Patch.FileMode.MISSING)
+          .build();
+
+  private static Patch.FileMode mapFileMode(FileMode jgitFileMode) {
+    if (!fileModeMap.containsKey(jgitFileMode)) {
+      throw new IllegalArgumentException("Unsupported type " + jgitFileMode);
+    }
+    return fileModeMap.get(jgitFileMode);
+  }
+
+  /**
+   * Creates a {@link GitFileDiff} using the {@code diffEntry} and the {@code diffFormatter}
+   * parameters.
+   */
+  static GitFileDiff create(DiffEntry diffEntry, DiffFormatter diffFormatter) throws IOException {
+    FileHeader fileHeader = diffFormatter.toFileHeader(diffEntry);
+    ImmutableList<Edit> edits =
+        fileHeader.toEditList().stream().map(Edit::fromJGitEdit).collect(toImmutableList());
+
+    return builder()
+        .edits(edits)
+        .oldId(diffEntry.getOldId())
+        .newId(diffEntry.getNewId())
+        .fileHeader(FileHeaderUtil.toString(fileHeader))
+        .oldPath(FileHeaderUtil.getOldPath(fileHeader))
+        .newPath(FileHeaderUtil.getNewPath(fileHeader))
+        .changeType(FileHeaderUtil.getChangeType(fileHeader))
+        .patchType(Optional.of(FileHeaderUtil.getPatchType(fileHeader)))
+        .oldMode(Optional.of(mapFileMode(diffEntry.getOldMode())))
+        .newMode(Optional.of(mapFileMode(diffEntry.getNewMode())))
+        .build();
+  }
+
+  /**
+   * Represents an empty file diff, which means that the file was not modified between the two git
+   * trees identified by {@link #oldId()} and {@link #newId()}.
+   *
+   * @param newFilePath the file name at the {@link #newId()} git tree.
+   */
+  static GitFileDiff empty(
+      AbbreviatedObjectId oldId, AbbreviatedObjectId newId, String newFilePath) {
+    return builder()
+        .oldId(oldId)
+        .newId(newId)
+        .newPath(Optional.of(newFilePath))
+        .changeType(ChangeType.MODIFIED)
+        .edits(ImmutableList.of())
+        .fileHeader("")
+        .build();
+  }
+
+  /** An {@link ImmutableList} of the modified regions in the file. */
+  public abstract ImmutableList<Edit> edits();
+
+  /** A string representation of the {@link org.eclipse.jgit.patch.FileHeader}. */
+  public abstract String fileHeader();
+
+  /** The file name at the old git tree identified by {@link #oldId()} */
+  public abstract Optional<String> oldPath();
+
+  /** The file name at the new git tree identified by {@link #newId()} */
+  public abstract Optional<String> newPath();
+
+  /** The 20 bytes SHA-1 object ID of the old git tree of the diff. */
+  public abstract AbbreviatedObjectId oldId();
+
+  /** The 20 bytes SHA-1 object ID of the new git tree of the diff. */
+  public abstract AbbreviatedObjectId newId();
+
+  /** The file mode of the old file at the old git tree diff identified by {@link #oldId()}. */
+  public abstract Optional<Patch.FileMode> oldMode();
+
+  /** The file mode of the new file at the new git tree diff identified by {@link #newId()}. */
+  public abstract Optional<Patch.FileMode> newMode();
+
+  /** The change type associated with the file. */
+  public abstract ChangeType changeType();
+
+  /** The patch type associated with the file. */
+  public abstract Optional<PatchType> patchType();
+
+  /**
+   * Returns true if the object was created using the {@link #empty(AbbreviatedObjectId,
+   * AbbreviatedObjectId, String)} method.
+   */
+  public boolean isEmpty() {
+    return edits().isEmpty();
+  }
+
+  /** Returns the size of the object in bytes. */
+  public int weight() {
+    int result = 20 * 2; // oldId and newId
+    result += 16 * edits().size(); // each edit contains 4 integers (hence 16 bytes)
+    result += stringSize(fileHeader());
+    if (oldPath().isPresent()) {
+      result += stringSize(oldPath().get());
+    }
+    if (newPath().isPresent()) {
+      result += stringSize(newPath().get());
+    }
+    result += 4;
+    if (patchType().isPresent()) {
+      result += 4;
+    }
+    if (oldMode().isPresent()) {
+      result += 4;
+    }
+    if (newMode().isPresent()) {
+      result += 4;
+    }
+    return result;
+  }
+
+  public static Builder builder() {
+    return new AutoValue_GitFileDiff.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder edits(ImmutableList<Edit> value);
+
+    public abstract Builder fileHeader(String value);
+
+    public abstract Builder oldPath(Optional<String> value);
+
+    public abstract Builder newPath(Optional<String> value);
+
+    public abstract Builder oldId(AbbreviatedObjectId value);
+
+    public abstract Builder newId(AbbreviatedObjectId value);
+
+    public abstract Builder oldMode(Optional<Patch.FileMode> value);
+
+    public abstract Builder newMode(Optional<Patch.FileMode> value);
+
+    public abstract Builder changeType(ChangeType value);
+
+    public abstract Builder patchType(Optional<PatchType> value);
+
+    public abstract GitFileDiff build();
+  }
+
+  public enum Serializer implements CacheSerializer<GitFileDiff> {
+    INSTANCE;
+
+    private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(3);
+
+    private static final FieldDescriptor NEW_PATH_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(4);
+
+    private static final FieldDescriptor OLD_MODE_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(7);
+
+    private static final FieldDescriptor NEW_MODE_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(8);
+
+    private static final FieldDescriptor PATCH_TYPE_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(10);
+
+    @Override
+    public byte[] serialize(GitFileDiff gitFileDiff) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      GitFileDiffProto.Builder builder =
+          GitFileDiffProto.newBuilder()
+              .setFileHeader(gitFileDiff.fileHeader())
+              .setOldId(idConverter.toByteString(gitFileDiff.oldId().toObjectId()))
+              .setNewId(idConverter.toByteString(gitFileDiff.newId().toObjectId()))
+              .setChangeType(gitFileDiff.changeType().name());
+      gitFileDiff
+          .edits()
+          .forEach(
+              e ->
+                  builder.addEdits(
+                      GitFileDiffProto.Edit.newBuilder()
+                          .setBeginA(e.beginA())
+                          .setEndA(e.endA())
+                          .setBeginB(e.beginB())
+                          .setEndB(e.endB())));
+      if (gitFileDiff.oldPath().isPresent()) {
+        builder.setOldPath(gitFileDiff.oldPath().get());
+      }
+      if (gitFileDiff.newPath().isPresent()) {
+        builder.setNewPath(gitFileDiff.newPath().get());
+      }
+      if (gitFileDiff.oldMode().isPresent()) {
+        builder.setOldMode(gitFileDiff.oldMode().get().name());
+      }
+      if (gitFileDiff.newMode().isPresent()) {
+        builder.setNewMode(gitFileDiff.newMode().get().name());
+      }
+      if (gitFileDiff.patchType().isPresent()) {
+        builder.setPatchType(gitFileDiff.patchType().get().name());
+      }
+      return Protos.toByteArray(builder.build());
+    }
+
+    @Override
+    public GitFileDiff deserialize(byte[] in) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      GitFileDiffProto proto = Protos.parseUnchecked(GitFileDiffProto.parser(), in);
+      GitFileDiff.Builder builder = GitFileDiff.builder();
+      builder
+          .edits(
+              proto.getEditsList().stream()
+                  .map(e -> Edit.create(e.getBeginA(), e.getEndA(), e.getBeginB(), e.getEndB()))
+                  .collect(toImmutableList()))
+          .fileHeader(proto.getFileHeader())
+          .oldId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getOldId())))
+          .newId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getNewId())))
+          .changeType(ChangeType.valueOf(proto.getChangeType()));
+
+      if (proto.hasField(OLD_PATH_DESCRIPTOR)) {
+        builder.oldPath(Optional.of(proto.getOldPath()));
+      }
+      if (proto.hasField(NEW_PATH_DESCRIPTOR)) {
+        builder.newPath(Optional.of(proto.getNewPath()));
+      }
+      if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
+        builder.oldMode(Optional.of(Patch.FileMode.valueOf(proto.getOldMode())));
+      }
+      if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
+        builder.newMode(Optional.of(Patch.FileMode.valueOf(proto.getNewMode())));
+      }
+      if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
+        builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
+      }
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
new file mode 100644
index 0000000..2516761
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
@@ -0,0 +1,43 @@
+//  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.patch.gitfilediff;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+
+/** This cache computes pure git diff for a single file path according to a git tree diff. */
+public interface GitFileDiffCache {
+
+  /**
+   * Returns the git file diff for a single file path identified by its key.
+   *
+   * @param key identifies two git trees, a specific file path and other diff parameters.
+   * @return the file diff for a single file path identified by its key.
+   * @throws DiffNotAvailableException if the tree IDs of the key are invalid for this project or if
+   *     file contents could not be read.
+   */
+  GitFileDiff get(GitFileDiffCacheKey key) throws DiffNotAvailableException;
+
+  /**
+   * Returns the file diff for a collection of file paths identified by their keys.
+   *
+   * @param keys identifying different file paths of different projects.
+   * @return a map of the input keys to their corresponding git file diffs.
+   * @throws DiffNotAvailableException if the diff failed to be evaluated for one or more of the
+   *     input keys due to invalid tree IDs or if file contents could not be read.
+   */
+  ImmutableMap<GitFileDiffCacheKey, GitFileDiff> getAll(Iterable<GitFileDiffCacheKey> keys)
+      throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
new file mode 100644
index 0000000..97cf37d32
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -0,0 +1,273 @@
+//  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.patch.gitfilediff;
+
+import static java.util.function.Function.identity;
+
+import com.google.auto.value.AutoValue;
+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.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Project;
+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.DiffNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.diff.HistogramDiff;
+import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+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 GitFileDiffCache} */
+public class GitFileDiffCacheImpl implements GitFileDiffCache {
+  private static final String GIT_DIFF = "git_file_diff";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(GitFileDiffCache.class).to(GitFileDiffCacheImpl.class);
+        persist(GIT_DIFF, GitFileDiffCacheKey.class, GitFileDiff.class)
+            .maximumWeight(10 << 20)
+            .weigher(GitFileDiffWeigher.class)
+            .keySerializer(GitFileDiffCacheKey.Serializer.INSTANCE)
+            .valueSerializer(GitFileDiff.Serializer.INSTANCE)
+            .loader(GitFileDiffCacheImpl.Loader.class);
+      }
+    };
+  }
+
+  /** Enum for the supported diff algorithms for the file diff computation. */
+  public enum DiffAlgorithm {
+    HISTOGRAM,
+    HISTOGRAM_WITHOUT_MYERS_FALLBACK
+  }
+
+  /** Creates a new JGit diff algorithm instance using the Gerrit's {@link DiffAlgorithm} enum. */
+  public static class DiffAlgorithmFactory {
+    public static org.eclipse.jgit.diff.DiffAlgorithm create(DiffAlgorithm diffAlgorithm) {
+      HistogramDiff result = new HistogramDiff();
+      if (diffAlgorithm.equals(DiffAlgorithm.HISTOGRAM_WITHOUT_MYERS_FALLBACK)) {
+        result.setFallbackAlgorithm(null);
+      }
+      return result;
+    }
+  }
+
+  private final LoadingCache<GitFileDiffCacheKey, GitFileDiff> cache;
+
+  @Inject
+  public GitFileDiffCacheImpl(
+      @Named(GIT_DIFF) LoadingCache<GitFileDiffCacheKey, GitFileDiff> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public GitFileDiff get(GitFileDiffCacheKey key) throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  @Override
+  public ImmutableMap<GitFileDiffCacheKey, GitFileDiff> getAll(Iterable<GitFileDiffCacheKey> keys)
+      throws DiffNotAvailableException {
+    try {
+      return cache.getAll(keys);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class Loader extends CacheLoader<GitFileDiffCacheKey, GitFileDiff> {
+    /**
+     * Extractor for the file path from a {@link DiffEntry}. Returns the old file path if the entry
+     * corresponds to a deleted file, otherwise it returns the new file path.
+     */
+    private static final Function<DiffEntry, String> pathExtractor =
+        (DiffEntry entry) ->
+            entry.getChangeType().equals(ChangeType.DELETE)
+                ? entry.getOldPath()
+                : entry.getNewPath();
+
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    public Loader(GitRepositoryManager repoManager) {
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    public GitFileDiff load(GitFileDiffCacheKey key) throws IOException {
+      return loadAll(ImmutableList.of(key)).get(key);
+    }
+
+    @Override
+    public Map<GitFileDiffCacheKey, GitFileDiff> loadAll(
+        Iterable<? extends GitFileDiffCacheKey> keys) throws IOException {
+      ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
+          ImmutableMap.builderWithExpectedSize(Iterables.size(keys));
+
+      Map<Project.NameKey, List<GitFileDiffCacheKey>> byProject =
+          Streams.stream(keys)
+              .distinct()
+              .collect(Collectors.groupingBy(GitFileDiffCacheKey::project));
+
+      for (Map.Entry<Project.NameKey, List<GitFileDiffCacheKey>> entry : byProject.entrySet()) {
+        try (Repository repo = repoManager.openRepository(entry.getKey());
+            ObjectReader reader = repo.newObjectReader()) {
+
+          // Grouping keys by diff options because each group of keys will be processed with a
+          // separate call to JGit using the DiffFormatter object.
+          Map<DiffOptions, List<GitFileDiffCacheKey>> optionsGroups =
+              entry.getValue().stream().collect(Collectors.groupingBy(DiffOptions::fromKey));
+
+          for (Map.Entry<DiffOptions, List<GitFileDiffCacheKey>> group : optionsGroups.entrySet()) {
+            result.putAll(loadAllImpl(repo, reader, group.getKey(), group.getValue()));
+          }
+        }
+      }
+      return result.build();
+    }
+
+    /**
+     * Loads the git file diffs for all keys of the same repository, and having the same diff {@code
+     * options}.
+     *
+     * @return The git file diffs for all input keys.
+     */
+    private Map<GitFileDiffCacheKey, GitFileDiff> loadAllImpl(
+        Repository repo, ObjectReader reader, DiffOptions options, List<GitFileDiffCacheKey> keys)
+        throws IOException {
+      ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
+          ImmutableMap.builderWithExpectedSize(keys.size());
+      Map<GitFileDiffCacheKey, String> filePaths =
+          keys.stream().collect(Collectors.toMap(identity(), GitFileDiffCacheKey::newFilePath));
+      DiffFormatter formatter = createDiffFormatter(options, repo, reader);
+      Map<String, DiffEntry> diffEntries = loadDiffEntries(formatter, options, filePaths.values());
+      for (GitFileDiffCacheKey key : filePaths.keySet()) {
+        String newFilePath = filePaths.get(key);
+        if (diffEntries.containsKey(newFilePath)) {
+          result.put(key, GitFileDiff.create(diffEntries.get(newFilePath), formatter));
+          continue;
+        }
+        result.put(
+            key,
+            GitFileDiff.empty(
+                AbbreviatedObjectId.fromObjectId(key.oldTree()),
+                AbbreviatedObjectId.fromObjectId(key.newTree()),
+                newFilePath));
+      }
+      return result.build();
+    }
+
+    private static Map<String, DiffEntry> loadDiffEntries(
+        DiffFormatter diffFormatter, DiffOptions diffOptions, Collection<String> filePaths)
+        throws IOException {
+      Set<String> filePathsSet = ImmutableSet.copyOf(filePaths);
+      List<DiffEntry> diffEntries =
+          diffFormatter.scan(diffOptions.oldTree(), diffOptions.newTree());
+
+      return diffEntries.stream()
+          .filter(d -> filePathsSet.contains(pathExtractor.apply(d)))
+          .collect(Collectors.toMap(d -> pathExtractor.apply(d), identity()));
+    }
+
+    private static DiffFormatter createDiffFormatter(
+        DiffOptions diffOptions, Repository repo, ObjectReader reader) {
+      try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        diffFormatter.setReader(reader, repo.getConfig());
+        RawTextComparator cmp = comparatorFor(diffOptions.whitespace());
+        diffFormatter.setDiffComparator(cmp);
+        if (diffOptions.renameScore() != -1) {
+          diffFormatter.setDetectRenames(true);
+          diffFormatter.getRenameDetector().setRenameScore(diffOptions.renameScore());
+        }
+        diffFormatter.setDiffAlgorithm(DiffAlgorithmFactory.create(diffOptions.diffAlgorithm()));
+        return diffFormatter;
+      }
+    }
+
+    private static RawTextComparator comparatorFor(Whitespace ws) {
+      switch (ws) {
+        case IGNORE_ALL:
+          return RawTextComparator.WS_IGNORE_ALL;
+
+        case IGNORE_TRAILING:
+          return RawTextComparator.WS_IGNORE_TRAILING;
+
+        case IGNORE_LEADING_AND_TRAILING:
+          return RawTextComparator.WS_IGNORE_CHANGE;
+
+        case IGNORE_NONE:
+        default:
+          return RawTextComparator.DEFAULT;
+      }
+    }
+  }
+
+  /** An entity representing the options affecting the diff computation. */
+  @AutoValue
+  abstract static class DiffOptions {
+    /** Convert a {@link GitFileDiffCacheKey} input to a {@link DiffOptions}. */
+    static DiffOptions fromKey(GitFileDiffCacheKey key) {
+      return create(
+          key.oldTree(), key.newTree(), key.renameScore(), key.whitespace(), key.diffAlgorithm());
+    }
+
+    private static DiffOptions create(
+        ObjectId oldTree,
+        ObjectId newTree,
+        Integer renameScore,
+        Whitespace whitespace,
+        DiffAlgorithm diffAlgorithm) {
+      return new AutoValue_GitFileDiffCacheImpl_DiffOptions(
+          oldTree, newTree, renameScore, whitespace, diffAlgorithm);
+    }
+
+    abstract ObjectId oldTree();
+
+    abstract ObjectId newTree();
+
+    abstract Integer renameScore();
+
+    abstract Whitespace whitespace();
+
+    abstract DiffAlgorithm diffAlgorithm();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
new file mode 100644
index 0000000..f104388
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
@@ -0,0 +1,127 @@
+// 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.patch.gitfilediff;
+
+import static com.google.gerrit.server.patch.DiffUtil.stringSize;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.GitFileDiffKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import org.eclipse.jgit.lib.ObjectId;
+
+@AutoValue
+public abstract class GitFileDiffCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /** The old 20 bytes SHA-1 git tree ID used in the git tree diff */
+  public abstract ObjectId oldTree();
+
+  /** The new 20 bytes SHA-1 git tree ID used in the git tree diff */
+  public abstract ObjectId newTree();
+
+  /** File name in the tree identified by {@link #newTree()} */
+  public abstract String newFilePath();
+
+  /**
+   * Percentage score used to identify a file as a "rename". A special value of -1 means that the
+   * computation will ignore renames and rename detection will be disabled.
+   */
+  public abstract int renameScore();
+
+  public abstract DiffAlgorithm diffAlgorithm();
+
+  public abstract DiffPreferencesInfo.Whitespace whitespace();
+
+  public int weight() {
+    return stringSize(project().get())
+        + 20 * 2 // oldTree and newTree
+        + stringSize(newFilePath())
+        + 4 // renameScore
+        + 4 // diffAlgorithm
+        + 4; // whitespace
+  }
+
+  public static Builder builder() {
+    return new AutoValue_GitFileDiffCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder project(NameKey value);
+
+    public abstract Builder oldTree(ObjectId value);
+
+    public abstract Builder newTree(ObjectId value);
+
+    public abstract Builder newFilePath(String value);
+
+    public abstract Builder renameScore(Integer value);
+
+    public Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract Builder diffAlgorithm(DiffAlgorithm value);
+
+    public abstract Builder whitespace(Whitespace value);
+
+    public abstract GitFileDiffCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<GitFileDiffCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(GitFileDiffCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          GitFileDiffKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setATree(idConverter.toByteString(key.oldTree()))
+              .setBTree(idConverter.toByteString(key.newTree()))
+              .setFilePath(key.newFilePath())
+              .setRenameScore(key.renameScore())
+              .setDiffAlgorithm(key.diffAlgorithm().name())
+              .setWhitepsace(key.whitespace().name())
+              .build());
+    }
+
+    @Override
+    public GitFileDiffCacheKey deserialize(byte[] in) {
+      GitFileDiffKeyProto proto = Protos.parseUnchecked(GitFileDiffKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return GitFileDiffCacheKey.builder()
+          .project(Project.nameKey(proto.getProject()))
+          .oldTree(idConverter.fromByteString(proto.getATree()))
+          .newTree(idConverter.fromByteString(proto.getBTree()))
+          .newFilePath(proto.getFilePath())
+          .renameScore(proto.getRenameScore())
+          .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
+          .whitespace(Whitespace.valueOf(proto.getWhitepsace()))
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
new file mode 100644
index 0000000..47f7791
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
@@ -0,0 +1,25 @@
+//  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.patch.gitfilediff;
+
+import com.google.common.cache.Weigher;
+
+public class GitFileDiffWeigher implements Weigher<GitFileDiffCacheKey, GitFileDiff> {
+
+  @Override
+  public int weigh(GitFileDiffCacheKey key, GitFileDiff gitFileDiff) {
+    return key.weight() + gitFileDiff.weight();
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index cf6a184..66299a8 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -49,8 +49,6 @@
 public class DefaultPermissionBackend extends PermissionBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final CurrentUser.PropertyKey<Boolean> IS_ADMIN = CurrentUser.PropertyKey.create();
-
   private final Provider<CurrentUser> currentUser;
   private final ProjectCache projectCache;
   private final ProjectControl.Factory projectControlFactory;
@@ -84,8 +82,15 @@
 
   @Override
   public WithUser absentUser(Account.Id id) {
-    IdentifiedUser identifiedUser = identifiedUserFactory.create(requireNonNull(id, "user"));
-    return new WithUserImpl(identifiedUser);
+    requireNonNull(id, "user");
+    Optional<Account.Id> user = getAccountIdOfIdentifiedUser();
+    if (user.isPresent() && id.equals(user.get())) {
+      // What looked liked an absent user is actually the current caller. Use the per-request
+      // singleton IdentifiedUser instead of constructing a new object to leverage caching in member
+      // variables of IdentifiedUser.
+      return new WithUserImpl(currentUser.get().asIdentifiedUser());
+    }
+    return new WithUserImpl(identifiedUserFactory.create(requireNonNull(id, "user")));
   }
 
   @Override
@@ -93,6 +98,21 @@
     return true;
   }
 
+  /**
+   * Returns the {@link com.google.gerrit.entities.Account.Id} of the current user if a user is
+   * signed in. Catches exceptions so that background jobs don't get impacted.
+   */
+  private Optional<Account.Id> getAccountIdOfIdentifiedUser() {
+    try {
+      return currentUser.get().isIdentifiedUser()
+          ? Optional.of(currentUser.get().getAccountId())
+          : Optional.empty();
+    } catch (Exception e) {
+      logger.atFine().withCause(e).log("Unable to get current user");
+      return Optional.empty();
+    }
+  }
+
   class WithUserImpl extends WithUser {
     private final CurrentUser user;
     private Boolean admin;
@@ -202,21 +222,13 @@
     }
 
     private Boolean computeAdmin() {
-      Optional<Boolean> r = user.get(IS_ADMIN);
-      if (r.isPresent()) {
-        return r.get();
-      }
-
-      boolean isAdmin;
       if (user.isImpersonating()) {
-        isAdmin = false;
-      } else if (user instanceof PeerDaemonUser) {
-        isAdmin = true;
-      } else {
-        isAdmin = allow(capabilities().administrateServer);
+        return false;
       }
-      user.put(IS_ADMIN, isAdmin);
-      return isAdmin;
+      if (user instanceof PeerDaemonUser) {
+        return true;
+      }
+      return allow(capabilities().administrateServer);
     }
 
     private boolean canEmailReviewers() {
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
index 3f84dff..d2e85be 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
@@ -31,6 +31,7 @@
       // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
       factory(ProjectControl.Factory.class);
       factory(DefaultRefFilter.Factory.class);
+      factory(VisibleChangesCache.Factory.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 03d3b63..1b528d7 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static java.util.stream.Collectors.toCollection;
 
@@ -23,12 +23,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -43,19 +39,15 @@
 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.ChangeNotes.Factory.ChangeNotesResult;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
@@ -70,7 +62,6 @@
 
   private final TagCache tagCache;
   private final ChangeNotes.Factory changeNotesFactory;
-  @Nullable private final SearchingChangeCacheImpl changeCache;
   private final PermissionBackend permissionBackend;
   private final RefVisibilityControl refVisibilityControl;
   private final ProjectControl projectControl;
@@ -80,27 +71,28 @@
   private final Counter0 fullFilterCount;
   private final Counter0 skipFilterCount;
   private final boolean skipFullRefEvaluationIfAllRefsAreVisible;
+  private final VisibleChangesCache.Factory visibleChangesCacheFactory;
 
-  private Map<Change.Id, BranchNameKey> visibleChanges;
+  private VisibleChangesCache visibleChangesCache;
 
   @Inject
   DefaultRefFilter(
       TagCache tagCache,
       ChangeNotes.Factory changeNotesFactory,
-      @Nullable SearchingChangeCacheImpl changeCache,
       PermissionBackend permissionBackend,
       RefVisibilityControl refVisibilityControl,
       @GerritServerConfig Config config,
       MetricMaker metricMaker,
+      VisibleChangesCache.Factory visibleChangesCacheFactory,
       @Assisted ProjectControl projectControl) {
     this.tagCache = tagCache;
     this.changeNotesFactory = changeNotesFactory;
-    this.changeCache = changeCache;
     this.permissionBackend = permissionBackend;
     this.refVisibilityControl = refVisibilityControl;
     this.skipFullRefEvaluationIfAllRefsAreVisible =
         config.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true);
     this.projectControl = projectControl;
+    this.visibleChangesCacheFactory = visibleChangesCacheFactory;
 
     this.user = projectControl.getUser();
     this.projectState = projectControl.getProjectState();
@@ -122,11 +114,12 @@
   /** Filters given refs and tags by visibility. */
   Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
       throws PermissionBackendException {
+    visibleChangesCache = visibleChangesCacheFactory.create(projectControl, repo);
     logger.atFinest().log(
         "Filter refs for repository %s by visibility (options = %s, refs = %s)",
         projectState.getNameKey(), opts, refs);
     logger.atFinest().log("Calling user: %s", user.getLoggableName());
-    logger.atFinest().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+    logger.atFinest().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
     logger.atFinest().log(
         "auth.skipFullRefEvaluationIfAllRefsAreVisible = %s",
         skipFullRefEvaluationIfAllRefsAreVisible);
@@ -155,11 +148,11 @@
     // Perform an initial ref filtering with all the refs the caller asked for. If we find tags that
     // we have to investigate separately (deferred tags) then perform a reachability check starting
     // from all visible branches (refs/heads/*).
-    Result initialRefFilter = filterRefs(new ArrayList<>(refs), repo, opts);
+    Result initialRefFilter = filterRefs(new ArrayList<>(refs), opts);
     List<Ref> visibleRefs = initialRefFilter.visibleRefs();
     if (!initialRefFilter.deferredTags().isEmpty()) {
       try (TraceTimer traceTimer = TraceContext.newTimer("Check visibility of deferred tags")) {
-        Result allVisibleBranches = filterRefs(getTaggableRefs(repo), repo, opts);
+        Result allVisibleBranches = filterRefs(getTaggableRefs(repo), opts);
         checkState(
             allVisibleBranches.deferredTags().isEmpty(),
             "unexpected tags found when filtering refs/heads/* "
@@ -193,8 +186,7 @@
    * separately for later rev-walk-based visibility computation. Tags where visibility is trivial to
    * compute will be returned as part of {@link Result#visibleRefs()}.
    */
-  Result filterRefs(List<Ref> refs, Repository repo, RefFilterOptions opts)
-      throws PermissionBackendException {
+  Result filterRefs(List<Ref> refs, RefFilterOptions opts) throws PermissionBackendException {
     logger.atFinest().log("Filter refs (refs = %s)", refs);
 
     // TODO(hiesel): Remove when optimization is done.
@@ -255,12 +247,13 @@
       } else if ((changeId = Change.Id.fromRef(refName)) != null) {
         // This is a mere performance optimization. RefVisibilityControl could determine the
         // visibility of these refs just fine. But instead, we use highly-optimized logic that
-        // looks only on the last 10k most recent changes using the change index and a cache.
+        // looks only on the available changes in the change index and cache (which are the
+        // most recent changes).
         if (hasAccessDatabase) {
           resultRefs.add(ref);
-        } else if (!visible(repo, changeId)) {
+        } else if (!visibleChangesCache.isVisible(changeId)) {
           logger.atFinest().log("Filter out invisible change ref %s", refName);
-        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(repo, refName)) {
+        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(refName)) {
           logger.atFinest().log("Filter out invisible change edit ref %s", refName);
         } else {
           // Change is visible
@@ -292,10 +285,7 @@
                       && !r.getName().startsWith(RefNames.REFS_TAGS)
                       && !r.isSymbolic()
                       && !r.getName().equals(RefNames.REFS_CONFIG))
-          // Don't use the default Java Collections.toList() as that is not size-aware and would
-          // expand an array list as new elements are added. Instead, provide a list that has the
-          // right size. This spares incremental list expansion which is quadratic in complexity.
-          .collect(toCollection(() -> new ArrayList<>(allRefs.size())));
+          .collect(Collectors.toList());
     } catch (IOException e) {
       throw new PermissionBackendException(e);
     }
@@ -305,27 +295,12 @@
     if (!canReadRef(REFS_CONFIG)) {
       return refs.stream()
           .filter(r -> !r.getName().equals(REFS_CONFIG))
-          // Don't use the default Java Collections.toList() as that is not size-aware and would
-          // expand an array list as new elements are added. Instead, provide a list that has the
-          // right size. This spares incremental list expansion which is quadratic in complexity.
           .collect(toCollection(() -> new ArrayList<>(refs.size())));
     }
     return refs;
   }
 
-  private boolean visible(Repository repo, Change.Id changeId) throws PermissionBackendException {
-    if (visibleChanges == null) {
-      if (changeCache == null) {
-        visibleChanges = visibleChangesByScan(repo);
-      } else {
-        visibleChanges = visibleChangesBySearch();
-      }
-      logger.atFinest().log("Visible changes: %s", visibleChanges.keySet());
-    }
-    return visibleChanges.containsKey(changeId);
-  }
-
-  private boolean visibleEdit(Repository repo, String name) throws PermissionBackendException {
+  private boolean visibleEdit(String name) throws PermissionBackendException {
     Change.Id id = Change.Id.fromEditRefPart(name);
     if (id == null) {
       logger.atWarning().log("Couldn't extract change ID from edit ref %s", name);
@@ -334,20 +309,16 @@
 
     if (user.isIdentifiedUser()
         && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))
-        && visible(repo, id)) {
+        && visibleChangesCache.isVisible(id)) {
       logger.atFinest().log("Own change edit ref is visible: %s", name);
       return true;
     }
 
-    // Initialize visibleChanges if it wasn't initialized yet.
-    if (visibleChanges == null) {
-      visible(repo, id);
-    }
-    if (visibleChanges.containsKey(id)) {
+    if (visibleChangesCache.isVisible(id)) {
       try {
         // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
         permissionBackendForProject
-            .ref(visibleChanges.get(id).branch())
+            .ref(visibleChangesCache.getBranchNameKey(id).branch())
             .check(RefPermission.READ_PRIVATE_CHANGES);
         logger.atFinest().log("Foreign change edit ref is visible: %s", name);
         return true;
@@ -361,72 +332,6 @@
     return false;
   }
 
-  private Map<Change.Id, BranchNameKey> visibleChangesBySearch() throws PermissionBackendException {
-    Project.NameKey project = projectState.getNameKey();
-    try {
-      Map<Change.Id, BranchNameKey> visibleChanges = new HashMap<>();
-      for (ChangeData cd : changeCache.getChangeData(project)) {
-        if (!projectState.statePermitsRead()) {
-          continue;
-        }
-        try {
-          permissionBackendForProject.change(cd).check(ChangePermission.READ);
-          visibleChanges.put(cd.getId(), cd.change().getDest());
-        } catch (AuthException e) {
-          // Do nothing.
-        }
-      }
-      return visibleChanges;
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", project);
-      return Collections.emptyMap();
-    }
-  }
-
-  private Map<Change.Id, BranchNameKey> visibleChangesByScan(Repository repo)
-      throws PermissionBackendException {
-    Project.NameKey p = projectState.getNameKey();
-    ImmutableList<ChangeNotesResult> changes;
-    try {
-      changes = changeNotesFactory.scan(repo, p).collect(toImmutableList());
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", p);
-      return Collections.emptyMap();
-    }
-
-    Map<Change.Id, BranchNameKey> result = Maps.newHashMapWithExpectedSize(changes.size());
-    for (ChangeNotesResult notesResult : changes) {
-      ChangeNotes notes = toNotes(notesResult);
-      if (notes != null) {
-        result.put(notes.getChangeId(), notes.getChange().getDest());
-      }
-    }
-    return result;
-  }
-
-  @Nullable
-  private ChangeNotes toNotes(ChangeNotesResult r) throws PermissionBackendException {
-    if (r.error().isPresent()) {
-      logger.atWarning().withCause(r.error().get()).log(
-          "Failed to load change %s in %s", r.id(), projectState.getName());
-      return null;
-    }
-
-    if (!projectState.statePermitsRead()) {
-      return null;
-    }
-
-    try {
-      permissionBackendForProject.change(r.notes()).check(ChangePermission.READ);
-      return r.notes();
-    } catch (AuthException e) {
-      // Skip.
-    }
-    return null;
-  }
-
   private boolean isMetadata(String name) {
     boolean isMetaData = RefNames.isRefsChanges(name) || RefNames.isRefsEdit(name);
     logger.atFinest().log("ref %s is " + (isMetaData ? "" : "not ") + "a metadata ref", name);
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index 07c9e84..d4f22e6 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -18,6 +18,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.CapabilityScope;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -31,7 +32,12 @@
 import java.util.Optional;
 import java.util.Set;
 
-/** Global server permissions built into Gerrit. */
+/**
+ * Global server permissions built into Gerrit.
+ *
+ * <p>See also {@link GlobalCapability} which lists the equivalent strings used in the
+ * refs/meta/config settings in All-Projects.
+ */
 public enum GlobalPermission implements GlobalOrPluginPermission {
   ACCESS_DATABASE,
   ADMINISTRATE_SERVER,
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index eceb970..27c6793 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -60,9 +60,9 @@
  * <p>{@code PermissionBackend} is a singleton for the server, acting as a factory for lightweight
  * request instances. Implementation classes may cache supporting data inside of {@link WithUser},
  * {@link ForProject}, {@link ForRef}, and {@link ForChange} instances, in addition to storing
- * within {@link CurrentUser} using a {@link com.google.gerrit.server.CurrentUser.PropertyKey}.
- * {@link GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser}
- * as {@link WithUser} instances are frequently created.
+ * within {@link CurrentUser} using a {@link com.google.gerrit.server.PropertyMap.Key}. {@link
+ * GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser} as
+ * {@link WithUser} instances are frequently created.
  *
  * <p>Example use:
  *
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index fd82559..dd00dca 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.CallerFinder;
+import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
@@ -425,40 +426,52 @@
   /** True if the user has this permission. */
   private boolean canPerform(String permissionName, boolean isChangeOwner, boolean withForce) {
     if (isBlocked(permissionName, isChangeOwner, withForce)) {
-      logger.atFine().log(
-          "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
-              + " because this permission is blocked (caller: %s)",
-          getUser().getLoggableName(),
-          permissionName,
-          withForce,
-          projectControl.getProject().getName(),
-          refName,
-          callerFinder.findCallerLazy());
+      if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+        String logMessage =
+            String.format(
+                "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
+                    + " because this permission is blocked",
+                getUser().getLoggableName(),
+                permissionName,
+                withForce,
+                projectControl.getProject().getName(),
+                refName);
+        LoggingContext.getInstance().addAclLogRecord(logMessage);
+        logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+      }
       return false;
     }
 
     for (PermissionRule pr : relevant.getAllowRules(permissionName)) {
       if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
-        logger.atFine().log(
-            "'%s' can perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
-            getUser().getLoggableName(),
-            permissionName,
-            withForce,
-            projectControl.getProject().getName(),
-            refName,
-            callerFinder.findCallerLazy());
+        if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+          String logMessage =
+              String.format(
+                  "'%s' can perform '%s' with force=%s on project '%s' for ref '%s'",
+                  getUser().getLoggableName(),
+                  permissionName,
+                  withForce,
+                  projectControl.getProject().getName(),
+                  refName);
+          LoggingContext.getInstance().addAclLogRecord(logMessage);
+          logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+        }
         return true;
       }
     }
 
-    logger.atFine().log(
-        "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
-        getUser().getLoggableName(),
-        permissionName,
-        withForce,
-        projectControl.getProject().getName(),
-        refName,
-        callerFinder.findCallerLazy());
+    if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+      String logMessage =
+          String.format(
+              "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'",
+              getUser().getLoggableName(),
+              permissionName,
+              withForce,
+              projectControl.getProject().getName(),
+              refName);
+      LoggingContext.getInstance().addAclLogRecord(logMessage);
+      logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+    }
     return false;
   }
 
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index 4744037..cc6387b 100644
--- a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -118,9 +118,14 @@
     Account.Id accountId;
     if ((accountId = Account.Id.fromRef(refName)) != null) {
       // Account ref is visible only to the corresponding account.
-      if (accountId.equals(currentUserAccountId)
-          && projectControl.controlForRef(refName).hasReadPermissionOnRef(true)) {
-        return true;
+      if (accountId.equals(currentUserAccountId)) {
+        // Always allow visibility to refs/draft-comments and refs/starred-changes. For all other
+        // refs, check if the user has read permissions.
+        if (RefNames.isRefsDraftsComments(refName)
+            || RefNames.isRefsStarredChanges(refName)
+            || projectControl.controlForRef(refName).hasReadPermissionOnRef(true)) {
+          return true;
+        }
       }
       return false;
     }
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index 6081e9a..d800782 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.permissions;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
@@ -28,6 +30,8 @@
 import java.util.ArrayList;
 import java.util.IdentityHashMap;
 import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
 
 /**
  * Caches the order AccessSections should be sorted for evaluation.
@@ -60,67 +64,62 @@
     this.cache = cache;
   }
 
-  // Sorts the given sections, but does not disturb ordering between equally exact sections.
+  /**
+   * Sorts the given sections in-place, but does not disturb ordering between equally exact
+   * sections.
+   */
   void sort(String ref, List<AccessSection> sections) {
     final int cnt = sections.size();
     if (cnt <= 1) {
       return;
     }
-
     EntryKey key = EntryKey.create(ref, sections);
-    EntryVal val = cache.getIfPresent(key);
-    if (val != null) {
-      int[] srcIdx = val.order;
-      if (srcIdx != null) {
-        AccessSection[] srcList = copy(sections);
-        for (int i = 0; i < cnt; i++) {
-          sections.set(i, srcList[srcIdx[i]]);
-        }
-      } else {
-        // Identity transform. No sorting is required.
-      }
+    EntryVal val;
+    try {
+      val = cache.get(key, new Loader(key, sections));
+    } catch (ExecutionException e) {
+      logger.atWarning().withCause(e).log("Error happened while sorting access sections.");
+      return;
+    }
+    ImmutableList<Integer> order = val.order();
+    List<AccessSection> sorted = new ArrayList<>();
+    for (int i = 0; i < cnt; i++) {
+      sorted.add(sections.get(order.get(i)));
+    }
+    for (int i = 0; i < cnt; i++) {
+      sections.set(i, sorted.get(i));
+    }
+  }
 
-    } else {
-      boolean poison = false;
+  private static class Loader implements Callable<EntryVal> {
+    private final List<AccessSection> sections;
+    EntryKey key;
+
+    Loader(EntryKey key, List<AccessSection> sections) {
+      this.key = key;
+      this.sections = sections;
+    }
+
+    @Override
+    public EntryVal call() throws Exception {
+      // We use IdentityHashMap (which uses reference equality for keys/values) to preserve distinct
+      // entries in the map for identical AccessSection keys
       IdentityHashMap<AccessSection, Integer> srcMap = new IdentityHashMap<>();
-      for (int i = 0; i < cnt; i++) {
-        poison |= srcMap.put(sections.get(i), i) != null;
+      for (int i = 0; i < sections.size(); i++) {
+        srcMap.put(sections.get(i), i);
       }
-
-      sections.sort(new MostSpecificComparator(ref));
-
-      int[] srcIdx;
-      if (isIdentityTransform(sections, srcMap)) {
-        srcIdx = null;
-      } else {
-        srcIdx = new int[cnt];
-        for (int i = 0; i < cnt; i++) {
-          srcIdx[i] = srcMap.get(sections.get(i));
-        }
+      ImmutableList<AccessSection> sorted =
+          sections.stream()
+              .sorted(new MostSpecificComparator(key.ref()))
+              .collect(toImmutableList());
+      ImmutableList.Builder<Integer> order = ImmutableList.builderWithExpectedSize(sections.size());
+      for (int i = 0; i < sorted.size(); i++) {
+        order.add(srcMap.get(sorted.get(i)));
       }
-
-      if (poison) {
-        logger.atSevere().log("Received duplicate AccessSection instances, not caching sort");
-      } else {
-        cache.put(key, new EntryVal(srcIdx));
-      }
+      return EntryVal.create(order.build());
     }
   }
 
-  private static AccessSection[] copy(List<AccessSection> sections) {
-    return sections.toArray(new AccessSection[sections.size()]);
-  }
-
-  private static boolean isIdentityTransform(
-      List<AccessSection> sections, IdentityHashMap<AccessSection, Integer> srcMap) {
-    for (int i = 0; i < sections.size(); i++) {
-      if (i != srcMap.get(sections.get(i))) {
-        return false;
-      }
-    }
-    return true;
-  }
-
   @AutoValue
   abstract static class EntryKey {
     public abstract String ref();
@@ -146,17 +145,18 @@
     }
   }
 
-  static final class EntryVal {
+  @AutoValue
+  abstract static class EntryVal {
     /**
      * Maps the input index to the output index.
      *
      * <p>For {@code x == order[y]} the expression means move the item at source position {@code x}
      * to the output position {@code y}.
      */
-    final int[] order;
+    abstract ImmutableList<Integer> order();
 
-    EntryVal(int[] order) {
-      this.order = order;
+    static EntryVal create(ImmutableList<Integer> order) {
+      return new AutoValue_SectionSortCache_EntryVal(order);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
new file mode 100644
index 0000000..2e47576
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
@@ -0,0 +1,169 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Gets all of the visible by current user changes in the repository that are available in the
+ * change index and cache.
+ */
+class VisibleChangesCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  interface Factory {
+    VisibleChangesCache create(ProjectControl projectControl, Repository repository);
+  }
+
+  @Nullable private final SearchingChangeCacheImpl changeCache;
+  private final ProjectState projectState;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final PermissionBackend.ForProject permissionBackendForProject;
+
+  private final Repository repository;
+  private Map<Change.Id, BranchNameKey> visibleChanges;
+
+  @Inject
+  VisibleChangesCache(
+      @Nullable SearchingChangeCacheImpl changeCache,
+      PermissionBackend permissionBackend,
+      ChangeNotes.Factory changeNotesFactory,
+      @Assisted ProjectControl projectControl,
+      @Assisted Repository repository) {
+    this.changeCache = changeCache;
+    this.projectState = projectControl.getProjectState();
+    this.permissionBackendForProject =
+        permissionBackend.user(projectControl.getUser()).project(projectState.getNameKey());
+    this.changeNotesFactory = changeNotesFactory;
+    this.repository = repository;
+  }
+
+  /**
+   * Returns {@code true} if the {@code changeId} in repository {@code repo} is visible to the user,
+   * by looking at the cached visible changes.
+   */
+  public boolean isVisible(Change.Id changeId) throws PermissionBackendException {
+    cachedVisibleChanges();
+    return visibleChanges.containsKey(changeId);
+  }
+
+  /**
+   * Returns the visible changes in the repository {@code repo}. If not cached, computes the visible
+   * changes and caches them.
+   */
+  public Map<Change.Id, BranchNameKey> cachedVisibleChanges() throws PermissionBackendException {
+    if (visibleChanges == null) {
+      if (changeCache == null) {
+        visibleChangesByScan();
+      } else {
+        visibleChangesBySearch();
+      }
+      logger.atFinest().log("Visible changes: %s", visibleChanges.keySet());
+    }
+    return visibleChanges;
+  }
+
+  /**
+   * Returns the {@code BranchNameKey} for {@code changeId}. If not cached, computes *all* visible
+   * changes and caches them before returning this specific change. If not visible or not found,
+   * returns {@code null}.
+   */
+  @Nullable
+  public BranchNameKey getBranchNameKey(Change.Id changeId) throws PermissionBackendException {
+    return cachedVisibleChanges().get(changeId);
+  }
+
+  private void visibleChangesBySearch() throws PermissionBackendException {
+    visibleChanges = new HashMap<>();
+    Project.NameKey project = projectState.getNameKey();
+    try {
+      for (ChangeData cd : changeCache.getChangeData(project)) {
+        if (!projectState.statePermitsRead()) {
+          continue;
+        }
+        try {
+          permissionBackendForProject.change(cd).check(ChangePermission.READ);
+          visibleChanges.put(cd.getId(), cd.change().getDest());
+        } catch (AuthException e) {
+          // Do nothing.
+        }
+      }
+    } catch (StorageException e) {
+      logger.atSevere().withCause(e).log(
+          "Cannot load changes for project %s, assuming no changes are visible", project);
+    }
+  }
+
+  private void visibleChangesByScan() throws PermissionBackendException {
+    visibleChanges = new HashMap<>();
+    Project.NameKey p = projectState.getNameKey();
+    ImmutableList<ChangeNotesResult> changes;
+    try {
+      changes = changeNotesFactory.scan(repository, p).collect(toImmutableList());
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(
+          "Cannot load changes for project %s, assuming no changes are visible", p);
+      return;
+    }
+
+    for (ChangeNotesResult notesResult : changes) {
+      ChangeNotes notes = toNotes(notesResult);
+      if (notes != null) {
+        visibleChanges.put(notes.getChangeId(), notes.getChange().getDest());
+      }
+    }
+  }
+
+  @Nullable
+  private ChangeNotes toNotes(ChangeNotesResult r) throws PermissionBackendException {
+    if (r.error().isPresent()) {
+      logger.atWarning().withCause(r.error().get()).log(
+          "Failed to load change %s in %s", r.id(), projectState.getName());
+      return null;
+    }
+
+    if (!projectState.statePermitsRead()) {
+      return null;
+    }
+
+    try {
+      permissionBackendForProject.change(r.notes()).check(ChangePermission.READ);
+      return r.notes();
+    } catch (AuthException e) {
+      // Skip.
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/plugins/JsPlugin.java b/java/com/google/gerrit/server/plugins/JsPlugin.java
index 12028b60..c120cdd 100644
--- a/java/com/google/gerrit/server/plugins/JsPlugin.java
+++ b/java/com/google/gerrit/server/plugins/JsPlugin.java
@@ -40,8 +40,7 @@
     String fileName = getSrcFile().getFileName().toString();
     int firstDash = fileName.indexOf("-");
     if (firstDash > 0) {
-      int extension =
-          fileName.endsWith(".js") ? fileName.lastIndexOf(".js") : fileName.lastIndexOf(".html");
+      int extension = fileName.lastIndexOf(".js");
       if (extension > 0) {
         return fileName.substring(firstDash + 1, extension);
       }
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index c4f4a1f..0a06081 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -733,7 +733,7 @@
   }
 
   private boolean isUiPlugin(String name) {
-    return isPlugin(name, "js") || isPlugin(name, "html");
+    return isPlugin(name, "js");
   }
 
   private boolean isPlugin(String fileName, String ext) {
diff --git a/java/com/google/gerrit/server/project/CreateRefControl.java b/java/com/google/gerrit/server/project/CreateRefControl.java
index 69ac93e..2e76949 100644
--- a/java/com/google/gerrit/server/project/CreateRefControl.java
+++ b/java/com/google/gerrit/server/project/CreateRefControl.java
@@ -105,7 +105,7 @@
       // If the tag has a PGP signature, allow a lower level of permission
       // than if it doesn't have a PGP signature.
       PermissionBackend.ForRef forRef = permissionBackend.user(user.get()).ref(branch);
-      if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
+      if (tag.getRawGpgSignature() != null) {
         forRef.check(RefPermission.CREATE_SIGNED_TAG);
       } else {
         forRef.check(RefPermission.CREATE_TAG);
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 7aa4029..730162f 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -35,6 +35,8 @@
     label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
     label.copyMinScore = toBoolean(labelType.isCopyMinScore());
     label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
+    label.copyAllScoresIfListOfFilesDidNotChange =
+        toBoolean(labelType.isCopyAllScoresIfListOfFilesDidNotChange());
     label.copyAllScoresIfNoChange = toBoolean(labelType.isCopyAllScoresIfNoChange());
     label.copyAllScoresIfNoCodeChange = toBoolean(labelType.isCopyAllScoresIfNoCodeChange());
     label.copyAllScoresOnTrivialRebase = toBoolean(labelType.isCopyAllScoresOnTrivialRebase());
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 89038e2..4a063a3 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -111,6 +111,8 @@
   public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
   public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
   public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
+  public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
+      "copyAllScoresIfListOfFilesDidNotChange";
   public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
       "copyAllScoresOnMergeFirstParentUpdate";
   public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
@@ -385,6 +387,7 @@
     this.accountsSection = accountsSection;
   }
 
+  /** Returns an access section, {@code name} typically is a ref pattern. */
   public AccessSection getAccessSection(String name) {
     return accessSections.get(name);
   }
@@ -1028,6 +1031,12 @@
           rc.getBoolean(LABEL, name, KEY_COPY_MIN_SCORE, LabelType.DEF_COPY_MIN_SCORE));
       label.setCopyMaxScore(
           rc.getBoolean(LABEL, name, KEY_COPY_MAX_SCORE, LabelType.DEF_COPY_MAX_SCORE));
+      label.setCopyAllScoresIfListOfFilesDidNotChange(
+          rc.getBoolean(
+              LABEL,
+              name,
+              KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+              LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE));
       label.setCopyAllScoresOnMergeFirstParentUpdate(
           rc.getBoolean(
               LABEL,
@@ -1565,6 +1574,13 @@
           rc,
           LABEL,
           name,
+          KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+          label.isCopyAllScoresIfListOfFilesDidNotChange(),
+          LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
+          name,
           KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
           label.isCopyAllScoresOnMergeFirstParentUpdate(),
           LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
diff --git a/java/com/google/gerrit/server/project/ProjectLevelConfig.java b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
index 4825233..8256198 100644
--- a/java/com/google/gerrit/server/project/ProjectLevelConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
@@ -16,14 +16,15 @@
 
 import static java.util.stream.Collectors.toList;
 
-import com.google.common.collect.Iterables;
 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;
@@ -73,23 +74,21 @@
 
   private final String fileName;
   private final ProjectState project;
-  private Config cfg;
+  private final ImmutableConfig immutableConfig;
 
-  public ProjectLevelConfig(String fileName, ProjectState project, Config cfg) {
+  public ProjectLevelConfig(
+      String fileName, ProjectState project, @Nullable ImmutableConfig immutableConfig) {
     this.fileName = fileName;
     this.project = project;
-    this.cfg = cfg;
+    this.immutableConfig = immutableConfig == null ? ImmutableConfig.EMPTY : immutableConfig;
   }
 
   public Config get() {
-    if (cfg == null) {
-      cfg = new Config();
-    }
-    return cfg;
+    return immutableConfig.mutableCopy();
   }
 
   public Config getWithInheritance() {
-    return getWithInheritance(false);
+    return getWithInheritance(/* merge= */ false);
   }
 
   /**
@@ -105,58 +104,61 @@
    * @return a combined config.
    */
   public Config getWithInheritance(boolean merge) {
-    Config cfgWithInheritance = new Config();
-    try {
-      cfgWithInheritance.fromText(get().toText());
-    } catch (ConfigInvalidException e) {
-      // cannot happen
-    }
-    ProjectState parent = Iterables.getFirst(project.parents(), null);
-    if (parent != null) {
-      Config parentCfg = parent.getConfig(fileName).getWithInheritance();
-      for (String section : parentCfg.getSections()) {
-        Set<String> allNames = get().getNames(section);
-        for (String name : parentCfg.getNames(section)) {
-          String[] parentValues = parentCfg.getStringList(section, null, name);
-          if (!allNames.contains(name)) {
-            cfgWithInheritance.setStringList(section, null, name, Arrays.asList(parentValues));
-          } else if (merge) {
-            cfgWithInheritance.setStringList(
-                section,
-                null,
-                name,
-                Stream.concat(
-                        Arrays.stream(cfg.getStringList(section, null, name)),
-                        Arrays.stream(parentValues))
-                    .sorted()
-                    .distinct()
-                    .collect(toList()));
-          }
-        }
+    Config cfg = new Config();
+    // Traverse from All-Projects down to the current project
+    StreamSupport.stream(project.treeInOrder().spliterator(), false)
+        .forEach(
+            parent -> {
+              ImmutableConfig levelCfg = parent.getConfig(fileName).immutableConfig;
+              for (String section : levelCfg.getSections()) {
+                Set<String> allNames = cfg.getNames(section);
+                for (String name : levelCfg.getNames(section)) {
+                  String[] levelValues = levelCfg.getStringList(section, null, name);
+                  if (allNames.contains(name) && merge) {
+                    cfg.setStringList(
+                        section,
+                        null,
+                        name,
+                        Stream.concat(
+                                Arrays.stream(cfg.getStringList(section, null, name)),
+                                Arrays.stream(levelValues))
+                            .sorted()
+                            .distinct()
+                            .collect(toList()));
+                  } else {
+                    cfg.setStringList(section, null, name, Arrays.asList(levelValues));
+                  }
+                }
 
-        for (String subsection : parentCfg.getSubsections(section)) {
-          allNames = get().getNames(section, subsection);
-          for (String name : parentCfg.getNames(section, subsection)) {
-            String[] parentValues = parentCfg.getStringList(section, subsection, name);
-            if (!allNames.contains(name)) {
-              cfgWithInheritance.setStringList(
-                  section, subsection, name, Arrays.asList(parentValues));
-            } else if (merge) {
-              cfgWithInheritance.setStringList(
-                  section,
-                  subsection,
-                  name,
-                  Streams.concat(
-                          Arrays.stream(cfg.getStringList(section, subsection, name)),
-                          Arrays.stream(parentValues))
-                      .sorted()
-                      .distinct()
-                      .collect(toList()));
-            }
-          }
-        }
-      }
-    }
-    return cfgWithInheritance;
+                for (String subsection : levelCfg.getSubsections(section)) {
+                  allNames = cfg.getNames(section, subsection);
+
+                  Set<String> allNamesLevelCfg = levelCfg.getNames(section, subsection);
+                  if (allNamesLevelCfg.isEmpty()) {
+                    // Set empty subsection.
+                    cfg.setString(section, subsection, null, null);
+                  } else {
+                    for (String name : allNamesLevelCfg) {
+                      String[] levelValues = levelCfg.getStringList(section, subsection, name);
+                      if (allNames.contains(name) && merge) {
+                        cfg.setStringList(
+                            section,
+                            subsection,
+                            name,
+                            Streams.concat(
+                                    Arrays.stream(cfg.getStringList(section, subsection, name)),
+                                    Arrays.stream(levelValues))
+                                .sorted()
+                                .distinct()
+                                .collect(toList()));
+                      } else {
+                        cfg.setStringList(section, subsection, name, Arrays.asList(levelValues));
+                      }
+                    }
+                  }
+                }
+              }
+            });
+    return cfg;
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index eecf1fe..249eb35 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -14,7 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.PermissionRule.Action.ALLOW;
+import static java.util.Comparator.comparing;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.FluentIterable;
@@ -174,8 +177,9 @@
   }
 
   public ProjectLevelConfig getConfig(String fileName) {
-    Optional<Config> rawConfig = cachedConfig.getProjectLevelConfig(fileName);
-    return new ProjectLevelConfig(fileName, this, rawConfig.orElse(new Config()));
+    checkState(fileName.endsWith(".config"), "file name must end in .config. is: " + fileName);
+    return new ProjectLevelConfig(
+        fileName, this, cachedConfig.getParsedProjectLevelConfigs().get(fileName));
   }
 
   public long getMaxObjectSizeLimit() {
@@ -264,7 +268,10 @@
   List<SectionMatcher> getLocalAccessSections() {
     List<SectionMatcher> sm = localAccessSections;
     if (sm == null) {
-      Collection<AccessSection> fromConfig = cachedConfig.getAccessSections().values();
+      ImmutableList<AccessSection> fromConfig =
+          cachedConfig.getAccessSections().values().stream()
+              .sorted(comparing(AccessSection::getName))
+              .collect(toImmutableList());
       sm = new ArrayList<>(fromConfig.size());
       for (AccessSection section : fromConfig) {
         if (isAllProjects) {
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
index dc8cdc7..797756b 100644
--- a/java/com/google/gerrit/server/project/RefUtil.java
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -102,6 +102,7 @@
     return Constants.R_HEADS;
   }
 
+  /** Fully qualifies a tag name to refs/tags/TAG-NAME */
   public static String normalizeTagRef(String tag) throws BadRequestException {
     String result = tag;
     while (result.startsWith("/")) {
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 6bf3beb..652c49f 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -91,7 +91,7 @@
       Account.Id reviewer,
       int value)
       throws PermissionBackendException {
-    if (change.isMerged()) {
+    if (change.isMerged() && value != 0) {
       return false;
     }
 
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 0e50bb0..a7659d4 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 
 import com.google.common.collect.Streams;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitTypeRecord;
@@ -33,20 +33,14 @@
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
-import java.util.stream.Collectors;
 
 /**
  * Evaluates a submit-like Prolog rule found in the rules.pl file of the current project and filters
  * 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();
-
-  private static final String DEFAULT_MSG = "Error evaluating project rules, check server log";
-
   private final ProjectCache projectCache;
   private final PrologRule prologRule;
   private final PluginSetContext<SubmitRule> submitRules;
@@ -85,21 +79,6 @@
     this.opts = options;
   }
 
-  public static SubmitRecord defaultRuleError() {
-    return createRuleError(DEFAULT_MSG);
-  }
-
-  public static SubmitRecord createRuleError(String err) {
-    SubmitRecord rec = new SubmitRecord();
-    rec.status = SubmitRecord.Status.RULE_ERROR;
-    rec.errorMessage = err;
-    return rec;
-  }
-
-  public static SubmitTypeRecord defaultTypeError() {
-    return SubmitTypeRecord.error(DEFAULT_MSG);
-  }
-
   /**
    * Evaluate the submit rules.
    *
@@ -117,14 +96,22 @@
         }
 
         projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
-      } catch (StorageException | NoSuchProjectException e) {
-        return Collections.singletonList(ruleError("Error looking up change " + cd.getId(), e));
+      } catch (NoSuchProjectException e) {
+        throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
       }
 
-      if ((!opts.allowClosed() || OnlineReindexMode.isActive()) && change.isClosed()) {
-        SubmitRecord rec = new SubmitRecord();
-        rec.status = SubmitRecord.Status.CLOSED;
-        return Collections.singletonList(rec);
+      if (change.isClosed() && (!opts.recomputeOnClosedChanges() || OnlineReindexMode.isActive())) {
+        return cd.notes().getSubmitRecords().stream()
+            .map(
+                r -> {
+                  SubmitRecord record = r.deepCopy();
+                  if (record.status == SubmitRecord.Status.OK) {
+                    // Submit records that were OK when they got merged are CLOSED now.
+                    record.status = SubmitRecord.Status.CLOSED;
+                  }
+                  return record;
+                })
+            .collect(toImmutableList());
       }
 
       // We evaluate all the plugin-defined evaluators,
@@ -133,15 +120,10 @@
           .map(c -> c.call(s -> s.evaluate(cd)))
           .filter(Optional::isPresent)
           .map(Optional::get)
-          .collect(Collectors.toList());
+          .collect(toImmutableList());
     }
   }
 
-  private SubmitRecord ruleError(String err, Exception e) {
-    logger.atSevere().withCause(e).log(err);
-    return defaultRuleError();
-  }
-
   /**
    * Evaluate the submit type rules to get the submit type.
    *
@@ -153,15 +135,10 @@
       try {
         projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
       } catch (NoSuchProjectException e) {
-        return typeError("Error looking up change " + cd.getId(), e);
+        throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
       }
 
       return prologRule.getSubmitType(cd);
     }
   }
-
-  private SubmitTypeRecord typeError(String err, Exception e) {
-    logger.atSevere().withCause(e).log(err);
-    return defaultTypeError();
-  }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRuleOptions.java b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
index ad077c0..3b511e1 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleOptions.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
@@ -25,7 +25,7 @@
 @AutoValue
 public abstract class SubmitRuleOptions {
   private static final SubmitRuleOptions defaults =
-      new AutoValue_SubmitRuleOptions.Builder().allowClosed(false).build();
+      new AutoValue_SubmitRuleOptions.Builder().recomputeOnClosedChanges(false).build();
 
   public static SubmitRuleOptions defaults() {
     return defaults;
@@ -35,13 +35,16 @@
     return defaults.toBuilder();
   }
 
-  public abstract boolean allowClosed();
+  /**
+   * True if the submit rules should be recomputed even when the change is already closed (merged).
+   */
+  public abstract boolean recomputeOnClosedChanges();
 
   public abstract Builder toBuilder();
 
   @AutoValue.Builder
   public abstract static class Builder {
-    public abstract SubmitRuleOptions.Builder allowClosed(boolean allowClosed);
+    public abstract SubmitRuleOptions.Builder recomputeOnClosedChanges(boolean allowClosed);
 
     public abstract SubmitRuleOptions build();
   }
diff --git a/java/com/google/gerrit/server/project/testing/BUILD b/java/com/google/gerrit/server/project/testing/BUILD
index 3112b5a..ab75ec7 100644
--- a/java/com/google/gerrit/server/project/testing/BUILD
+++ b/java/com/google/gerrit/server/project/testing/BUILD
@@ -5,8 +5,5 @@
     testonly = True,
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/entities",
-    ],
+    deps = ["//java/com/google/gerrit/entities"],
 )
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
index 157c746..62f8560 100644
--- a/java/com/google/gerrit/server/project/testing/TestLabels.java
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project.testing;
 
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
 import java.util.Arrays;
@@ -22,7 +23,7 @@
 public class TestLabels {
   public static LabelType codeReview() {
     return label(
-        "Code-Review",
+        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"),
@@ -31,7 +32,8 @@
   }
 
   public static LabelType verified() {
-    return label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+    return label(
+        LabelId.VERIFIED, value(1, LabelId.VERIFIED), value(0, "No score"), value(-1, "Fails"));
   }
 
   public static LabelType patchSetLock() {
diff --git a/java/com/google/gerrit/server/query/change/AfterPredicate.java b/java/com/google/gerrit/server/query/change/AfterPredicate.java
index df5a71d..8f92d9a 100644
--- a/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -14,15 +14,21 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeField;
+import java.sql.Timestamp;
 import java.util.Date;
 
+/**
+ * Predicate that matches a {@link Timestamp} field from the index in a range from the passed {@code
+ * String} representation of the Timestamp value to the maximum supported time.
+ */
 public class AfterPredicate extends TimestampRangeChangePredicate {
   protected final Date cut;
 
-  public AfterPredicate(String value) throws QueryParseException {
-    super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_BEFORE, value);
+  public AfterPredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+      throws QueryParseException {
+    super(def, name, value);
     cut = parse(value);
   }
 
@@ -38,6 +44,10 @@
 
   @Override
   public boolean match(ChangeData cd) {
-    return cd.change().getLastUpdatedOn().getTime() >= cut.getTime();
+    Timestamp valueTimestamp = this.getValueTimestamp(cd);
+    if (valueTimestamp == null) {
+      return false;
+    }
+    return valueTimestamp.getTime() >= cut.getTime();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/AgePredicate.java b/java/com/google/gerrit/server/query/change/AgePredicate.java
index 36eb5b7..d38789f 100644
--- a/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -17,7 +17,6 @@
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -46,7 +45,10 @@
 
   @Override
   public boolean match(ChangeData object) {
-    Change change = object.change();
-    return change != null && change.getLastUpdatedOn().getTime() <= cut;
+    Timestamp valueTimestamp = this.getValueTimestamp(object);
+    if (valueTimestamp == null) {
+      return false;
+    }
+    return valueTimestamp.getTime() <= cut;
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/BeforePredicate.java b/java/com/google/gerrit/server/query/change/BeforePredicate.java
index dacabc0..6e28ce6 100644
--- a/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -14,15 +14,21 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeField;
+import java.sql.Timestamp;
 import java.util.Date;
 
+/**
+ * Predicate that matches a {@link Timestamp} field from the index in a range from the the epoch to
+ * the passed {@code String} representation of the Timestamp value.
+ */
 public class BeforePredicate extends TimestampRangeChangePredicate {
   protected final Date cut;
 
-  public BeforePredicate(String value) throws QueryParseException {
-    super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_BEFORE, value);
+  public BeforePredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+      throws QueryParseException {
+    super(def, name, value);
     cut = parse(value);
   }
 
@@ -38,6 +44,10 @@
 
   @Override
   public boolean match(ChangeData cd) {
-    return cd.change().getLastUpdatedOn().getTime() <= cut.getTime();
+    Timestamp valueTimestamp = this.getValueTimestamp(cd);
+    if (valueTimestamp == null) {
+      return false;
+    }
+    return valueTimestamp.getTime() <= cut.getTime();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 2c43bb7..f7167cd 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -26,13 +26,17 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.ImmutableSortedSet;
 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.SetMultimap;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
@@ -43,6 +47,7 @@
 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;
@@ -50,6 +55,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -69,6 +75,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.RobotCommentNotes;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -103,7 +110,31 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * ChangeData provides lazily loaded interface to change metadata loaded from NoteDb. It can be
+ * constructed by loading from NoteDb, or calling setters. The latter happens when ChangeData is
+ * retrieved through the change index. This happens for Applications that are performance sensitive
+ * (eg. dashboard loads, git protocol negotiation) but can tolerate staleness. In that case, setting
+ * lazyLoad=false disables loading from NoteDb, so we don't accidentally enable a slow path.
+ */
 public class ChangeData {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public enum StorageConstraint {
+    /**
+     * This instance was loaded from the change index. Backfilling missing data from NoteDb is not
+     * allowed.
+     */
+    INDEX_ONLY,
+    /**
+     * This instance was loaded from the change index. Backfilling missing data from NoteDb is
+     * allowed.
+     */
+    INDEX_PRIMARY_NOTEDB_SECONDARY,
+    /** This instance was loaded from NoteDb. */
+    NOTEDB_ONLY
+  }
+
   public static List<Change> asChanges(List<ChangeData> changeDatas) {
     List<Change> result = new ArrayList<>(changeDatas.size());
     for (ChangeData cd : changeDatas) {
@@ -269,7 +300,7 @@
   private final Map<SubmitRuleOptions, List<SubmitRecord>> submitRecords =
       Maps.newLinkedHashMapWithExpectedSize(1);
 
-  private boolean lazyLoad = true;
+  private StorageConstraint storageConstraint = StorageConstraint.NOTEDB_ONLY;
   private Change change;
   private ChangeNotes notes;
   private String commitMessage;
@@ -307,8 +338,8 @@
   private Integer unresolvedCommentCount;
   private Integer totalCommentCount;
   private LabelTypes labelTypes;
-
-  private ImmutableList<byte[]> refStates;
+  private Optional<Timestamp> mergedOn;
+  private ImmutableSetMultimap<NameKey, RefState> refStates;
   private ImmutableList<byte[]> refStatePatterns;
 
   @Inject
@@ -362,11 +393,17 @@
    * lazyLoad} is on, the {@code ChangeData} object will load from the database ("lazily") when a
    * field accessor is called.
    */
-  public ChangeData setLazyLoad(boolean load) {
-    lazyLoad = load;
+  public ChangeData setStorageConstraint(StorageConstraint storageConstraint) {
+    this.storageConstraint = storageConstraint;
     return this;
   }
 
+  /** Returns {@code true} if we allow reading data from NoteDb. */
+  public boolean lazyload() {
+    return storageConstraint.ordinal()
+        >= StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY.ordinal();
+  }
+
   public AllUsersName getAllUsersNameForIndexing() {
     return allUsersName;
   }
@@ -381,7 +418,7 @@
 
   public List<String> currentFilePaths() {
     if (currentFiles == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyList();
       }
       Optional<DiffSummary> p = getDiffSummary();
@@ -392,7 +429,7 @@
 
   private Optional<DiffSummary> getDiffSummary() {
     if (diffSummary == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Optional.empty();
       }
 
@@ -423,7 +460,7 @@
 
   public Optional<ChangedLines> changedLines() {
     if (changedLines == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Optional.empty();
       }
       changedLines = computeChangedLines();
@@ -456,7 +493,7 @@
   }
 
   public Change change() {
-    if (change == null && lazyLoad) {
+    if (change == null && lazyload()) {
       reloadChange();
     }
     return change;
@@ -487,7 +524,7 @@
 
   public ChangeNotes notes() {
     if (notes == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         throw new StorageException("ChangeNotes not available, lazyLoad = false");
       }
       notes = notesFactory.create(project(), legacyId);
@@ -513,7 +550,7 @@
 
   public List<PatchSetApproval> currentApprovals() {
     if (currentApprovals == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyList();
       }
       Change c = change();
@@ -594,6 +631,7 @@
       author = c.getAuthorIdent();
       committer = c.getCommitterIdent();
       parentCount = c.getParentCount();
+      merge = parentCount > 1;
     } catch (IOException e) {
       throw new StorageException(
           String.format(
@@ -607,7 +645,7 @@
   /** Returns the most recent update (i.e. status) per user. */
   public ImmutableSet<AttentionSetUpdate> attentionSet() {
     if (attentionSet == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ImmutableSet.of();
       }
       attentionSet = notes().getAttentionSet();
@@ -616,6 +654,29 @@
   }
 
   /**
+   * Returns the {@link Optional} value of time when the change was merged.
+   *
+   * <p>The value can be set from index field, see {@link ChangeData#setMergedOn} or loaded from the
+   * database (available in {@link ChangeNotes})
+   *
+   * @return {@link Optional} value of time when the change was merged.
+   * @throws StorageException if {@code lazyLoad} is off, {@link ChangeNotes} can not be loaded
+   *     because we do not expect to call the database.
+   */
+  public Optional<Timestamp> getMergedOn() throws StorageException {
+    if (mergedOn == null) {
+      // The value was not loaded yet, try to get from the database.
+      mergedOn = notes().getMergedOn();
+    }
+    return mergedOn;
+  }
+
+  /** Sets the value e.g. when loading from index. */
+  public void setMergedOn(@Nullable Timestamp mergedOn) {
+    this.mergedOn = Optional.ofNullable(mergedOn);
+  }
+
+  /**
    * Sets the specified attention set. If two or more entries refer to the same user, throws an
    * {@link IllegalStateException}.
    */
@@ -662,7 +723,7 @@
    */
   public ListMultimap<PatchSet.Id, PatchSetApproval> approvals() {
     if (allApprovals == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ImmutableListMultimap.of();
       }
       allApprovals = approvalsUtil.byChange(notes());
@@ -670,14 +731,16 @@
     return allApprovals;
   }
 
-  /** @return The submit ('SUBM') approval label */
+  /* @return legacy submit ('SUBM') approval label */
+  // TODO(mariasavtchouk): Deprecate legacy submit label,
+  // see com.google.gerrit.entities.LabelId.LEGACY_SUBMIT_NAME
   public Optional<PatchSetApproval> getSubmitApproval() {
     return currentApprovals().stream().filter(PatchSetApproval::isLegacySubmit).findFirst();
   }
 
   public ReviewerSet reviewers() {
     if (reviewers == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         // We are not allowed to load values from NoteDb. Reviewers were not populated with values
         // from the index. However, we need these values for permission checks.
         throw new IllegalStateException("reviewers not populated");
@@ -693,7 +756,7 @@
 
   public ReviewerByEmailSet reviewersByEmail() {
     if (reviewersByEmail == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ReviewerByEmailSet.empty();
       }
       reviewersByEmail = notes().getReviewersByEmail();
@@ -719,7 +782,7 @@
 
   public ReviewerSet pendingReviewers() {
     if (pendingReviewers == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ReviewerSet.empty();
       }
       pendingReviewers = notes().getPendingReviewers();
@@ -737,7 +800,7 @@
 
   public ReviewerByEmailSet pendingReviewersByEmail() {
     if (pendingReviewersByEmail == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ReviewerByEmailSet.empty();
       }
       pendingReviewersByEmail = notes().getPendingReviewersByEmail();
@@ -747,7 +810,7 @@
 
   public List<ReviewerStatusUpdate> reviewerUpdates() {
     if (reviewerUpdates == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyList();
       }
       reviewerUpdates = approvalsUtil.getReviewerUpdates(notes());
@@ -765,7 +828,7 @@
 
   public Collection<HumanComment> publishedComments() {
     if (publishedComments == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyList();
       }
       publishedComments = commentsUtil.publishedHumanCommentsByChange(notes());
@@ -775,7 +838,7 @@
 
   public Collection<RobotComment> robotComments() {
     if (robotComments == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyList();
       }
       robotComments = commentsUtil.robotCommentsByChange(notes());
@@ -785,7 +848,7 @@
 
   public Integer unresolvedCommentCount() {
     if (unresolvedCommentCount == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return null;
       }
 
@@ -807,7 +870,7 @@
 
   public Integer totalCommentCount() {
     if (totalCommentCount == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return null;
       }
 
@@ -824,7 +887,7 @@
 
   public List<ChangeMessage> messages() {
     if (messages == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyList();
       }
       messages = cmUtil.byChange(notes());
@@ -833,36 +896,35 @@
   }
 
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
-    List<SubmitRecord> records = getCachedSubmitRecord(options);
+    // If the change is not submitted yet, 'strict' and 'lenient' both have the same result. If the
+    // change is submitted, SubmitRecord requested with 'strict' will contain just a single entry
+    // that with status=CLOSED. The latter is cheap to evaluate as we don't have to run any actual
+    // evaluation.
+    List<SubmitRecord> records = submitRecords.get(options);
     if (records == null) {
-      if (!lazyLoad) {
+      if (storageConstraint != StorageConstraint.NOTEDB_ONLY) {
+        // Submit requirements are expensive. We allow loading them only if this change did not
+        // originate from the change index and we can invest the extra time.
+        logger.atWarning().log(
+            "Tried to load SubmitRecords for change fetched from index %s: %d",
+            project(), getId().get());
         return Collections.emptyList();
       }
       records = submitRuleEvaluatorFactory.create(options).evaluate(this);
       submitRecords.put(options, records);
+      if (!change().isClosed() && submitRecords.size() == 1) {
+        // Cache the SubmitRecord with allowClosed = !allowClosed as the SubmitRecord are the same.
+        submitRecords.put(
+            options
+                .toBuilder()
+                .recomputeOnClosedChanges(!options.recomputeOnClosedChanges())
+                .build(),
+            records);
+      }
     }
     return records;
   }
 
-  @Nullable
-  public List<SubmitRecord> getSubmitRecords(SubmitRuleOptions options) {
-    return getCachedSubmitRecord(options);
-  }
-
-  private List<SubmitRecord> getCachedSubmitRecord(SubmitRuleOptions options) {
-    List<SubmitRecord> records = submitRecords.get(options);
-    if (records != null) {
-      return records;
-    }
-
-    if (options.allowClosed() && change != null && change.getStatus().isOpen()) {
-      SubmitRuleOptions openSubmitRuleOptions = options.toBuilder().allowClosed(false).build();
-      return submitRecords.get(openSubmitRuleOptions);
-    }
-
-    return null;
-  }
-
   public void setSubmitRecords(SubmitRuleOptions options, List<SubmitRecord> records) {
     submitRecords.put(options, records);
   }
@@ -893,7 +955,7 @@
       } else if (c.isWorkInProgress()) {
         return null;
       } else {
-        if (!lazyLoad) {
+        if (!lazyload()) {
           return null;
         }
         PatchSet ps = currentPatchSet();
@@ -930,7 +992,7 @@
         return null;
       }
     }
-    return parentCount > 1;
+    return merge;
   }
 
   public Set<Account.Id> editsByUser() {
@@ -939,7 +1001,7 @@
 
   public Map<Account.Id, Ref> editRefs() {
     if (editsByUser == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyMap();
       }
       Change c = change();
@@ -971,7 +1033,7 @@
 
   public Map<Account.Id, Ref> draftRefs() {
     if (draftsByUser == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptyMap();
       }
       Change c = change();
@@ -1016,7 +1078,7 @@
 
   public Set<Account.Id> reviewedBy() {
     if (reviewedBy == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptySet();
       }
       Change c = change();
@@ -1048,7 +1110,7 @@
 
   public Set<String> hashtags() {
     if (hashtags == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return Collections.emptySet();
       }
       hashtags = notes().getHashtags();
@@ -1062,7 +1124,7 @@
 
   public ImmutableListMultimap<Account.Id, String> stars() {
     if (stars == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ImmutableListMultimap.of();
       }
       ImmutableListMultimap.Builder<Account.Id, String> b = ImmutableListMultimap.builder();
@@ -1080,7 +1142,7 @@
 
   public ImmutableMap<Account.Id, StarRef> starRefs() {
     if (starRefs == null) {
-      if (!lazyLoad) {
+      if (!lazyload()) {
         return ImmutableMap.of();
       }
       starRefs = requireNonNull(starredChangesUtil).byChange(legacyId);
@@ -1098,7 +1160,7 @@
       if (stars != null) {
         starsOf = StarsOf.create(accountId, stars.get(accountId));
       } else {
-        if (!lazyLoad) {
+        if (!lazyload()) {
           return ImmutableSet.of();
         }
         starsOf = StarsOf.create(accountId, starredChangesUtil.getLabels(accountId, legacyId));
@@ -1144,12 +1206,38 @@
     }
   }
 
-  public ImmutableList<byte[]> getRefStates() {
+  public SetMultimap<NameKey, RefState> getRefStates() {
+    if (refStates == null) {
+      if (!lazyload()) {
+        return ImmutableSetMultimap.of();
+      }
+
+      ImmutableSetMultimap.Builder<NameKey, RefState> result = ImmutableSetMultimap.builder();
+      editRefs().values().forEach(r -> result.put(project, RefState.of(r)));
+      starRefs().values().forEach(r -> result.put(allUsersName, RefState.of(r.ref())));
+
+      // 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.
+      RobotCommentNotes robotNotes = notes().getRobotCommentNotes();
+      result.put(project, RefState.create(robotNotes.getRefName(), robotNotes.getMetaId()));
+      draftRefs().values().forEach(r -> result.put(allUsersName, RefState.of(r)));
+
+      refStates = result.build();
+    }
+
     return refStates;
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public void setRefStates(Iterable<byte[]> refStates) {
-    this.refStates = ImmutableList.copyOf(refStates);
+    // TODO(hanwen): remove Google use, and drop this method.
+    setRefStates(RefState.parseStates(refStates));
+  }
+
+  public void setRefStates(ImmutableSetMultimap<Project.NameKey, RefState> refStates) {
+    this.refStates = refStates;
   }
 
   public ImmutableList<byte[]> getRefStatePatterns() {
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index c6bcd60..a66c43ae 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -88,7 +88,7 @@
             ? permissionBackend.absentUser(user.getAccountId())
             : permissionBackend.user(
                 Optional.of(user)
-                    .filter(u -> u instanceof SingleGroupUser || u instanceof InternalUser)
+                    .filter(u -> u instanceof GroupBackedUser || u instanceof InternalUser)
                     .orElseGet(anonymousUserProvider::get));
     try {
       withUser.change(cd).check(ChangePermission.READ);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 04e6d49..b02b52d 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -142,7 +142,7 @@
   public static final String FIELD_ASSIGNEE = "assignee";
   public static final String FIELD_AUTHOR = "author";
   public static final String FIELD_EXACTAUTHOR = "exactauthor";
-  public static final String FIELD_BEFORE = "before";
+
   public static final String FIELD_CHANGE = "change";
   public static final String FIELD_CHANGE_ID = "change_id";
   public static final String FIELD_COMMENT = "comment";
@@ -169,9 +169,11 @@
   public static final String FIELD_LIMIT = "limit";
   public static final String FIELD_MERGE = "merge";
   public static final String FIELD_MERGEABLE = "mergeable2";
+  public static final String FIELD_MERGED_ON = "mergedon";
   public static final String FIELD_MESSAGE = "message";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
+  public static final String FIELD_PARENTOF = "parentof";
   public static final String FIELD_PARENTPROJECT = "parentproject";
   public static final String FIELD_PATH = "path";
   public static final String FIELD_PENDING_REVIEWER = "pendingreviewer";
@@ -199,11 +201,19 @@
   public static final String FIELD_CHERRY_PICK_OF_CHANGE = "cherrypickofchange";
   public static final String FIELD_CHERRY_PICK_OF_PATCHSET = "cherrypickofpatchset";
 
+  public static final String ARG_ID_NAME = "name";
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
   public static final String ARG_ID_OWNER = "owner";
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
 
+  public static final String OPERATOR_MERGED_BEFORE = "mergedbefore";
+  public static final String OPERATOR_MERGED_AFTER = "mergedafter";
+
+  // Operators to match on the last time the change was updated. Naming for legacy reasons.
+  public static final String OPERATOR_BEFORE = "before";
+  public static final String OPERATOR_AFTER = "after";
+
   private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> mydef =
       new QueryBuilder.Definition<>(ChangeQueryBuilder.class);
 
@@ -470,7 +480,7 @@
 
   @Operator
   public Predicate<ChangeData> before(String value) throws QueryParseException {
-    return new BeforePredicate(value);
+    return new BeforePredicate(ChangeField.UPDATED, ChangeQueryBuilder.OPERATOR_BEFORE, value);
   }
 
   @Operator
@@ -480,7 +490,7 @@
 
   @Operator
   public Predicate<ChangeData> after(String value) throws QueryParseException {
-    return new AfterPredicate(value);
+    return new AfterPredicate(ChangeField.UPDATED, ChangeQueryBuilder.OPERATOR_AFTER, value);
   }
 
   @Operator
@@ -489,6 +499,28 @@
   }
 
   @Operator
+  public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
+    if (!args.index.getSchema().hasField(ChangeField.MERGED_ON)) {
+      throw new QueryParseException(
+          String.format(
+              "'%s' operator is not supported by change index version", OPERATOR_MERGED_BEFORE));
+    }
+    return new BeforePredicate(
+        ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
+    if (!args.index.getSchema().hasField(ChangeField.MERGED_ON)) {
+      throw new QueryParseException(
+          String.format(
+              "'%s' operator is not supported by change index version", OPERATOR_MERGED_AFTER));
+    }
+    return new AfterPredicate(
+        ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
+  }
+
+  @Operator
   public Predicate<ChangeData> change(String query) throws QueryParseException {
     Optional<ChangeTriplet> triplet = ChangeTriplet.parse(query);
     if (triplet.isPresent()) {
@@ -704,6 +736,16 @@
   }
 
   @Operator
+  public Predicate<ChangeData> parentof(String value) throws QueryParseException {
+    List<ChangeData> changes = parseChangeData(value);
+    List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
+    for (ChangeData c : changes) {
+      or.add(new ParentOfPredicate(value, c, args.repoManager));
+    }
+    return Predicate.or(or);
+  }
+
+  @Operator
   public Predicate<ChangeData> parentproject(String name) {
     return new ParentProjectPredicate(args.projectCache, args.childProjects, name);
   }
@@ -1034,7 +1076,7 @@
       for (GroupReference ref : suggestions) {
         ids.add(ref.getUUID());
       }
-      return visibleto(new SingleGroupUser(ids));
+      return visibleto(new GroupBackedUser(ids));
     }
 
     throw error("No user or group matches \"" + who + "\".");
@@ -1232,9 +1274,36 @@
   }
 
   @Operator
-  public Predicate<ChangeData> query(String name) throws QueryParseException {
+  public Predicate<ChangeData> query(String value) throws QueryParseException {
+    // [name=]<name>[,user=<user>] || [user=<user>,][name=]<name>
+    PredicateArgs inputArgs = new PredicateArgs(value);
+    String name = null;
+    Account.Id account = null;
+
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
-      VersionedAccountQueries q = VersionedAccountQueries.forUser(self());
+      // [name=]<name>
+      if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+        name = inputArgs.keyValue.get(ARG_ID_NAME);
+      } else if (inputArgs.positional.size() == 1) {
+        name = Iterables.getOnlyElement(inputArgs.positional);
+      } else if (inputArgs.positional.size() > 1) {
+        throw new QueryParseException("Error parsing named query: " + value);
+      }
+
+      // [,user=<user>]
+      if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        if (accounts != null && accounts.size() > 1) {
+          throw error(
+              String.format(
+                  "\"%s\" resolves to multiple accounts", inputArgs.keyValue.get(ARG_ID_USER)));
+        }
+        account = (accounts == null ? self() : Iterables.getOnlyElement(accounts));
+      } else {
+        account = self();
+      }
+
+      VersionedAccountQueries q = VersionedAccountQueries.forUser(account);
       q.load(args.allUsersName, git);
       String query = q.getQueryList().getQuery(name);
       if (query != null) {
@@ -1244,7 +1313,7 @@
       throw new QueryParseException(
           "Unknown named query (no " + args.allUsersName + " repo): " + name, e);
     } catch (IOException | ConfigInvalidException e) {
-      throw new QueryParseException("Error parsing named query: " + name, e);
+      throw new QueryParseException("Error parsing named query: " + value, e);
     }
     throw new QueryParseException("Unknown named query: " + name);
   }
@@ -1256,19 +1325,46 @@
   }
 
   @Operator
-  public Predicate<ChangeData> destination(String name) throws QueryParseException {
+  public Predicate<ChangeData> destination(String value) throws QueryParseException {
+    // [name=]<name>[,user=<user>] || [user=<user>,][name=]<name>
+    PredicateArgs inputArgs = new PredicateArgs(value);
+    String name = null;
+    Account.Id account = null;
+
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
-      VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
+      // [name=]<name>
+      if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+        name = inputArgs.keyValue.get(ARG_ID_NAME);
+      } else if (inputArgs.positional.size() == 1) {
+        name = Iterables.getOnlyElement(inputArgs.positional);
+      } else if (inputArgs.positional.size() > 1) {
+        throw new QueryParseException("Error parsing named destination: " + value);
+      }
+
+      // [,user=<user>]
+      if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        if (accounts != null && accounts.size() > 1) {
+          throw error(
+              String.format(
+                  "\"%s\" resolves to multiple accounts", inputArgs.keyValue.get(ARG_ID_USER)));
+        }
+        account = (accounts == null ? self() : Iterables.getOnlyElement(accounts));
+      } else {
+        account = self();
+      }
+
+      VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
       d.load(args.allUsersName, git);
       Set<BranchNameKey> destinations = d.getDestinationList().getDestinations(name);
       if (destinations != null && !destinations.isEmpty()) {
-        return new DestinationPredicate(destinations, name);
+        return new DestinationPredicate(destinations, value);
       }
     } catch (RepositoryNotFoundException e) {
       throw new QueryParseException(
           "Unknown named destination (no " + args.allUsersName + " repo): " + name, e);
     } catch (IOException | ConfigInvalidException e) {
-      throw new QueryParseException("Error parsing named destination: " + name, e);
+      throw new QueryParseException("Error parsing named destination: " + value, e);
     }
     throw new QueryParseException("Unknown named destination: " + name);
   }
@@ -1481,14 +1577,18 @@
   }
 
   private List<Change> parseChange(String value) throws QueryParseException {
+    return asChanges(parseChangeData(value));
+  }
+
+  private List<ChangeData> parseChangeData(String value) throws QueryParseException {
     if (PAT_LEGACY_ID.matcher(value).matches()) {
       Optional<Change.Id> id = Change.Id.tryParse(value);
       if (!id.isPresent()) {
         throw error("Invalid change id " + value);
       }
-      return asChanges(args.queryProvider.get().byLegacyChangeId(id.get()));
+      return args.queryProvider.get().byLegacyChangeId(id.get());
     } else if (PAT_CHANGE_ID.matcher(value).matches()) {
-      List<Change> changes = asChanges(args.queryProvider.get().byKeyPrefix(parseChangeId(value)));
+      List<ChangeData> changes = args.queryProvider.get().byKeyPrefix(parseChangeId(value));
       if (changes.isEmpty()) {
         throw error("Change " + value + " not found");
       }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 9677321..7d02ecd 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
@@ -33,10 +32,8 @@
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.account.AccountLimits;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
-import com.google.gerrit.server.change.PluginDefinedAttributesFactory;
 import com.google.gerrit.server.change.PluginDefinedInfosFactory;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
@@ -61,7 +58,6 @@
 public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
     implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider, PluginDefinedInfosFactory {
   private final Provider<CurrentUser> userProvider;
-  private final ImmutableListMultimap<String, ChangeAttributeFactory> attributeFactoriesByPlugin;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
   private final List<Extension<ChangePluginDefinedInfoFactory>>
@@ -83,7 +79,6 @@
       IndexConfig indexConfig,
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
-      DynamicSet<ChangeAttributeFactory> attributeFactories,
       Sequences sequences,
       ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory,
       DynamicSet<ChangePluginDefinedInfoFactory> changePluginDefinedInfoFactories) {
@@ -99,14 +94,6 @@
     this.changeIsVisibleToPredicateFactory = changeIsVisibleToPredicateFactory;
     this.sequences = sequences;
 
-    ImmutableListMultimap.Builder<String, ChangeAttributeFactory> factoriesBuilder =
-        ImmutableListMultimap.builder();
-    ImmutableListMultimap.Builder<String, ChangePluginDefinedInfoFactory> infosFactoriesBuilder =
-        ImmutableListMultimap.builder();
-    // Eagerly call Extension#get() rather than storing Extensions, since that method invokes the
-    // Provider on every call, which could be expensive if we invoke it once for every change.
-    attributeFactories.entries().forEach(e -> factoriesBuilder.put(e.getPluginName(), e.get()));
-    attributeFactoriesByPlugin = factoriesBuilder.build();
     changePluginDefinedInfoFactories
         .entries()
         .forEach(e -> changePluginDefinedInfoFactoriesByPlugin.add(e));
@@ -134,18 +121,6 @@
     return dynamicBeans.get(plugin);
   }
 
-  public PluginDefinedAttributesFactory getAttributesFactory() {
-    return this::buildPluginInfo;
-  }
-
-  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
-    return PluginDefinedAttributesFactories.createAll(
-        cd,
-        this,
-        attributeFactoriesByPlugin.entries().stream()
-            .map(e -> new Extension<>(e.getKey(), e::getValue)));
-  }
-
   public PluginDefinedInfosFactory getInfosFactory() {
     return this::createPluginDefinedInfos;
   }
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 4b46888..30d5e2f 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -73,7 +73,7 @@
     }
 
     boolean hasVote = false;
-    object.setLazyLoad(true);
+    object.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
         hasVote = true;
diff --git a/java/com/google/gerrit/server/query/change/GroupBackedUser.java b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
new file mode 100644
index 0000000..d0d5735
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import java.util.Set;
+
+/**
+ * Representation of a user that does not have a Gerrit account.
+ *
+ * <p>This user representation is intended to be used to check permissions for groups:
+ *
+ * <p>There are occasions where we need to check if a resource - such as a change - is accessible by
+ * a group. Our entire {@link com.google.gerrit.server.permissions.PermissionBackend} works solely
+ * with {@link CurrentUser}. This class can be used to check permissions on a synthetic user with
+ * the given group memberships. Any real Gerrit user with the same group memberships would receive
+ * the same permission check results.
+ */
+public final class GroupBackedUser extends CurrentUser {
+  private final GroupMembership groups;
+
+  /**
+   * Creates a new instance
+   *
+   * @param groups this set has to include all parent groups the user is contained in through
+   *     subgroup membership. Given a set of groups that contains the user directly, callers can use
+   *     {@link
+   *     com.google.gerrit.server.account.GroupIncludeCache#parentGroupsOf(AccountGroup.UUID)} to
+   *     resolve parent groups.
+   */
+  public GroupBackedUser(Set<AccountGroup.UUID> groups) {
+    this.groups = new ListGroupMembership(groups);
+  }
+
+  @Override
+  public GroupMembership getEffectiveGroups() {
+    return groups;
+  }
+
+  @Override
+  public String getLoggableName() {
+    return "GroupBackedUser with memberships: " + groups.getKnownGroups();
+  }
+
+  @Override
+  public Object getCacheKey() {
+    return groups.getKnownGroups();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 6907f15..cf53a1b 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -265,7 +265,8 @@
     }
 
     if (includeSubmitRecords) {
-      SubmitRuleOptions options = SubmitRuleOptions.builder().allowClosed(true).build();
+      SubmitRuleOptions options =
+          SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
       eventFactory.addSubmitRecords(c, submitRuleEvaluatorFactory.create(options).evaluate(d));
     }
 
@@ -333,15 +334,9 @@
       eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
     }
 
-    c.plugins = queryProcessor.getAttributesFactory().create(d);
     List<PluginDefinedInfo> pluginInfos = pluginInfosByChange.get(d.getId());
     if (!pluginInfos.isEmpty()) {
-      if (c.plugins == null) {
-        c.plugins = pluginInfos;
-      } else {
-        c.plugins = new ArrayList<>(c.plugins);
-        c.plugins.addAll(pluginInfos);
-      }
+      c.plugins = pluginInfos;
     }
     return c;
   }
diff --git a/java/com/google/gerrit/server/query/change/ParentOfPredicate.java b/java/com/google/gerrit/server/query/change/ParentOfPredicate.java
new file mode 100644
index 0000000..e48d586
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ParentOfPredicate.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.OperatorPredicate;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class ParentOfPredicate extends OperatorPredicate<ChangeData>
+    implements Matchable<ChangeData> {
+  protected final Set<RevCommit> parents;
+
+  public ParentOfPredicate(String value, ChangeData change, GitRepositoryManager repoManager) {
+    super(ChangeQueryBuilder.FIELD_PARENTOF, value);
+    this.parents = getParents(change, repoManager);
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    return changeData.patchSets().stream().anyMatch(ps -> parents.contains(ps.commitId()));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  protected Set<RevCommit> getParents(ChangeData change, GitRepositoryManager repoManager) {
+    PatchSet ps = change.currentPatchSet();
+    try (Repository repo = repoManager.openRepository(change.project());
+        RevWalk walk = new RevWalk(repo)) {
+      RevCommit c = walk.parseCommit(ps.commitId());
+      return Sets.newHashSet(c.getParents());
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format(
+              "Loading commit %s for ps %d of change %d failed.",
+              ps.commitId(), ps.id().get(), ps.id().changeId().get()),
+          e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/java/com/google/gerrit/server/query/change/SingleGroupUser.java
deleted file mode 100644
index c451d46..0000000
--- a/java/com/google/gerrit/server/query/change/SingleGroupUser.java
+++ /dev/null
@@ -1,44 +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.server.query.change;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.account.ListGroupMembership;
-import java.util.Set;
-
-public final class SingleGroupUser extends CurrentUser {
-  private final GroupMembership groups;
-
-  public SingleGroupUser(AccountGroup.UUID groupId) {
-    this(ImmutableSet.of(groupId));
-  }
-
-  public SingleGroupUser(Set<AccountGroup.UUID> groups) {
-    this.groups = new ListGroupMembership(groups);
-  }
-
-  @Override
-  public GroupMembership getEffectiveGroups() {
-    return groups;
-  }
-
-  @Override
-  public Object getCacheKey() {
-    return groups.getKnownGroups();
-  }
-}
diff --git a/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
index 8248bf5..f6b194b 100644
--- a/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.query.group;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.query.account.AccountQueryBuilder;
 
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 5231c5a..992f60d 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -16,10 +16,10 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupField;
 import java.util.Locale;
 
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
index fbc8d0e..89c802d 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.List;
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
index ed687f3..e5fb036 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.group.GroupQueryBuilder.FIELD_LIMIT;
 
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.AndSource;
 import com.google.gerrit.index.query.IndexPredicate;
@@ -26,7 +27,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndexRewriter;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
diff --git a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
index 5732873..b9b58b8 100644
--- a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
+++ b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
@@ -21,10 +21,10 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.InternalQuery;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.inject.Inject;
 import java.util.List;
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
index 11783fc..5465d6d 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
@@ -39,6 +39,8 @@
  *
  * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
  * holding on to a single instance.
+ *
+ * <p>By default, enforces visibility to CurrentUser.
  */
 public class ProjectQueryProcessor extends QueryProcessor<ProjectData> {
   private final PermissionBackend permissionBackend;
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 4c07d8a..708e860 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -19,13 +19,11 @@
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/json",
-        "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/time",
-        "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
         "//lib:blame-cache",
         "//lib:gson",
diff --git a/java/com/google/gerrit/server/restapi/RestApiModule.java b/java/com/google/gerrit/server/restapi/RestApiModule.java
index dc2fe5f..909c1f4 100644
--- a/java/com/google/gerrit/server/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/RestApiModule.java
@@ -29,5 +29,6 @@
     install(new com.google.gerrit.server.restapi.group.Module());
     install(new PluginRestApiModule());
     install(new com.google.gerrit.server.restapi.project.Module());
+    install(new com.google.gerrit.server.restapi.project.Module.BatchModule());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
index 119e2e4..61ff6b8 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
@@ -53,6 +53,7 @@
       return new AccountResource(accountResolver.resolve(id.get()).asUniqueUser());
     } catch (UnresolvableAccountException e) {
       if (e.isSelf()) {
+        // Must be authenticated to use 'me' or 'self'.
         throw new AuthException(e.getMessage(), e);
       }
       throw new ResourceNotFoundException(e.getMessage(), e);
diff --git a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
index 6df6c3c..9952987 100644
--- a/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
+++ b/java/com/google/gerrit/server/restapi/account/GetSshKeys.java
@@ -60,8 +60,7 @@
 
   @Override
   public Response<List<SshKeyInfo>> apply(AccountResource rsrc)
-      throws AuthException, RepositoryNotFoundException, IOException, ConfigInvalidException,
-          PermissionBackendException {
+      throws AuthException, IOException, ConfigInvalidException, PermissionBackendException {
     if (!self.get().hasSameAccountId(rsrc.getUser())) {
       permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
     }
diff --git a/java/com/google/gerrit/server/restapi/account/Module.java b/java/com/google/gerrit/server/restapi/account/Module.java
index 51055b8..7570465 100644
--- a/java/com/google/gerrit/server/restapi/account/Module.java
+++ b/java/com/google/gerrit/server/restapi/account/Module.java
@@ -76,8 +76,6 @@
     get(SSH_KEY_KIND).to(GetSshKey.class);
     delete(SSH_KEY_KIND).to(DeleteSshKey.class);
 
-    get(ACCOUNT_KIND, "oauthtoken").to(GetOAuthToken.class);
-
     get(ACCOUNT_KIND, "avatar").to(GetAvatar.class);
     get(ACCOUNT_KIND, "avatar.change.url").to(GetAvatarChangeUrl.class);
 
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index 17e31bd..2131070 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -94,7 +94,7 @@
       throws RestApiException, IOException, PermissionBackendException {
     Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
     for (ProjectWatchInfo info : input) {
-      if (info.project == null) {
+      if (info.project == null || info.project.trim().isEmpty()) {
         throw new BadRequestException("project name must be specified");
       }
 
diff --git a/java/com/google/gerrit/server/restapi/account/PutActive.java b/java/com/google/gerrit/server/restapi/account/PutActive.java
index a80ab3f..3b431db 100644
--- a/java/com/google/gerrit/server/restapi/account/PutActive.java
+++ b/java/com/google/gerrit/server/restapi/account/PutActive.java
@@ -32,7 +32,7 @@
  *
  * <p>This REST endpoint handles {@code PUT /accounts/<account-identifier>/active} requests.
  *
- * <p>Only active accounts can login into Gerrit.
+ * <p>Only active accounts can login into Gerrit, or are suggested as reviewers.
  *
  * <p>Marking an account as inactive is handled by {@link DeleteActive}.
  */
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index c108dcb..e67fe9e 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -17,6 +17,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -96,8 +97,7 @@
 
   @Singleton
   public static class Create
-      implements RestCollectionCreateView<
-          AccountResource, AccountResource.StarredChange, EmptyInput> {
+      implements RestCollectionCreateView<AccountResource, AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
     private final ChangesCollection changes;
     private final StarredChangesUtil starredChangesUtil;
@@ -113,7 +113,7 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource rsrc, IdString id, EmptyInput in)
+    public Response<?> apply(AccountResource rsrc, IdString id, Input in)
         throws RestApiException, IOException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to add starred change");
@@ -148,7 +148,7 @@
   }
 
   @Singleton
-  public static class Put implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
+  public static class Put implements RestModifyView<AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
 
     @Inject
@@ -157,8 +157,7 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
-        throws AuthException {
+    public Response<?> apply(AccountResource.StarredChange rsrc, Input in) throws AuthException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed update starred changes");
       }
@@ -167,7 +166,7 @@
   }
 
   @Singleton
-  public static class Delete implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
+  public static class Delete implements RestModifyView<AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
@@ -178,7 +177,7 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
+    public Response<?> apply(AccountResource.StarredChange rsrc, Input in)
         throws AuthException, IOException, IllegalLabelException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed remove starred change");
@@ -192,6 +191,4 @@
       return Response.none();
     }
   }
-
-  public static class EmptyInput {}
 }
diff --git a/java/com/google/gerrit/server/restapi/account/Stars.java b/java/com/google/gerrit/server/restapi/account/Stars.java
index c27bdd8..cc362f2 100644
--- a/java/com/google/gerrit/server/restapi/account/Stars.java
+++ b/java/com/google/gerrit/server/restapi/account/Stars.java
@@ -46,6 +46,12 @@
 import java.util.Set;
 import java.util.SortedSet;
 
+/**
+ * Implements adding label stars to changes.
+ *
+ * <p>This handles {@code POST} and {@code GET} for {@code
+ * /accounts/<account-identifier>/stars.changes/<change ID>}.
+ */
 @Singleton
 public class Stars implements ChildCollection<AccountResource, AccountResource.Star> {
 
@@ -70,6 +76,7 @@
   public Star parse(AccountResource parent, IdString id)
       throws RestApiException, PermissionBackendException, IOException {
     IdentifiedUser user = parent.getUser();
+    // This enforces visibility of the change.
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
     Set<String> labels = starredChangesUtil.getLabels(user.getAccountId(), change.getId());
     return new AccountResource.Star(user, change, labels);
@@ -87,6 +94,7 @@
 
   @Singleton
   public static class ListStarredChanges implements RestReadView<AccountResource> {
+
     private final Provider<CurrentUser> self;
     private final ChangesCollection changes;
 
@@ -121,6 +129,7 @@
 
   @Singleton
   public static class Get implements RestReadView<AccountResource.Star> {
+
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
@@ -142,6 +151,7 @@
 
   @Singleton
   public static class Post implements RestModifyView<AccountResource.Star, StarsInput> {
+
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index a5be14f..cb1256c 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -42,6 +42,7 @@
 public class AddToAttentionSet
     implements RestCollectionModifyView<
         ChangeResource, AttentionSetEntryResource, AttentionSetInput> {
+
   private final BatchUpdate.Factory updateFactory;
   private final AccountResolver accountResolver;
   private final AddToAttentionSetOp.Factory opFactory;
@@ -72,8 +73,9 @@
   public Response<AccountInfo> apply(ChangeResource changeResource, AttentionSetInput input)
       throws Exception {
     AttentionSetUtil.validateInput(input);
+    Account.Id attentionUserId =
+        AttentionSetUtil.resolveAccount(accountResolver, changeResource.getNotes(), input.user);
 
-    Account.Id attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
     if (serviceUserClassifier.isServiceUser(attentionUserId)) {
       throw new BadRequestException(
           String.format(
diff --git a/java/com/google/gerrit/server/restapi/change/AttentionSet.java b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
index 45d78dc..f72fe64ec 100644
--- a/java/com/google/gerrit/server/restapi/change/AttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
@@ -17,14 +17,15 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.change.AttentionSetEntryResource;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -58,12 +59,10 @@
 
   @Override
   public AttentionSetEntryResource parse(ChangeResource changeResource, IdString idString)
-      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
-    try {
-      Account.Id accountId = accountResolver.resolve(idString.get()).asUnique().account().id();
-      return new AttentionSetEntryResource(changeResource, accountId);
-    } catch (UnresolvableAccountException e) {
-      throw new ResourceNotFoundException(idString, e);
-    }
+      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException,
+          BadRequestException {
+    Account.Id accountId =
+        AttentionSetUtil.resolveAccount(accountResolver, changeResource.getNotes(), idString.get());
+    return new AttentionSetEntryResource(changeResource, accountId);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 7a15a1d..318b0fa 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -210,7 +210,7 @@
         }
         try {
           editInfo.files =
-              fileInfoJson.toFileInfoMap(
+              fileInfoJson.getFileInfoMap(
                   rsrc.getChange(), edit.get().getEditCommit(), basePatchSet);
         } catch (PatchListNotAvailableException e) {
           throw new ResourceNotFoundException(e.getMessage());
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 6ee3284c..ee6484c 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -44,7 +43,6 @@
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.ResetCherryPickOp;
 import com.google.gerrit.server.change.SetCherryPickOp;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -114,7 +112,6 @@
   private final ApprovalsUtil approvalsUtil;
   private final NotifyResolver notifyResolver;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   CherryPickChange(
@@ -131,8 +128,7 @@
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
       NotifyResolver notifyResolver,
-      BatchUpdate.Factory batchUpdateFactory,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      BatchUpdate.Factory batchUpdateFactory) {
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
@@ -147,7 +143,6 @@
     this.approvalsUtil = approvalsUtil;
     this.notifyResolver = notifyResolver;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.urlFormatter = urlFormatter;
   }
 
   /**
@@ -282,20 +277,36 @@
                 input.parent, commitToCherryPick.getParentCount()));
       }
 
-      String message = Strings.nullToEmpty(input.message).trim();
-      message = message.isEmpty() ? commitToCherryPick.getFullMessage() : message;
+      // If the commit message is not set, the commit message of the source commit will be used.
+      String commitMessage = Strings.nullToEmpty(input.message);
+      commitMessage = commitMessage.isEmpty() ? commitToCherryPick.getFullMessage() : commitMessage;
 
-      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverTimeZone);
+      String destChangeId = getDestinationChangeId(commitMessage, changeIdForNewChange);
 
-      final ObjectId generatedChangeId =
-          changeIdForNewChange != null
-              ? changeIdForNewChange
-              : CommitMessageUtil.generateChangeId();
-      String commitMessage = ChangeIdUtil.insertId(message, generatedChangeId).trim() + '\n';
+      ChangeData destChange = null;
+      if (destChangeId != null) {
+        // If "idForNewChange" is not null we must fail, since we are not expecting an already
+        // existing change.
+        destChange = getDestChangeWithVerification(destChangeId, dest, idForNewChange != null);
+      }
+
+      if (changeIdForNewChange != null) {
+        // If Change-Id was explicitly provided for the new change, override the value in commit
+        // message.
+        commitMessage = ChangeIdUtil.insertId(commitMessage, changeIdForNewChange, true);
+      } else if (destChangeId == null) {
+        // If commit message did not specify Change-Id, generate a new one and insert to the
+        // message.
+        commitMessage =
+            ChangeIdUtil.insertId(commitMessage, CommitMessageUtil.generateChangeId(), true);
+      }
+      commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(commitMessage);
 
       CodeReviewCommit cherryPickCommit;
       ProjectState projectState =
           projectCache.get(dest.project()).orElseThrow(noSuchProject(dest.project()));
+      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverTimeZone);
+
       try {
         MergeUtil mergeUtil;
         if (input.allowConflicts) {
@@ -317,86 +328,54 @@
                 input.parent - 1,
                 input.allowEmpty,
                 input.allowConflicts);
-
-        Change.Key changeKey;
-        final List<String> idList =
-            ChangeUtil.getChangeIdsFromFooter(cherryPickCommit, urlFormatter.get());
-        if (!idList.isEmpty()) {
-          final String idStr = idList.get(idList.size() - 1).trim();
-          changeKey = Change.key(idStr);
-        } else {
-          changeKey = Change.key("I" + generatedChangeId.name());
-        }
-
-        BranchNameKey newDest = BranchNameKey.create(project, destRef.getName());
-        List<ChangeData> destChanges =
-            queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
-        if (destChanges.size() > 1) {
-          throw new InvalidChangeOperationException(
-              "Several changes with key "
-                  + changeKey
-                  + " reside on the same branch. "
-                  + "Cannot create a new patch set.");
-        }
-        try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, timestamp)) {
-          bu.setRepository(git, revWalk, oi);
-          bu.setNotify(resolveNotify(input));
-          Change.Id changeId;
-          String newTopic = null;
-          if (input.topic != null) {
-            newTopic = Strings.emptyToNull(input.topic.trim());
-          }
-          if (newTopic == null
-              && sourceChange != null
-              && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
-            newTopic = sourceChange.getTopic() + "-" + newDest.shortName();
-          }
-          if (destChanges.size() == 1) {
-            // The change key exists on the destination branch. The cherry pick
-            // will be added as a new patch set. If "idForNewChange" is not null we must fail,
-            // since we are not expecting an already existing change.
-            if (idForNewChange != null) {
-              throw new InvalidChangeOperationException(
-                  String.format(
-                      "Expected that cherry-pick of commit %s with Change-Id %s to branch %s"
-                          + "in project %s creates a new change, but found existing change %d",
-                      sourceCommit.getName(),
-                      changeKey,
-                      dest.branch(),
-                      dest.project(),
-                      destChanges.get(0).getId().get()));
-            }
-            changeId =
-                insertPatchSet(
-                    bu,
-                    git,
-                    destChanges.get(0).notes(),
-                    cherryPickCommit,
-                    sourceChange,
-                    newTopic,
-                    workInProgress);
-          } else {
-            // Change key not found on destination branch. We can create a new
-            // change.
-            changeId =
-                createNewChange(
-                    bu,
-                    cherryPickCommit,
-                    dest.branch(),
-                    newTopic,
-                    project,
-                    sourceChange,
-                    sourceCommit,
-                    input,
-                    revertedChange,
-                    idForNewChange,
-                    workInProgress);
-          }
-          bu.execute();
-          return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
-        }
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
-        throw new IntegrationConflictException("Cherry pick failed: " + e.getMessage());
+        throw new IntegrationConflictException("Cherry pick failed: " + e.getMessage(), e);
+      }
+
+      try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, timestamp)) {
+        bu.setRepository(git, revWalk, oi);
+        bu.setNotify(resolveNotify(input));
+        Change.Id changeId;
+        String newTopic = null;
+        if (input.topic != null) {
+          newTopic = Strings.emptyToNull(input.topic.trim());
+        }
+        if (newTopic == null
+            && sourceChange != null
+            && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
+          newTopic = sourceChange.getTopic() + "-" + dest.shortName();
+        }
+        if (destChange != null) {
+          // The change key exists on the destination branch. The cherry pick
+          // will be added as a new patch set.
+          changeId =
+              insertPatchSet(
+                  bu,
+                  git,
+                  destChange.notes(),
+                  cherryPickCommit,
+                  sourceChange,
+                  newTopic,
+                  workInProgress);
+        } else {
+          // Change key not found on destination branch. We can create a new
+          // change.
+          changeId =
+              createNewChange(
+                  bu,
+                  cherryPickCommit,
+                  dest.branch(),
+                  newTopic,
+                  project,
+                  sourceChange,
+                  sourceCommit,
+                  input,
+                  revertedChange,
+                  idForNewChange,
+                  workInProgress);
+        }
+        bu.execute();
+        return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
       }
     }
   }
@@ -576,4 +555,81 @@
 
     return stringBuilder.toString();
   }
+
+  /**
+   * Returns the Change-Id of destination change (as intended by the caller of cherry-pick
+   * operation).
+   *
+   * <p>The Change-Id can be provided in one of the following ways:
+   *
+   * <ul>
+   *   <li>Explicitly provided for the new change.
+   *   <li>Provided in the input commit message.
+   *   <li>Taken from the source commit if commit message was not set.
+   * </ul>
+   *
+   * Otherwise should be generated.
+   *
+   * @param commitMessage the commit message, as intended by the caller of cherry-pick operation.
+   * @param changeIdForNewChange the explicitly provided Change-Id for the new change.
+   * @return The Change-Id of destination change, {@code null} if Change-Id was not provided by the
+   *     caller of cherry-pick operation and should be generated.
+   */
+  @Nullable
+  private String getDestinationChangeId(
+      String commitMessage, @Nullable ObjectId changeIdForNewChange) {
+    if (changeIdForNewChange != null) {
+      return CommitMessageUtil.getChangeIdFromObjectId(changeIdForNewChange);
+    }
+    return CommitMessageUtil.getChangeIdFromCommitMessageFooter(commitMessage).orElse(null);
+  }
+
+  /**
+   * Returns the change from the destination branch, if it exists and is valid for the cherry-pick.
+   *
+   * @param destChangeId the Change-ID of the change in the destination branch.
+   * @param destBranch the branch to search by the Change-ID.
+   * @param verifyIsMissing if {@code true}, verifies that the change should be missing in the
+   *     destination branch.
+   * @return the verified change or {@code null} if the change was not found.
+   * @throws InvalidChangeOperationException if the change was found but failed validation
+   */
+  @Nullable
+  private ChangeData getDestChangeWithVerification(
+      String destChangeId, BranchNameKey destBranch, boolean verifyIsMissing)
+      throws InvalidChangeOperationException {
+    List<ChangeData> destChanges =
+        queryProvider.get().setLimit(2).byBranchKey(destBranch, Change.key(destChangeId));
+    if (destChanges.size() > 1) {
+      throw new InvalidChangeOperationException(
+          "Several changes with key "
+              + destChangeId
+              + " reside on the same branch. "
+              + "Cannot create a new patch set.");
+    }
+    if (destChanges.size() == 1 && verifyIsMissing) {
+      throw new InvalidChangeOperationException(
+          String.format(
+              "Expected that cherry-pick with Change-Id %s to branch %s "
+                  + "in project %s creates a new change, but found existing change %d",
+              destChangeId,
+              destBranch.branch(),
+              destBranch.project().get(),
+              destChanges.get(0).getId().get()));
+    }
+    ChangeData destChange = destChanges.size() == 1 ? destChanges.get(0) : null;
+
+    if (destChange != null && destChange.change().isClosed()) {
+      throw new InvalidChangeOperationException(
+          String.format(
+              "Cherry-pick with Change-Id %s could not update the existing change %d "
+                  + "in destination branch %s of project %s, because the change was closed (%s)",
+              destChangeId,
+              destChange.getId().get(),
+              destBranch.branch(),
+              destBranch.project(),
+              destChange.change().getStatus().name()));
+    }
+    return destChange;
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 4de9b63..edc8fcf 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -20,9 +20,12 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
@@ -31,15 +34,18 @@
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.comment.CommentContextCache;
+import com.google.gerrit.server.comment.CommentContextKey;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
@@ -47,18 +53,20 @@
 public class CommentJson {
 
   private final AccountLoader.Factory accountLoaderFactory;
+  private final CommentContextCache commentContextCache;
+
+  private Project.NameKey project;
+  private Change.Id changeId;
 
   private boolean fillAccounts = true;
   private boolean fillPatchSet;
-  private CommentContextLoader.Factory commentContextLoaderFactory;
-  private CommentContextLoader commentContextLoader;
+  private boolean fillCommentContext;
+  private int contextPadding;
 
   @Inject
-  CommentJson(
-      AccountLoader.Factory accountLoaderFactory,
-      CommentContextLoader.Factory commentContextLoaderFactory) {
+  CommentJson(AccountLoader.Factory accountLoaderFactory, CommentContextCache commentContextCache) {
     this.accountLoaderFactory = accountLoaderFactory;
-    this.commentContextLoaderFactory = commentContextLoaderFactory;
+    this.commentContextCache = commentContextCache;
   }
 
   CommentJson setFillAccounts(boolean fillAccounts) {
@@ -71,10 +79,23 @@
     return this;
   }
 
-  CommentJson setEnableContext(boolean enableContext, Project.NameKey project) {
-    if (enableContext) {
-      this.commentContextLoader = commentContextLoaderFactory.create(project);
-    }
+  CommentJson setFillCommentContext(boolean fillCommentContext) {
+    this.fillCommentContext = fillCommentContext;
+    return this;
+  }
+
+  CommentJson setContextPadding(int contextPadding) {
+    this.contextPadding = contextPadding;
+    return this;
+  }
+
+  CommentJson setProjectKey(Project.NameKey project) {
+    this.project = project;
+    return this;
+  }
+
+  CommentJson setChangeId(Change.Id changeId) {
+    this.changeId = changeId;
     return this;
   }
 
@@ -93,9 +114,6 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
-      }
       return info;
     }
 
@@ -111,7 +129,6 @@
           list = new ArrayList<>();
           out.put(o.path, list);
         }
-        o.path = null;
         list.add(o);
       }
 
@@ -120,9 +137,12 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
+
+      List<T> allComments = out.values().stream().flatMap(Collection::stream).collect(toList());
+      if (fillCommentContext) {
+        addCommentContext(allComments);
       }
+      allComments.forEach(c -> c.path = null); // we don't need path since it exists in the map keys
       return out;
     }
 
@@ -138,12 +158,45 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
+
+      if (fillCommentContext) {
+        addCommentContext(out);
       }
+
       return out;
     }
 
+    protected void addCommentContext(List<T> allComments) {
+      List<CommentContextKey> keys =
+          allComments.stream().map(this::createCommentContextKey).collect(toList());
+      ImmutableMap<CommentContextKey, CommentContext> allContext = commentContextCache.getAll(keys);
+      for (T c : allComments) {
+        CommentContextKey contextKey = createCommentContextKey(c);
+        CommentContext commentContext = allContext.get(contextKey);
+        c.contextLines = toContextLineInfoList(commentContext);
+        c.sourceContentType = commentContext.contentType();
+      }
+    }
+
+    protected List<ContextLineInfo> toContextLineInfoList(CommentContext commentContext) {
+      List<ContextLineInfo> result = new ArrayList<>();
+      for (Map.Entry<Integer, String> e : commentContext.lines().entrySet()) {
+        result.add(new ContextLineInfo(e.getKey(), e.getValue()));
+      }
+      return result;
+    }
+
+    protected CommentContextKey createCommentContextKey(T r) {
+      return CommentContextKey.builder()
+          .project(project)
+          .changeId(changeId)
+          .id(r.id)
+          .path(r.path)
+          .patchset(r.patchSet)
+          .contextPadding(contextPadding)
+          .build();
+    }
+
     protected abstract T toInfo(F comment, AccountLoader loader);
 
     protected void fillCommentInfo(Comment c, CommentInfo r, AccountLoader loader) {
@@ -170,9 +223,6 @@
         r.author = loader.get(c.author.getId());
       }
       r.commitId = c.getCommitId().getName();
-      if (commentContextLoader != null) {
-        r.contextLines = commentContextLoader.getContext(r);
-      }
     }
 
     protected Range toRange(Comment.Range commentRange) {
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 681509c..34af285 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -18,7 +18,9 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.util.stream.Collectors.groupingBy;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
@@ -28,6 +30,9 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffMappings;
@@ -47,6 +52,7 @@
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Optional;
+import java.util.function.Function;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -62,15 +68,48 @@
 public class CommentPorter {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  @VisibleForTesting
+  @Singleton
+  static class Metrics {
+    final Counter0 portedAsPatchsetLevel;
+    final Counter0 portedAsFileLevel;
+    final Counter0 portedAsRangeComments;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      portedAsPatchsetLevel =
+          metricMaker.newCounter(
+              "ported_comments/as_patchset_level",
+              new Description("Total number of comments ported as patchset-level comments.")
+                  .setRate()
+                  .setUnit("count"));
+      portedAsFileLevel =
+          metricMaker.newCounter(
+              "ported_comments/as_file_level",
+              new Description("Total number of comments ported as file-level comments.")
+                  .setRate()
+                  .setUnit("count"));
+      portedAsRangeComments =
+          metricMaker.newCounter(
+              "ported_comments/as_range_comments",
+              new Description(
+                      "Total number of comments having line/range values in the ported patchset.")
+                  .setRate()
+                  .setUnit("count"));
+    }
+  }
+
   private final GitPositionTransformer positionTransformer =
       new GitPositionTransformer(BestPositionOnConflict.INSTANCE);
   private final PatchListCache patchListCache;
   private final CommentsUtil commentsUtil;
+  private final Metrics metrics;
 
   @Inject
-  public CommentPorter(PatchListCache patchListCache, CommentsUtil commentsUtil) {
+  public CommentPorter(PatchListCache patchListCache, CommentsUtil commentsUtil, Metrics metrics) {
     this.patchListCache = patchListCache;
     this.commentsUtil = commentsUtil;
+    this.metrics = metrics;
   }
 
   /**
@@ -204,9 +243,13 @@
 
     ImmutableList<PositionedEntity<HumanComment>> positionedComments =
         comments.stream().map(this::toPositionedEntity).collect(toImmutableList());
-    return positionTransformer.transform(positionedComments, mappings).stream()
-        .map(PositionedEntity::getEntityAtUpdatedPosition)
-        .collect(toImmutableList());
+    ImmutableMap<PositionedEntity<HumanComment>, HumanComment> origToPortedMap =
+        positionTransformer.transform(positionedComments, mappings).stream()
+            .collect(
+                ImmutableMap.toImmutableMap(
+                    Function.identity(), PositionedEntity::getEntityAtUpdatedPosition));
+    collectMetrics(origToPortedMap);
+    return ImmutableList.copyOf(origToPortedMap.values());
   }
 
   private ImmutableSet<Mapping> loadMappings(
@@ -269,6 +312,10 @@
     return positionBuilder.lineRange(extractLineRange(comment)).build();
   }
 
+  /**
+   * Returns {@link Optional#empty()} if the {@code comment} parameter is a file comment, or the
+   * comment range {start_line, end_line} otherwise.
+   */
   private static Optional<GitPositionTransformer.Range> extractLineRange(HumanComment comment) {
     // Line specifications in comment are 1-based. Line specifications in Position are 0-based.
     if (comment.range != null) {
@@ -316,6 +363,33 @@
     return new Range(lineRange.start() + 1, originalStartChar, adjustedEndLine, originalEndChar);
   }
 
+  /**
+   * Collect metrics from the original and ported comments.
+   *
+   * @param portMap map of the ported comments. The keys contain a {@link PositionedEntity} of the
+   *     original comment, and the values contain the transformed comments.
+   */
+  private void collectMetrics(ImmutableMap<PositionedEntity<HumanComment>, HumanComment> portMap) {
+    for (Map.Entry<PositionedEntity<HumanComment>, HumanComment> entry : portMap.entrySet()) {
+      HumanComment original = entry.getKey().getEntity();
+      HumanComment transformed = entry.getValue();
+
+      if (!Patch.isMagic(original.key.filename)) {
+        if (Patch.PATCHSET_LEVEL.equals(transformed.key.filename)) {
+          metrics.portedAsPatchsetLevel.increment();
+        } else if (extractLineRange(original).isPresent()) {
+          if (extractLineRange(transformed).isPresent()) {
+            metrics.portedAsRangeComments.increment();
+          } else {
+            // line range was present in the original comment, but the ported comment is a file
+            // level comment.
+            metrics.portedAsFileLevel.increment();
+          }
+        }
+      }
+    }
+  }
+
   /** A filter which just keeps those comments which are before the given patchset. */
   private static class EarlierPatchsetCommentFilter implements HumanCommentFilter {
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 52887e0..c392bd1 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -223,6 +223,12 @@
       throw new BadRequestException("branch must be non-empty");
     }
     input.branch = RefNames.fullName(input.branch);
+    if (!isBranchAllowed(input.branch)) {
+      throw new BadRequestException(
+          "Cannot create a change on ref "
+              + input.branch
+              + ". Gerrit internal refs and refs/tags/* are not allowed.");
+    }
 
     String subject = Strings.nullToEmpty(input.subject);
     subject = subject.replaceAll("(?m)^#.*$\n?", "").trim();
@@ -292,6 +298,11 @@
     }
   }
 
+  /** Changes are allowed to be created on any ref that is not Gerrit internal or a tag ref. */
+  private boolean isBranchAllowed(String branch) {
+    return !RefNames.isGerritRef(branch) && !branch.startsWith(RefNames.REFS_TAGS);
+  }
+
   private void checkRequiredPermissions(
       Project.NameKey project, String refName, @Nullable AccountInput author)
       throws ResourceNotFoundException, AuthException, PermissionBackendException {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index 20fd675..842ed2a 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -93,10 +93,7 @@
       }
       IdentifiedUser deletedAssigneeUser = userFactory.create(currentAssigneeId);
       deletedAssignee = deletedAssigneeUser.state();
-      // noteDb
       update.removeAssignee();
-      // reviewDb
-      change.setAssignee(null);
       addMessage(ctx, update, deletedAssigneeUser);
       return true;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 392aef7..f82284e 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -163,7 +163,7 @@
             revisions.parse(resource.getChangeResource(), IdString.fromDecoded(base));
         r =
             Response.ok(
-                fileInfoJson.toFileInfoMap(
+                fileInfoJson.getFileInfoMap(
                     resource.getChange(),
                     resource.getPatchSet().commitId(),
                     baseResource.getPatchSet()));
@@ -180,10 +180,10 @@
         }
         r =
             Response.ok(
-                fileInfoJson.toFileInfoMap(
+                fileInfoJson.getFileInfoMap(
                     resource.getChange(), resource.getPatchSet().commitId(), parentNum - 1));
       } else {
-        r = Response.ok(fileInfoJson.toFileInfoMap(resource.getChange(), resource.getPatchSet()));
+        r = Response.ok(fileInfoJson.getFileInfoMap(resource.getChange(), resource.getPatchSet()));
       }
 
       if (resource.isCacheable()) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetBlame.java b/java/com/google/gerrit/server/restapi/change/GetBlame.java
index 12b4d44..04828f2 100644
--- a/java/com/google/gerrit/server/restapi/change/GetBlame.java
+++ b/java/com/google/gerrit/server/restapi/change/GetBlame.java
@@ -27,6 +27,7 @@
 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;
@@ -39,7 +40,6 @@
 import java.util.concurrent.TimeUnit;
 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.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -86,7 +86,7 @@
       throws RestApiException, IOException, InvalidChangeOperationException {
     Project.NameKey project = resource.getRevision().getChange().getProject();
     try (Repository repository = repoManager.openRepository(project);
-        ObjectInserter ins = repository.newObjectInserter();
+        InMemoryInserter ins = new InMemoryInserter(repository);
         ObjectReader reader = ins.newReader();
         RevWalk revWalk = new RevWalk(reader)) {
       String refName =
@@ -115,7 +115,9 @@
         result = blame(parents[0], path, repository, revWalk);
 
       } else if (parents.length == 2) {
-        ObjectId automerge = autoMerger.merge(repository, revWalk, ins, revCommit, mergeStrategy);
+        ObjectId automerge =
+            autoMerger.lookupFromGitOrMergeInMemory(
+                repository, revWalk, ins, revCommit, mergeStrategy);
         result = blame(automerge, path, repository, revWalk);
 
       } else {
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index 1ef3c4b..340b9a0 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -14,31 +14,43 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.MissingMetaObjectException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.io.IOException;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.kohsuke.args4j.Option;
 
 public class GetChange
@@ -46,16 +58,23 @@
         DynamicOptions.BeanReceiver,
         DynamicOptions.BeanProvider {
   private final ChangeJson.Factory json;
-  private final DynamicSet<ChangeAttributeFactory> attrFactories;
   private final DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories;
   private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
+  private final GitRepositoryManager repoMgr;
 
   @Option(name = "-o", usage = "Output options")
   public void addOption(ListChangesOption o) {
     options.add(o);
   }
 
+  @Option(name = "--meta", usage = "NoteDb meta SHA1")
+  String metaRevId = "";
+
+  public void setMetaRevId(String metaRevId) {
+    this.metaRevId = metaRevId == null ? "" : metaRevId;
+  }
+
   @Option(name = "-O", usage = "Output option flags, in hex")
   void setOptionFlagsHex(String hex) {
     options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
@@ -64,11 +83,11 @@
   @Inject
   GetChange(
       ChangeJson.Factory json,
-      DynamicSet<ChangeAttributeFactory> attrFactories,
-      DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories) {
+      DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories,
+      GitRepositoryManager repoMgr) {
     this.json = json;
-    this.attrFactories = attrFactories;
     this.pdiFactories = pdiFactories;
+    this.repoMgr = repoMgr;
   }
 
   @Override
@@ -82,21 +101,40 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) {
-    return Response.withMustRevalidate(newChangeJson().format(rsrc));
+  public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException {
+    try {
+      Change change = rsrc.getChange();
+      ObjectId changeMetaRevId = getMetaRevId(change);
+      return Response.withMustRevalidate(newChangeJson().format(change, changeMetaRevId));
+    } catch (MissingMetaObjectException e) {
+      throw new PreconditionFailedException(e.getMessage());
+    }
   }
 
   Response<ChangeInfo> apply(RevisionResource rsrc) {
     return Response.withMustRevalidate(newChangeJson().format(rsrc));
   }
 
-  private ChangeJson newChangeJson() {
-    return json.create(options, this::buildPluginInfo, this::createPluginDefinedInfos);
+  @Nullable
+  private ObjectId getMetaRevId(Change change) throws RestApiException {
+    if (metaRevId.isEmpty()) {
+      return null;
+    }
+
+    // It might be interesting to also allow {SHA1}^^, so callers can walk back into history
+    // without having to fetch the entire /meta ref. If we do so, we have to be careful that
+    // the error messages can't be abused to fetch hidden data.
+    ObjectId metaRevObjectId;
+    try {
+      metaRevObjectId = ObjectId.fromString(metaRevId);
+    } catch (InvalidObjectIdException e) {
+      throw new BadRequestException("invalid meta SHA1: " + metaRevId, e);
+    }
+    return verifyMetaId(change, metaRevObjectId);
   }
 
-  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
-    return PluginDefinedAttributesFactories.createAll(
-        cd, this, Streams.stream(attrFactories.entries()));
+  private ChangeJson newChangeJson() {
+    return json.create(options, this::createPluginDefinedInfos);
   }
 
   private ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
@@ -104,4 +142,34 @@
     return PluginDefinedAttributesFactories.createAll(
         cds, this, Streams.stream(pdiFactories.entries()));
   }
+
+  private ObjectId verifyMetaId(Change change, @Nullable ObjectId id) throws RestApiException {
+    if (id == null) {
+      return null;
+    }
+
+    String changeMetaRefName = RefNames.changeMetaRef(change.getId());
+    try (Repository repo = repoMgr.openRepository(change.getProject());
+        RevWalk rw = new RevWalk(repo)) {
+      rw.setRetainBody(false);
+      Ref ref = repo.getRefDatabase().exactRef(changeMetaRefName);
+      RevCommit tip = rw.parseCommit(ref.getObjectId());
+      rw.markStart(tip);
+      for (RevCommit rev : rw) {
+        if (id.equals(rev)) {
+          return id;
+        }
+      }
+    } catch (IOException e) {
+      throw RestApiException.wrap(
+          "I/O error while reading meta-ref id="
+              + id.getName()
+              + " from change "
+              + change.getChangeId(),
+          e);
+    }
+
+    throw new PreconditionFailedException(
+        id.getName() + " not reachable from " + changeMetaRefName);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
index 21a08dc..d76ce04 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -58,7 +58,13 @@
       rw.parseBody(commit);
       CommitInfo info =
           json.create(ImmutableSet.of())
-              .getCommitInfo(rsrc.getProject(), rw, commit, addLinks, true);
+              .getCommitInfo(
+                  rsrc.getProject(),
+                  rw,
+                  commit,
+                  addLinks,
+                  /* fillCommit= */ true,
+                  rsrc.getChange().getDest().branch());
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
         r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
diff --git a/java/com/google/gerrit/server/restapi/change/GetDetail.java b/java/com/google/gerrit/server/restapi/change/GetDetail.java
index e31d84b..4139560 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDetail.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
@@ -58,7 +59,7 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc) {
+  public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException {
     return delegate.apply(rsrc);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 8d51786..19256bb 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -36,6 +36,7 @@
 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.server.CurrentUser;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.RevisionResource;
@@ -51,6 +52,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
 import org.kohsuke.args4j.CmdLineParser;
@@ -67,6 +69,7 @@
   private final PatchScriptFactory.Factory patchScriptFactoryFactory;
   private final Revisions revisions;
   private final WebLinks webLinks;
+  private final Provider<CurrentUser> currentUser;
 
   @Option(name = "--base", metaVar = "REVISION")
   String base;
@@ -93,11 +96,13 @@
       ProjectCache projectCache,
       PatchScriptFactory.Factory patchScriptFactoryFactory,
       Revisions revisions,
-      WebLinks webLinks) {
+      WebLinks webLinks,
+      Provider<CurrentUser> currentUser) {
     this.projectCache = projectCache;
     this.patchScriptFactoryFactory = patchScriptFactoryFactory;
     this.revisions = revisions;
     this.webLinks = webLinks;
+    this.currentUser = currentUser;
   }
 
   @Override
@@ -132,11 +137,15 @@
       if (basePatchSet.id().get() == 0) {
         throw new BadRequestException("edit not allowed as base");
       }
-      psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.id(), pId, prefs);
+      psf =
+          patchScriptFactoryFactory.create(
+              notes, fileName, basePatchSet.id(), pId, prefs, currentUser.get());
     } else if (parentNum > 0) {
-      psf = patchScriptFactoryFactory.create(notes, fileName, parentNum - 1, pId, prefs);
+      psf =
+          patchScriptFactoryFactory.create(
+              notes, fileName, parentNum - 1, pId, prefs, currentUser.get());
     } else {
-      psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs);
+      psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs, currentUser.get());
     }
 
     try {
@@ -174,6 +183,8 @@
     private final DiffSide sideB;
     private final String revA;
     private final String revB;
+    private final String hashA;
+    private final String hashB;
     private final FileResource resource;
     @Nullable private final PatchSet basePatchSet;
 
@@ -192,6 +203,7 @@
       this.sideB = sideB;
 
       revA = basePatchSet != null ? basePatchSet.refName() : sideA.fileInfo().commitId;
+      hashA = sideA.fileInfo().commitId;
 
       RevisionResource revision = resource.getRevision();
       revB =
@@ -199,8 +211,9 @@
               .getEdit()
               .map(edit -> edit.getRefName())
               .orElseGet(() -> revision.getPatchSet().refName());
+      hashB = sideB.fileInfo().commitId;
 
-      logger.atFine().log("revA = %s, revB = %s", revA, revB);
+      logger.atFine().log("revA = %s, hashA = %s, revB = %s, hashB = %s", revA, hashA, revB, hashB);
     }
 
     @Override
@@ -219,15 +232,18 @@
     @Override
     public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type type) {
       String rev;
+      String hash;
       DiffSide side;
       if (type == DiffSide.Type.SIDE_A) {
         rev = revA;
+        hash = hashA;
         side = sideA;
       } else {
         rev = revB;
+        hash = hashB;
         side = sideB;
       }
-      return webLinks.getFileLinks(projectName.get(), rev, side.fileName());
+      return webLinks.getFileLinks(projectName.get(), rev, hash, side.fileName());
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index 0c67fd6..f0639b5 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -81,7 +81,14 @@
       List<CommitInfo> result = new ArrayList<>(commits.size());
       RevisionJson changeJson = json.create(ImmutableSet.of());
       for (RevCommit c : commits) {
-        result.add(changeJson.getCommitInfo(rsrc.getProject(), rw, c, addLinks, true));
+        result.add(
+            changeJson.getCommitInfo(
+                rsrc.getProject(),
+                rw,
+                c,
+                addLinks,
+                /* fillCommit= */ true,
+                rsrc.getChange().getDest().branch()));
       }
       return createResponse(rsrc, result);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java b/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
new file mode 100644
index 0000000..1e71d4c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
@@ -0,0 +1,162 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ListOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInfoDiffer;
+import com.google.gerrit.extensions.common.ChangeInfoDifference;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+/** Gets the diff for a change at two NoteDb meta SHA-1s. */
+public class GetMetaDiff
+    implements RestReadView<ChangeResource>,
+        DynamicOptions.BeanReceiver,
+        DynamicOptions.BeanProvider {
+
+  private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
+  private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
+
+  private final Provider<GetChange> getChangeProvider;
+  private final GitRepositoryManager repoManager;
+
+  @Option(name = "-o", usage = "Output options")
+  public void addOption(ListChangesOption o) {
+    options.add(o);
+  }
+
+  @Option(name = "-O", usage = "Output option flags, in hex")
+  void setOptionFlagsHex(String hex) {
+    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
+  }
+
+  @Option(name = "--old", usage = "old NoteDb meta SHA-1")
+  String oldMetaRevId = "";
+
+  public void setOldMetaRevId(@Nullable String oldMetaRevId) {
+    this.oldMetaRevId = oldMetaRevId == null ? "" : oldMetaRevId;
+  }
+
+  @Option(name = "--meta", usage = "new NoteDb meta SHA-1")
+  String metaRevId = "";
+
+  public void setNewMetaRevId(@Nullable String metaRevId) {
+    this.metaRevId = metaRevId == null ? "" : metaRevId;
+  }
+
+  @Inject
+  GetMetaDiff(Provider<GetChange> getChangeProvider, GitRepositoryManager repoManager) {
+    this.getChangeProvider = getChangeProvider;
+    this.repoManager = repoManager;
+  }
+
+  @Override
+  public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
+    dynamicBeans.put(plugin, dynamicBean);
+  }
+
+  @Override
+  public DynamicBean getDynamicBean(String plugin) {
+    return dynamicBeans.get(plugin);
+  }
+
+  @Override
+  public Response<ChangeInfoDifference> apply(ChangeResource resource)
+      throws RestApiException, IOException {
+    return Response.ok(
+        ChangeInfoDiffer.getDifference(getOldChangeInfo(resource), getNewChangeInfo(resource)));
+  }
+
+  private ChangeInfo getOldChangeInfo(ChangeResource resource)
+      throws RestApiException, IOException {
+    GetChange getChange = createGetChange();
+    getChange.setMetaRevId(getOldMetaRevId(resource));
+    ChangeInfo oldChangeInfo;
+    try {
+      oldChangeInfo = getChange.apply(resource).value();
+    } catch (PreconditionFailedException e) {
+      oldChangeInfo = new ChangeInfo();
+    }
+    return oldChangeInfo;
+  }
+
+  private String getOldMetaRevId(ChangeResource resource)
+      throws IOException, BadRequestException, PreconditionFailedException {
+    if (!oldMetaRevId.isEmpty()) {
+      return oldMetaRevId;
+    }
+    String newMetaRevId = getNewMetaRevId(resource);
+    try (Repository repo = repoManager.openRepository(resource.getProject());
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId resourceId = ObjectId.fromString(newMetaRevId);
+      RevCommit commit = rw.parseCommit(resourceId);
+      return commit.getParentCount() == 0
+          ? resourceId.getName()
+          : commit.getParent(0).getId().getName();
+    } catch (InvalidObjectIdException e) {
+      throw new BadRequestException("invalid meta SHA1: " + newMetaRevId, e);
+    } catch (MissingObjectException e) {
+      throw new PreconditionFailedException(e.getMessage());
+    }
+  }
+
+  private ChangeInfo getNewChangeInfo(ChangeResource resource)
+      throws RestApiException, IOException {
+    GetChange getChange = createGetChange();
+    getChange.setMetaRevId(getNewMetaRevId(resource));
+    return getChange.apply(resource).value();
+  }
+
+  private String getNewMetaRevId(ChangeResource resource) throws IOException {
+    if (!metaRevId.isEmpty()) {
+      return metaRevId;
+    }
+    try (Repository repo = repoManager.openRepository(resource.getProject())) {
+      return repo.exactRef(changeMetaRef(resource.getId())).getObjectId().getName();
+    }
+  }
+
+  private GetChange createGetChange() {
+    GetChange getChange = getChangeProvider.get();
+    options.forEach(getChange::addOption);
+    dynamicBeans.forEach(getChange::setDynamicBean);
+    return getChange;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
index c4da3b6..527129c 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
@@ -14,67 +14,26 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.ActionInfo;
-import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.change.ActionJson;
-import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.submit.ChangeSet;
-import com.google.gerrit.server.submit.MergeSuperSet;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.Map;
-import org.eclipse.jgit.lib.Config;
 
 @Singleton
-public class GetRevisionActions implements ETagView<RevisionResource> {
+public class GetRevisionActions implements RestReadView<RevisionResource> {
   private final ActionJson delegate;
-  private final Config config;
-  private final Provider<MergeSuperSet> mergeSuperSet;
-  private final ChangeResource.Factory changeResourceFactory;
 
   @Inject
-  GetRevisionActions(
-      ActionJson delegate,
-      Provider<MergeSuperSet> mergeSuperSet,
-      ChangeResource.Factory changeResourceFactory,
-      @GerritServerConfig Config config) {
+  GetRevisionActions(ActionJson delegate) {
     this.delegate = delegate;
-    this.mergeSuperSet = mergeSuperSet;
-    this.changeResourceFactory = changeResourceFactory;
-    this.config = config;
   }
 
   @Override
   public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) {
     return Response.withMustRevalidate(delegate.format(rsrc));
   }
-
-  @Override
-  public String getETag(RevisionResource rsrc) {
-    Hasher h = Hashing.murmur3_128().newHasher();
-    CurrentUser user = rsrc.getUser();
-    try {
-      rsrc.getChangeResource().prepareETag(h, user);
-      h.putBoolean(MergeSuperSet.wholeTopicEnabled(config));
-      ChangeSet cs = mergeSuperSet.get().completeChangeSet(rsrc.getChange(), user);
-      for (ChangeData cd : cs.changes()) {
-        changeResourceFactory.create(cd.notes(), user).prepareETag(h, user);
-      }
-      h.putBoolean(cs.furtherHiddenChanges());
-    } catch (IOException | PermissionBackendException e) {
-      throw new StorageException(e);
-    }
-    return h.hash().toString();
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index e3b433c..c90e4fc 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -43,6 +42,7 @@
   private final CommentsUtil commentsUtil;
 
   private boolean includeContext;
+  private int contextPadding;
 
   /**
    * Optional parameter. If set, the contextLines field of the {@link ContextLineInfo} of the
@@ -55,6 +55,16 @@
     this.includeContext = context;
   }
 
+  /**
+   * Optional parameter. Works only if {@link #includeContext} is set to true. If {@link
+   * #contextPadding} is set, the context lines in the response will be padded with {@link
+   * #contextPadding} extra lines before and after the comment range.
+   */
+  @Option(name = "--context-padding")
+  public void setContextPadding(int contextPadding) {
+    this.contextPadding = contextPadding;
+  }
+
   @Inject
   ListChangeComments(
       ChangeData.Factory changeDataFactory,
@@ -84,8 +94,7 @@
 
   private ImmutableList<CommentInfo> getAsList(Iterable<HumanComment> comments, ChangeResource rsrc)
       throws PermissionBackendException {
-    ImmutableList<CommentInfo> commentInfos =
-        getCommentFormatter(rsrc.getProject()).formatAsList(comments);
+    ImmutableList<CommentInfo> commentInfos = getCommentFormatter(rsrc).formatAsList(comments);
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
     CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
     return commentInfos;
@@ -93,8 +102,7 @@
 
   private Map<String, List<CommentInfo>> getAsMap(
       Iterable<HumanComment> comments, ChangeResource rsrc) throws PermissionBackendException {
-    Map<String, List<CommentInfo>> commentInfosMap =
-        getCommentFormatter(rsrc.getProject()).format(comments);
+    Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter(rsrc).format(comments);
     List<CommentInfo> commentInfos =
         commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
@@ -102,12 +110,15 @@
     return commentInfosMap;
   }
 
-  private CommentJson.HumanCommentFormatter getCommentFormatter(Project.NameKey project) {
+  private CommentJson.HumanCommentFormatter getCommentFormatter(ChangeResource rsrc) {
     return commentJson
         .get()
         .setFillAccounts(true)
         .setFillPatchSet(true)
-        .setEnableContext(includeContext, project)
+        .setFillCommentContext(includeContext)
+        .setContextPadding(contextPadding)
+        .setProjectKey(rsrc.getProject())
+        .setChangeId(rsrc.getId())
         .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index 3841dc1..65f90ae 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -29,6 +30,7 @@
 import com.google.inject.Singleton;
 import java.util.List;
 import java.util.Map;
+import org.kohsuke.args4j.Option;
 
 @Singleton
 public class ListChangeDrafts implements RestReadView<ChangeResource> {
@@ -36,6 +38,30 @@
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
 
+  private boolean includeContext;
+  private int contextPadding;
+
+  /**
+   * Optional parameter. If set, the contextLines field of the {@link ContextLineInfo} of the
+   * response will contain the lines of the source file where the comment was written.
+   *
+   * @param context If true, comment context will be attached to the response
+   */
+  @Option(name = "--enable-context")
+  public void setContext(boolean context) {
+    this.includeContext = context;
+  }
+
+  /**
+   * Optional parameter. Works only if {@link #includeContext} is set to true. If {@link
+   * #contextPadding} is set, the context lines in the response will be padded with {@link
+   * #contextPadding} extra lines before and after the comment range.
+   */
+  @Option(name = "--context-padding")
+  public void setContextPadding(int contextPadding) {
+    this.contextPadding = contextPadding;
+  }
+
   @Inject
   ListChangeDrafts(
       ChangeData.Factory changeDataFactory,
@@ -57,7 +83,7 @@
     if (!rsrc.getUser().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-    return Response.ok(getCommentFormatter().format(listComments(rsrc)));
+    return Response.ok(getCommentFormatter(rsrc).format(listComments(rsrc)));
   }
 
   public List<CommentInfo> getComments(ChangeResource rsrc)
@@ -65,14 +91,18 @@
     if (!rsrc.getUser().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-    return getCommentFormatter().formatAsList(listComments(rsrc));
+    return getCommentFormatter(rsrc).formatAsList(listComments(rsrc));
   }
 
-  private HumanCommentFormatter getCommentFormatter() {
+  private HumanCommentFormatter getCommentFormatter(ChangeResource rsrc) {
     return commentJson
         .get()
         .setFillAccounts(false)
         .setFillPatchSet(true)
+        .setFillCommentContext(includeContext)
+        .setContextPadding(contextPadding)
+        .setProjectKey(rsrc.getProject())
+        .setChangeId(rsrc.getId())
         .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
index 9b254f1..e92fe5c 100644
--- a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
@@ -18,7 +18,9 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -46,7 +48,10 @@
 
   @Override
   public Response<Map<String, List<CommentInfo>>> apply(RevisionResource revisionResource)
-      throws PermissionBackendException {
+      throws PermissionBackendException, RestApiException {
+    if (!revisionResource.getUser().isIdentifiedUser()) {
+      throw new AuthException("requires authentication; only authenticated users can have drafts");
+    }
     PatchSet targetPatchset = revisionResource.getPatchSet();
 
     List<HumanComment> draftComments =
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 69e2788..f87c9a1 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -29,7 +29,6 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
@@ -47,7 +46,9 @@
 import com.google.gerrit.server.change.SetCherryPickOp;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.change.WorkInProgressOp;
+import com.google.gerrit.server.comment.CommentContextLoader;
 import com.google.gerrit.server.restapi.change.Reviewed.DeleteReviewed;
 import com.google.gerrit.server.restapi.change.Reviewed.PutReviewed;
 import com.google.gerrit.server.util.AttentionSetEmail;
@@ -81,6 +82,7 @@
 
     postOnCollection(CHANGE_KIND).to(CreateChange.class);
     get(CHANGE_KIND).to(GetChange.class);
+    get(CHANGE_KIND, "meta_diff").to(GetMetaDiff.class);
     post(CHANGE_KIND, "merge").to(CreateMergePatchSet.class);
     get(CHANGE_KIND, "detail").to(GetDetail.class);
     get(CHANGE_KIND, "topic").to(GetTopic.class);
@@ -218,9 +220,9 @@
     factory(SetAssigneeOp.Factory.class);
     factory(SetCherryPickOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
+    factory(SetTopicOp.Factory.class);
     factory(SetPrivateOp.Factory.class);
     factory(WorkInProgressOp.Factory.class);
-    factory(SetTopicOp.Factory.class);
     factory(AddToAttentionSetOp.Factory.class);
     factory(RemoveFromAttentionSetOp.Factory.class);
     factory(AttentionSetEmail.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index ecfb96d..fc7e9f4 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -140,6 +141,18 @@
     // Not allowed to move if the current patch set is locked.
     psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
+    // Keeping all votes can be confusing in the context of the destination branch, see the
+    // discussion in
+    // https://gerrit-review.googlesource.com/c/gerrit/+/129171
+    // Only administrators are allowed to keep all labels at their own risk.
+    try {
+      if (input.keepAllVotes) {
+        permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
+      }
+    } catch (AuthException denied) {
+      throw new AuthException("move is not permitted with keepAllVotes option", denied);
+    }
+
     // Move requires abandoning this change, and creating a new change.
     try {
       rsrc.permissions().check(ABANDON);
@@ -223,7 +236,9 @@
       update.setBranch(newDestKey.branch());
       change.setDest(newDestKey);
 
-      updateApprovals(ctx, update, psId, projectKey);
+      if (!input.keepAllVotes) {
+        updateApprovals(ctx, update, psId, projectKey);
+      }
 
       StringBuilder msgBuf = new StringBuilder();
       msgBuf.append("Change destination moved from ");
diff --git a/java/com/google/gerrit/server/restapi/change/OnPostReview.java b/java/com/google/gerrit/server/restapi/change/OnPostReview.java
new file mode 100644
index 0000000..b179d02
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/OnPostReview.java
@@ -0,0 +1,47 @@
+// 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.restapi.change;
+
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.util.Map;
+import java.util.Optional;
+
+/** Extension point that is invoked on post review. */
+@ExtensionPoint
+public interface OnPostReview {
+  /**
+   * Allows implementors to return a message that should be included into the change message that is
+   * posted on post review.
+   *
+   * @param user the user that posts the review
+   * @param changeNotes the change on which post review is performed
+   * @param patchSet the patch set on which post review is performed
+   * @param oldApprovals old approvals that changed as result of post review
+   * @param approvals all current approvals
+   * @return message that should be included into the change message that is posted on post review,
+   *     {@link Optional#empty()} if the change message should not be extended
+   */
+  default Optional<String> getChangeMessageAddOn(
+      IdentifiedUser user,
+      ChangeNotes changeNotes,
+      PatchSet patchSet,
+      Map<String, Short> oldApprovals,
+      Map<String, Short> approvals) {
+    return Optional.empty();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 575a19d..58321e9 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -15,7 +15,6 @@
 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;
@@ -30,6 +29,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
@@ -177,6 +177,7 @@
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
   private final PluginSetContext<CommentValidator> commentValidators;
+  private final PluginSetContext<OnPostReview> onPostReviews;
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final boolean strictLabels;
   private final boolean publishPatchSetLevelComment;
@@ -203,6 +204,7 @@
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
       PluginSetContext<CommentValidator> commentValidators,
+      PluginSetContext<OnPostReview> onPostReviews,
       ReplyAttentionSetUpdates replyAttentionSetUpdates) {
     this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
@@ -223,6 +225,7 @@
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.commentValidators = commentValidators;
+    this.onPostReviews = onPostReviews;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
     this.publishPatchSetLevelComment =
@@ -353,7 +356,8 @@
       }
 
       // Add WorkInProgressOp if requested.
-      if (input.ready || input.workInProgress) {
+      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);
@@ -402,6 +406,10 @@
     return Response.ok(output);
   }
 
+  private boolean didWorkInProgressChange(boolean currentWorkInProgress, ReviewInput input) {
+    return input.ready == currentWorkInProgress || input.workInProgress != currentWorkInProgress;
+  }
+
   private NotifyHandling defaultNotify(Change c, ReviewInput in) {
     boolean workInProgress = c.isWorkInProgress();
     if (in.workInProgress) {
@@ -1225,9 +1233,10 @@
     }
 
     private boolean isReviewer(ChangeContext ctx) {
-      ChangeData cd = changeDataFactory.create(ctx.getNotes());
-      ReviewerSet reviewers = cd.reviewers();
-      return reviewers.byState(REVIEWER).contains(ctx.getAccountId());
+      return approvalsUtil
+          .getReviewers(ctx.getNotes())
+          .byState(REVIEWER)
+          .contains(ctx.getAccountId());
     }
 
     private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
@@ -1269,7 +1278,10 @@
             del.add(c);
             update.putApproval(normName, (short) 0);
           }
-        } else if (c != null && c.value() != ent.getValue()) {
+          // Only allow voting again if the vote is copied over from a past patch-set, or the
+          // values are different.
+        } else if (c != null
+            && (c.value() != ent.getValue() || isApprovalCopiedOver(c, ctx.getNotes()))) {
           PatchSetApproval.Builder b =
               c.toBuilder()
                   .value(ent.getValue())
@@ -1312,6 +1324,17 @@
       return !del.isEmpty() || !ups.isEmpty();
     }
 
+    /**
+     * Approval is copied over if it doesn't exist in the approvals of the current patch-set
+     * according to change notes (which means it was computed in {@link
+     * com.google.gerrit.server.ApprovalInference})
+     */
+    private boolean isApprovalCopiedOver(
+        PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
+      return !changeNotes.getApprovals().get(changeNotes.getChange().currentPatchSetId()).stream()
+          .anyMatch(p -> p.equals(patchSetApproval));
+    }
+
     private void validatePostSubmitLabels(
         ChangeContext ctx,
         LabelTypes labelTypes,
@@ -1356,7 +1379,6 @@
         if (prev == null) {
           continue;
         }
-        checkState(prev != psa.value()); // Should be filtered out above.
         if (prev > psa.value()) {
           reduced.add(psa);
         }
@@ -1425,6 +1447,23 @@
       } else if (in.ready) {
         buf.append("\n\n" + START_REVIEW_MESSAGE);
       }
+
+      List<String> pluginMessages = new ArrayList<>();
+      onPostReviews.runEach(
+          onPostReview ->
+              onPostReview
+                  .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
+                  .ifPresent(
+                      pluginMessage ->
+                          pluginMessages.add(
+                              !pluginMessage.endsWith("\n")
+                                  ? pluginMessage + "\n"
+                                  : pluginMessage)));
+      if (!pluginMessages.isEmpty()) {
+        buf.append("\n\n");
+        buf.append(Joiner.on("\n").join(pluginMessages));
+      }
+
       if (buf.length() == 0) {
         return false;
       }
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
index ed6c0a5..4acf809 100644
--- a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
+++ b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
@@ -42,7 +42,6 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.Collection;
@@ -56,7 +55,6 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.kohsuke.args4j.Option;
 
-@Singleton
 public class PreviewSubmit implements RestReadView<RevisionResource> {
   private static final int MAX_DEFAULT_BUNDLE_SIZE = 100 * 1024 * 1024;
 
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 37318d0..1ed7fd7 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -34,7 +33,6 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -72,7 +70,6 @@
   private final PatchSetUtil psUtil;
   private final NotifyResolver notifyResolver;
   private final ProjectCache projectCache;
-  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   PutMessage(
@@ -84,8 +81,7 @@
       @GerritPersonIdent PersonIdent gerritIdent,
       PatchSetUtil psUtil,
       NotifyResolver notifyResolver,
-      ProjectCache projectCache,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      ProjectCache projectCache) {
     this.updateFactory = updateFactory;
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
@@ -95,7 +91,6 @@
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
     this.projectCache = projectCache;
-    this.urlFormatter = urlFormatter;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index 325b80c..3031781 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -60,7 +61,7 @@
       sanitizedInput.topic = sanitizedInput.topic.trim();
     }
 
-    SetTopicOp op = topicOpFactory.create(sanitizedInput);
+    SetTopicOp op = topicOpFactory.create(sanitizedInput.topic);
     try (BatchUpdate u =
         updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op);
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 7df74f8..cbbaa52 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -190,9 +190,7 @@
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = queryProcessor.query(qb.parse(queries));
     List<List<ChangeInfo>> res =
-        json.create(
-                options, queryProcessor.getAttributesFactory(), queryProcessor.getInfosFactory())
-            .format(results);
+        json.create(options, queryProcessor.getInfosFactory()).format(results);
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
       if (results.get(n).more() && !info.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 75ba4c1..cfdf04d 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -115,7 +116,7 @@
     try (Repository repo = repoManager.openRepository(change.getProject());
         ObjectInserter oi = repo.newObjectInserter();
         ObjectReader reader = oi.newReader();
-        RevWalk rw = new RevWalk(reader);
+        RevWalk rw = CodeReviewCommit.newRevWalk(reader);
         BatchUpdate bu =
             updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       if (!change.isNew()) {
@@ -124,18 +125,23 @@
         throw new ResourceConflictException(
             "cannot rebase merge commits or commit with no ancestor");
       }
-      // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
-      bu.setNotify(NotifyResolver.Result.none());
-      bu.setRepository(repo, rw, oi);
-      bu.addOp(
-          change.getId(),
+      RebaseChangeOp rebaseOp =
           rebaseFactory
               .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
               .setForceContentMerge(true)
-              .setFireRevisionCreated(true));
+              .setAllowConflicts(input.allowConflicts)
+              .setFireRevisionCreated(true);
+      // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
+      bu.setNotify(NotifyResolver.Result.none());
+      bu.setRepository(repo, rw, oi);
+      bu.addOp(change.getId(), rebaseOp);
       bu.execute();
+
+      ChangeInfo changeInfo = json.create(OPTIONS).format(change.getProject(), change.getId());
+      changeInfo.containsGitConflicts =
+          !rebaseOp.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
+      return Response.ok(changeInfo);
     }
-    return Response.ok(json.create(OPTIONS).format(change.getProject(), change.getId()));
   }
 
   private ObjectId findBaseRev(
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index c4dd04e..7fe463e 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -69,13 +70,9 @@
     }
     input.user = Strings.nullToEmpty(input.user).trim();
     if (!input.user.isEmpty()) {
-      Account.Id attentionUserId = null;
-      try {
-        attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
-      } catch (AccountResolver.UnresolvableAccountException ex) {
-        throw new BadRequestException(
-            "The user specified in the input body couldn't be found.", ex);
-      }
+      Account.Id attentionUserId =
+          AttentionSetUtil.resolveAccount(
+              accountResolver, attentionResource.getChangeResource().getNotes(), input.user);
       if (attentionUserId.get() != attentionResource.getAccountId().get()) {
         throw new BadRequestException(
             "The field \"user\" must be empty, or must match the user specified in the URL.");
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 65c0cda..a1bd678 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -312,10 +312,14 @@
       throws BadRequestException, IOException, PermissionBackendException,
           UnprocessableEntityException, ConfigInvalidException {
     AttentionSetUtil.validateInput(add);
-    Account.Id attentionUserId =
-        getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
-
-    addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
+    try {
+      Account.Id attentionUserId =
+          getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
+      addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
+      // message here, then it would be possible to probe whether an account exists.
+    }
   }
 
   private void removeFromAttentionSet(
@@ -326,10 +330,14 @@
       throws BadRequestException, IOException, PermissionBackendException,
           UnprocessableEntityException, ConfigInvalidException {
     AttentionSetUtil.validateInput(remove);
-    Account.Id attentionUserId =
-        getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
-
-    removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
+    try {
+      Account.Id attentionUserId =
+          getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
+      removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
+      // message here, then it would be possible to probe whether an account exists.
+    }
   }
 
   private Account.Id getAccountId(ChangeNotes changeNotes, String user)
@@ -356,15 +364,21 @@
       ChangeNotes changeNotes, String user, Set<Account.Id> accountsChangedInCommit)
       throws ConfigInvalidException, IOException, PermissionBackendException,
           UnprocessableEntityException, BadRequestException {
-    Account.Id attentionUserId = getAccountId(changeNotes, user);
-    if (accountsChangedInCommit.contains(attentionUserId)) {
-      throw new BadRequestException(
-          String.format(
-              "%s can not be added/removed twice, and can not be added and "
-                  + "removed at the same time",
-              user));
+    try {
+      Account.Id attentionUserId = getAccountId(changeNotes, user);
+      if (accountsChangedInCommit.contains(attentionUserId)) {
+        throw new BadRequestException(
+            String.format(
+                "%s can not be added/removed twice, and can not be added and "
+                    + "removed at the same time",
+                user));
+      }
+      accountsChangedInCommit.add(attentionUserId);
+      return attentionUserId;
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      // This can only happen if this user can't see the account or the account doesn't exist.
+      // Silently modify the account's attention set anyway, if the account exists.
+      return accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
     }
-    accountsChangedInCommit.add(attentionUserId);
-    return attentionUserId;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 39df82d..d80ab696 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -182,7 +182,7 @@
     // Sort results
     Stream<Map.Entry<Account.Id, MutableDouble>> sorted =
         reviewerScores.entrySet().stream()
-            .sorted(Collections.reverseOrder(Map.Entry.comparingByValue()));
+            .sorted(Map.Entry.comparingByValue(Collections.reverseOrder()));
     List<Account.Id> sortedSuggestions = sorted.map(Map.Entry::getKey).collect(toList());
     logger.atFine().log("Sorted suggestions: %s", sortedSuggestions);
     return sortedSuggestions;
@@ -202,7 +202,7 @@
       double baseWeight, String query, List<Account.Id> candidateList)
       throws IOException, ConfigInvalidException {
     int numberOfRelevantChanges = config.getInt("suggest", "relevantChanges", 50);
-    // Get the user's last 25 changes, check reviewers
+    // Get the user's last numberOfRelevantChanges changes, check reviewers
     try {
       List<ChangeData> result =
           queryProvider
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index e77bfe7..ba0720a 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -30,8 +30,10 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -66,12 +68,14 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
@@ -108,7 +112,6 @@
 
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
-  private final ChangeData.Factory changeDataFactory;
   private final Provider<MergeOp> mergeOpProvider;
   private final Provider<MergeSuperSet> mergeSuperSet;
   private final AccountResolver accountResolver;
@@ -127,7 +130,6 @@
   Submit(
       GitRepositoryManager repoManager,
       PermissionBackend permissionBackend,
-      ChangeData.Factory changeDataFactory,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeSuperSet> mergeSuperSet,
       AccountResolver accountResolver,
@@ -137,7 +139,6 @@
       ProjectCache projectCache) {
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
-    this.changeDataFactory = changeDataFactory;
     this.mergeOpProvider = mergeOpProvider;
     this.mergeSuperSet = mergeSuperSet;
     this.accountResolver = accountResolver;
@@ -307,7 +308,7 @@
       throw new StorageException("Could not determine problems for the change", e);
     }
 
-    ChangeData cd = changeDataFactory.create(resource.getNotes());
+    ChangeData cd = resource.getChangeResource().getChangeData();
     try {
       MergeOp.checkSubmitRule(cd, false);
     } catch (ResourceConflictException e) {
@@ -373,8 +374,15 @@
 
   public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
     Set<ChangeData> mergeabilityMap = new HashSet<>();
+    Set<ObjectId> outDatedPatchsets = new HashSet<>();
     for (ChangeData change : cs.changes()) {
       mergeabilityMap.add(change);
+      // Add all the patchsets commit ids except the current patchset.
+      outDatedPatchsets.addAll(
+          change.notes().getPatchSets().values().stream()
+              .map(p -> p.commitId())
+              .collect(Collectors.toSet()));
+      outDatedPatchsets.remove(change.currentPatchSet().commitId());
     }
 
     ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
@@ -388,12 +396,17 @@
           allParents.add(parent.getId());
         }
       }
-
       for (ChangeData change : targetBranch) {
+
         RevCommit commit = commits.get(change.getId());
         boolean isMergeCommit = commit.getParentCount() > 1;
         boolean isLastInChain = !allParents.contains(commit.getId());
-
+        if (Arrays.stream(commit.getParents()).anyMatch(c -> outDatedPatchsets.contains(c.getId()))
+            && !isCherryPickSubmit(change)) {
+          // Found a parent that depends on an outdated patchset and the submit strategy is not
+          // cherry-pick.
+          continue;
+        }
         // Recheck mergeability rather than using value stored in the index,
         // which may be stale.
         // TODO(dborowitz): This is ugly; consider providing a way to not read
@@ -419,6 +432,11 @@
     return mergeabilityMap;
   }
 
+  private boolean isCherryPickSubmit(ChangeData changeData) {
+    SubmitTypeRecord submitTypeRecord = changeData.submitTypeRecord();
+    return submitTypeRecord.isOk() && submitTypeRecord.type == SubmitType.CHERRY_PICK;
+  }
+
   private HashMap<Change.Id, RevCommit> findCommits(
       Collection<ChangeData> changes, Project.NameKey project) throws IOException {
     HashMap<Change.Id, RevCommit> commits = new HashMap<>();
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 780c60a..c3dd1b5 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -222,7 +222,6 @@
     info.showAssigneeInChangesTable =
         toBoolean(
             config.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
-    info.largeChange = config.getInt("change", "largeChange", 500);
     info.replyTooltip =
         Optional.ofNullable(config.getString("change", null, "replyTooltip"))
                 .orElse("Reply and score")
@@ -305,6 +304,7 @@
     info.editGpgKeys =
         toBoolean(enableSignedPush && config.getBoolean("gerrit", null, "editGpgKeys", true));
     info.primaryWeblinkName = config.getString("gerrit", null, "primaryWeblinkName");
+    info.instanceId = config.getString("gerrit", null, "instanceId");
     return info;
   }
 
@@ -320,17 +320,12 @@
     PluginConfigInfo info = new PluginConfigInfo();
     info.hasAvatars = toBoolean(avatar.hasImplementation());
     info.jsResourcePaths = new ArrayList<>();
-    info.htmlResourcePaths = new ArrayList<>();
     plugins.runEach(
         plugin -> {
           String path =
               String.format(
                   "plugins/%s/%s", plugin.getPluginName(), plugin.getJavaScriptResourcePath());
-          if (path.endsWith(".html")) {
-            info.htmlResourcePaths.add(path);
-          } else {
-            info.jsResourcePaths.add(path);
-          }
+          info.jsResourcePaths.add(path);
         });
     return info;
   }
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index 74ca721..0ec63ba 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -45,7 +46,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -54,6 +54,7 @@
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.GroupCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -66,6 +67,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Optional;
+import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -75,7 +77,7 @@
 public class CreateGroup
     implements RestCollectionCreateView<TopLevelResource, GroupResource, GroupInput> {
   private final Provider<IdentifiedUser> self;
-  private final PersonIdent serverIdent;
+  private final TimeZone serverTimeZone;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupCache groupCache;
   private final GroupResolver groups;
@@ -89,7 +91,7 @@
   @Inject
   CreateGroup(
       Provider<IdentifiedUser> self,
-      @GerritPersonIdent PersonIdent serverIdent,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
       GroupCache groupCache,
       GroupResolver groups,
@@ -100,7 +102,7 @@
       @GerritServerConfig Config cfg,
       Sequences sequences) {
     this.self = self;
-    this.serverIdent = serverIdent;
+    this.serverTimeZone = serverIdent.get().getTimeZone();
     this.groupsUpdateProvider = groupsUpdateProvider;
     this.groupCache = groupCache;
     this.groups = groups;
@@ -210,7 +212,7 @@
             createGroupArgs.uuid,
             GroupUuid.make(
                 createGroupArgs.getGroupName(),
-                self.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone())));
+                self.get().newCommitterIdent(TimeUtil.nowTs(), serverTimeZone)));
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
             .setGroupUUID(uuid)
diff --git a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
index 8a469f1..e3aa0f3 100644
--- a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.server.restapi.group;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.util.Comparator.comparing;
 
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroupByIdAudit;
 import com.google.gerrit.entities.AccountGroupMemberAudit;
 import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -33,7 +35,6 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -42,7 +43,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Optional;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
 
@@ -105,14 +106,17 @@
                   member));
         }
       }
-
-      for (AccountGroupByIdAudit auditEvent :
-          groups.getSubgroupsAudit(allUsersRepo, group.getGroupUUID())) {
+      List<AccountGroupByIdAudit> subGroupsAudit =
+          groups.getSubgroupsAudit(allUsersRepo, group.getGroupUUID());
+      Map<AccountGroup.UUID, InternalGroup> groups =
+          groupCache.get(
+              subGroupsAudit.stream().map(a -> a.includeUuid()).collect(toImmutableList()));
+      for (AccountGroupByIdAudit auditEvent : subGroupsAudit) {
         AccountGroup.UUID includedGroupUUID = auditEvent.includeUuid();
-        Optional<InternalGroup> includedGroup = groupCache.get(includedGroupUUID);
+        InternalGroup includedGroup = groups.get(includedGroupUUID);
         GroupInfo member;
-        if (includedGroup.isPresent()) {
-          member = groupJson.format(new InternalGroupDescription(includedGroup.get()));
+        if (includedGroup != null) {
+          member = groupJson.format(new InternalGroupDescription(includedGroup));
         } else {
           member = new GroupInfo();
           member.id = Url.encode(includedGroupUUID.get());
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index 3e2a577..96402be 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.restapi.group;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
@@ -57,7 +57,6 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
-import java.util.Optional;
 import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
@@ -273,10 +272,12 @@
       throws IOException, ConfigInvalidException, PermissionBackendException {
     Pattern pattern = getRegexPattern();
     Stream<GroupDescription.Internal> existingGroups =
-        getAllExistingGroups()
-            .filter(group -> isRelevant(pattern, group))
-            .map(this::loadGroup)
-            .flatMap(Streams::stream)
+        loadGroups(
+                getAllExistingGroups()
+                    .filter(group -> isRelevant(pattern, group))
+                    .map(g -> g.getUUID())
+                    .collect(toImmutableSet()))
+            .stream()
             .filter(this::isVisible)
             .sorted(GROUP_COMPARATOR)
             .skip(start);
@@ -359,11 +360,13 @@
       throws IOException, ConfigInvalidException, PermissionBackendException {
     Pattern pattern = getRegexPattern();
     Stream<? extends GroupDescription.Internal> foundGroups =
-        groups
-            .getAllGroupReferences()
-            .filter(group -> isRelevant(pattern, group))
-            .map(this::loadGroup)
-            .flatMap(Streams::stream)
+        loadGroups(
+                groups
+                    .getAllGroupReferences()
+                    .filter(group -> isRelevant(pattern, group))
+                    .map(g -> g.getUUID())
+                    .collect(toImmutableSet()))
+            .stream()
             .filter(this::isVisible)
             .filter(filter)
             .sorted(GROUP_COMPARATOR)
@@ -379,8 +382,10 @@
     return groupInfos;
   }
 
-  private Optional<GroupDescription.Internal> loadGroup(GroupReference groupReference) {
-    return groupCache.get(groupReference.getUUID()).map(InternalGroupDescription::new);
+  private Set<GroupDescription.Internal> loadGroups(Collection<AccountGroup.UUID> groupUuids) {
+    return groupCache.get(groupUuids).values().stream()
+        .map(InternalGroupDescription::new)
+        .collect(toImmutableSet());
   }
 
   private List<GroupInfo> getGroupsOwnedBy(String id)
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
index 87b00c1..22870c1 100644
--- a/java/com/google/gerrit/server/restapi/group/ListMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -31,7 +32,6 @@
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
@@ -157,23 +157,21 @@
   private Set<Account.Id> getIndirectMemberIds(
       GroupDescription.Internal group, HashSet<AccountGroup.UUID> seenGroups) {
     Set<Account.Id> indirectMembers = new HashSet<>();
+    Set<AccountGroup.UUID> subgroupMembersToLoad = new HashSet<>();
     for (AccountGroup.UUID subgroupUuid : group.getSubgroups()) {
       if (!seenGroups.contains(subgroupUuid)) {
         seenGroups.add(subgroupUuid);
-
-        Set<Account.Id> subgroupMembers =
-            groupCache
-                .get(subgroupUuid)
-                .map(InternalGroupDescription::new)
-                .map(
-                    subgroup -> {
-                      GroupControl subgroupControl = groupControlFactory.controlFor(subgroup);
-                      return getTransitiveMemberIds(subgroup, subgroupControl, seenGroups);
-                    })
-                .orElseGet(ImmutableSet::of);
-        indirectMembers.addAll(subgroupMembers);
+        subgroupMembersToLoad.add(subgroupUuid);
       }
     }
+    groupCache.get(subgroupMembersToLoad).values().stream()
+        .map(InternalGroupDescription::new)
+        .forEach(
+            subgroup -> {
+              GroupControl subgroupControl = groupControlFactory.controlFor(subgroup);
+              indirectMembers.addAll(getTransitiveMemberIds(subgroup, subgroupControl, seenGroups));
+            });
+
     return indirectMembers;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
index 380d42e..26e8459 100644
--- a/java/com/google/gerrit/server/restapi/group/QueryGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -26,7 +27,6 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.group.GroupQueryBuilder;
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 037a953..37616cd 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// 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.
@@ -17,6 +17,7 @@
 import static com.google.gerrit.entities.RefNames.REFS_HEADS;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
@@ -25,9 +26,10 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 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.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.DefaultPermissionMappings;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -36,15 +38,14 @@
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
 
-@Singleton
-public class CheckAccess implements RestModifyView<ProjectResource, AccessCheckInput> {
+public class CheckAccess implements RestReadView<ProjectResource> {
   private final AccountResolver accountResolver;
   private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitRepositoryManager;
@@ -59,7 +60,15 @@
     this.gitRepositoryManager = gitRepositoryManager;
   }
 
-  @Override
+  @Option(name = "--ref", usage = "ref name to check permission for")
+  String refName;
+
+  @Option(name = "--account", usage = "account to check acccess for")
+  String account;
+
+  @Option(name = "--perm", usage = "permission to check; default: read of any ref.")
+  String permission;
+
   public Response<AccessCheckInfo> apply(ProjectResource rsrc, AccessCheckInput input)
       throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
     permissionBackend.user(rsrc.getUser()).check(GlobalPermission.VIEW_ACCESS);
@@ -73,60 +82,90 @@
       throw new BadRequestException("input requires 'account'");
     }
 
-    Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
+    try (TraceContext traceContext = TraceContext.open()) {
+      traceContext.enableAclLogging();
 
-    AccessCheckInfo info = new AccessCheckInfo();
-    try {
-      permissionBackend
-          .absentUser(match)
-          .project(rsrc.getNameKey())
-          .check(ProjectPermission.ACCESS);
-    } catch (AuthException e) {
-      info.message = String.format("user %s cannot see project %s", match, rsrc.getName());
-      info.status = HttpServletResponse.SC_FORBIDDEN;
-      return Response.ok(info);
-    }
+      Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
 
-    RefPermission refPerm;
-    if (!Strings.isNullOrEmpty(input.permission)) {
-      if (Strings.isNullOrEmpty(input.ref)) {
-        throw new BadRequestException("must set 'ref' when specifying 'permission'");
-      }
-      Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
-      if (!rp.isPresent()) {
-        throw new BadRequestException(
-            String.format("'%s' is not recognized as ref permission", input.permission));
-      }
-
-      refPerm = rp.get();
-    } else {
-      refPerm = RefPermission.READ;
-    }
-
-    if (!Strings.isNullOrEmpty(input.ref)) {
       try {
         permissionBackend
             .absentUser(match)
-            .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
-            .check(refPerm);
+            .project(rsrc.getNameKey())
+            .check(ProjectPermission.ACCESS);
       } catch (AuthException e) {
-        info.status = HttpServletResponse.SC_FORBIDDEN;
-        info.message =
-            String.format(
-                "user %s lacks permission %s for %s in project %s",
-                match, input.permission, input.ref, rsrc.getName());
-        return Response.ok(info);
+        return Response.ok(
+            createInfo(
+                traceContext,
+                HttpServletResponse.SC_FORBIDDEN,
+                String.format("user %s cannot see project %s", match, rsrc.getName())));
       }
-    } else {
-      // We say access is okay if there are no refs, but this warrants a warning,
-      // as access denied looks the same as no branches to the user.
-      try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
-        if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
-          info.message = "access is OK, but repository has no branches under refs/heads/";
+
+      RefPermission refPerm;
+      if (!Strings.isNullOrEmpty(input.permission)) {
+        if (Strings.isNullOrEmpty(input.ref)) {
+          throw new BadRequestException("must set 'ref' when specifying 'permission'");
+        }
+        Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
+        if (!rp.isPresent()) {
+          throw new BadRequestException(
+              String.format("'%s' is not recognized as ref permission", input.permission));
+        }
+
+        refPerm = rp.get();
+      } else {
+        refPerm = RefPermission.READ;
+      }
+
+      String message = null;
+      if (!Strings.isNullOrEmpty(input.ref)) {
+        try {
+          permissionBackend
+              .absentUser(match)
+              .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
+              .check(refPerm);
+        } catch (AuthException e) {
+          return Response.ok(
+              createInfo(
+                  traceContext,
+                  HttpServletResponse.SC_FORBIDDEN,
+                  String.format(
+                      "user %s lacks permission %s for %s in project %s",
+                      match, input.permission, input.ref, rsrc.getName())));
+        }
+      } else {
+        // We say access is okay if there are no refs, but this warrants a warning,
+        // as access denied looks the same as no branches to the user.
+        try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
+          if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
+            message = "access is OK, but repository has no branches under refs/heads/";
+          }
         }
       }
+      return Response.ok(createInfo(traceContext, HttpServletResponse.SC_OK, message));
     }
-    info.status = HttpServletResponse.SC_OK;
-    return Response.ok(info);
+  }
+
+  private AccessCheckInfo createInfo(TraceContext traceContext, int statusCode, String message) {
+    AccessCheckInfo info = new AccessCheckInfo();
+    info.status = statusCode;
+    info.message = message;
+    info.debugLogs = traceContext.getAclLogRecords();
+    if (info.debugLogs.isEmpty()) {
+      info.debugLogs =
+          ImmutableList.of("Found no rules that apply, so defaulting to no permission");
+    }
+    return info;
+  }
+
+  @Override
+  public Response<AccessCheckInfo> apply(ProjectResource rsrc)
+      throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
+
+    AccessCheckInput input = new AccessCheckInput();
+    input.ref = refName;
+    input.account = account;
+    input.permission = permission;
+
+    return apply(rsrc, input);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java b/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
deleted file mode 100644
index 6aaa678..0000000
--- a/java/com/google/gerrit/server/restapi/project/CheckAccessReadView.java
+++ /dev/null
@@ -1,62 +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.restapi.project;
-
-import com.google.gerrit.extensions.api.config.AccessCheckInfo;
-import com.google.gerrit.extensions.api.config.AccessCheckInput;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectResource;
-import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.kohsuke.args4j.Option;
-
-public class CheckAccessReadView implements RestReadView<ProjectResource> {
-  String refName;
-  String account;
-  String permission;
-
-  @Inject CheckAccess checkAccess;
-
-  @Option(name = "--ref", usage = "ref name to check permission for")
-  void addOption(String refName) {
-    this.refName = refName;
-  }
-
-  @Option(name = "--account", usage = "account to check acccess for")
-  void setAccount(String account) {
-    this.account = account;
-  }
-
-  @Option(name = "--perm", usage = "permission to check; default: read of any ref.")
-  void setPermission(String perm) {
-    this.permission = perm;
-  }
-
-  @Override
-  public Response<AccessCheckInfo> apply(ProjectResource rsrc)
-      throws PermissionBackendException, RestApiException, IOException, ConfigInvalidException {
-
-    AccessCheckInput input = new AccessCheckInput();
-    input.ref = refName;
-    input.account = account;
-    input.permission = permission;
-
-    return checkAccess.apply(rsrc, input);
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
index da6ff14..4730318 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
@@ -101,10 +101,20 @@
       }
 
       RevCommit targetCommit = rw.parseCommit(destRef.getObjectId());
-      RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, source);
 
-      if (!commits.canRead(resource.getProjectState(), git, sourceCommit)) {
-        throw new BadRequestException("do not have read permission for: " + source);
+      RevCommit sourceCommit = null;
+      try {
+        sourceCommit = MergeUtil.resolveCommit(git, rw, source);
+        if (!commits.canRead(resource.getProjectState(), git, sourceCommit)) {
+          throw new BadRequestException("do not have read permission for: " + source);
+        }
+      } catch (BadRequestException e) {
+        // Throw a unified exception for permission denied and unresolvable commits.
+        throw new BadRequestException(
+            "Error resolving: '"
+                + source
+                + "'. Do not have read permission, or failed to resolve to a commit.",
+            e);
       }
 
       if (rw.isMergedInto(sourceCommit, targetCommit)) {
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
index 21d7f0b..033463c 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -48,10 +49,12 @@
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/** The collection of commit IDs (ie. 40 char hex IDs) */
 @Singleton
 public class CommitsCollection implements ChildCollection<ProjectResource, CommitResource> {
   private final DynamicMap<RestView<CommitResource>> views;
@@ -93,10 +96,12 @@
     try (Repository repo = repoManager.openRepository(parent.getNameKey());
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(objectId);
-      rw.parseBody(commit);
       if (!canRead(parent.getProjectState(), repo, commit)) {
         throw new ResourceNotFoundException(id);
       }
+      // GetCommit depends on the body of both the commit and parent being parsed, to get the
+      // subject.
+      rw.parseBody(commit);
       for (int i = 0; i < commit.getParentCount(); i++) {
         rw.parseBody(rw.parseCommit(commit.getParent(i)));
       }
@@ -171,9 +176,8 @@
     // If we have already checked change refs using the change index, spare any further checks for
     // changes.
     List<Ref> refs =
-        repo.getRefDatabase().getRefs().stream()
-            .filter(r -> !r.getName().startsWith(RefNames.REFS_CHANGES))
-            .collect(toImmutableList());
+        repo.getRefDatabase()
+            .getRefsByPrefixWithExclusions(RefDatabase.ALL, ImmutableSet.of(RefNames.REFS_CHANGES));
     return reachable.fromRefs(project, repo, commit, refs);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
similarity index 77%
rename from java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
rename to java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
index 783b39b..904a16f 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
@@ -21,6 +21,10 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.ConfigParameterInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.InheritedBooleanInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.MaxObjectSizeLimitInfo;
+import com.google.gerrit.extensions.api.projects.ConfigInfo.SubmitTypeInfo;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -42,9 +46,12 @@
 import java.util.Map;
 import java.util.TreeMap;
 
-public class ConfigInfoImpl extends ConfigInfo {
+public class ConfigInfoCreator {
+  /** do not instantiate this class. */
+  private ConfigInfoCreator() {}
+
   @SuppressWarnings("deprecation")
-  public ConfigInfoImpl(
+  public static ConfigInfo constructInfo(
       boolean serverEnableSignedPush,
       ProjectState projectState,
       CurrentUser user,
@@ -53,8 +60,9 @@
       AllProjectsName allProjects,
       UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
+    ConfigInfo configInfo = new ConfigInfo();
     Project p = projectState.getProject();
-    this.description = Strings.emptyToNull(p.getDescription());
+    configInfo.description = Strings.emptyToNull(p.getDescription());
 
     ProjectState parentState = Iterables.getFirst(projectState.parents(), null);
     for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
@@ -63,48 +71,51 @@
       if (parentState != null) {
         info.inheritedValue = parentState.is(cfg);
       }
-      BooleanProjectConfigTransformations.set(cfg, this, info);
+      BooleanProjectConfigTransformations.set(cfg, configInfo, info);
     }
 
     if (!serverEnableSignedPush) {
-      this.enableSignedPush = null;
-      this.requireSignedPush = null;
+      configInfo.enableSignedPush = null;
+      configInfo.requireSignedPush = null;
     }
 
-    this.maxObjectSizeLimit = getMaxObjectSizeLimit(projectState, p);
+    configInfo.maxObjectSizeLimit = getMaxObjectSizeLimit(projectState, p);
 
-    this.defaultSubmitType = new SubmitTypeInfo();
-    this.defaultSubmitType.value = projectState.getSubmitType();
-    this.defaultSubmitType.configuredValue =
+    configInfo.defaultSubmitType = new SubmitTypeInfo();
+    configInfo.defaultSubmitType.value = projectState.getSubmitType();
+    configInfo.defaultSubmitType.configuredValue =
         MoreObjects.firstNonNull(
             projectState.getConfig().getProject().getSubmitType(), Project.DEFAULT_SUBMIT_TYPE);
     ProjectState parent =
         projectState.isAllProjects() ? projectState : projectState.parents().get(0);
-    this.defaultSubmitType.inheritedValue = parent.getSubmitType();
+    configInfo.defaultSubmitType.inheritedValue = parent.getSubmitType();
 
-    this.submitType = this.defaultSubmitType.value;
+    configInfo.submitType = configInfo.defaultSubmitType.value;
 
-    this.state =
+    configInfo.state =
         p.getState() != com.google.gerrit.extensions.client.ProjectState.ACTIVE
             ? p.getState()
             : null;
 
-    this.commentlinks = new LinkedHashMap<>();
+    configInfo.commentlinks = new LinkedHashMap<>();
     for (CommentLinkInfo cl : projectState.getCommentLinks()) {
-      this.commentlinks.put(cl.name, cl);
+      configInfo.commentlinks.put(cl.name, cl);
     }
 
-    pluginConfig = getPluginConfig(projectState, pluginConfigEntries, cfgFactory, allProjects);
+    configInfo.pluginConfig =
+        getPluginConfig(projectState, pluginConfigEntries, cfgFactory, allProjects);
 
-    actions = new TreeMap<>();
+    configInfo.actions = new TreeMap<>();
     for (UiAction.Description d : uiActions.from(views, new ProjectResource(projectState, user))) {
-      actions.put(d.getId(), new ActionInfo(d));
+      configInfo.actions.put(d.getId(), new ActionInfo(d));
     }
 
-    this.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
+    configInfo.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
+    return configInfo;
   }
 
-  private MaxObjectSizeLimitInfo getMaxObjectSizeLimit(ProjectState projectState, Project p) {
+  private static MaxObjectSizeLimitInfo getMaxObjectSizeLimit(
+      ProjectState projectState, Project p) {
     MaxObjectSizeLimitInfo info = new MaxObjectSizeLimitInfo();
     EffectiveMaxObjectSizeLimit limit = projectState.getEffectiveMaxObjectSizeLimit();
     long value = limit.value;
@@ -114,7 +125,7 @@
     return info;
   }
 
-  private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
+  private static Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
       ProjectState project,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
@@ -162,7 +173,7 @@
     return !pluginConfig.isEmpty() ? pluginConfig : null;
   }
 
-  private String getInheritedValue(
+  private static String getInheritedValue(
       ProjectState project, PluginConfigFactory cfgFactory, Extension<ProjectConfigEntry> e) {
     ProjectConfigEntry configEntry = e.getProvider().get();
     ProjectState parent = Iterables.getFirst(project.parents(), null);
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index b901057..2fd2d65 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -109,6 +109,12 @@
               + MagicBranch.getMagicRefNamePrefix(ref)
               + "\"");
     }
+    if (!isBranchAllowed(ref)) {
+      throw new BadRequestException(
+          "Cannot create a branch with name \""
+              + ref
+              + "\". Not allowed to create branches under Gerrit internal or tags refs.");
+    }
 
     BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
@@ -187,4 +193,9 @@
       throw new BadRequestException("invalid revision \"" + input.revision + "\"", e);
     }
   }
+
+  /** Branches cannot be created under any Gerrit internal or tags refs. */
+  private boolean isBranchAllowed(String branch) {
+    return !RefNames.isGerritRef(branch) && !branch.startsWith(RefNames.REFS_TAGS);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateChange.java b/java/com/google/gerrit/server/restapi/project/CreateChange.java
index dbcd8c9..59efd06 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateChange.java
@@ -59,8 +59,8 @@
       throw new AuthException("Authentication required");
     }
 
-    if (!Strings.isNullOrEmpty(input.project)) {
-      throw new BadRequestException("may not specify project");
+    if (!Strings.isNullOrEmpty(input.project) && !rsrc.getName().equals(input.project)) {
+      throw new BadRequestException("project must match URL");
     }
 
     input.project = rsrc.getName();
diff --git a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
index e9a0d7f..34d6696 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
@@ -26,11 +26,9 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import org.kohsuke.args4j.Option;
 
-@Singleton
 public class CreateDashboard
     implements RestCollectionCreateView<ProjectResource, DashboardResource, SetDashboardInput> {
   private final Provider<SetDefaultDashboard> setDefault;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index e93c25c..025ff50 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -174,6 +174,11 @@
       labelType.setCopyMaxScore(input.copyMaxScore);
     }
 
+    if (input.copyAllScoresIfListOfFilesDidNotChange != null) {
+      labelType.setCopyAllScoresIfListOfFilesDidNotChange(
+          input.copyAllScoresIfListOfFilesDidNotChange);
+    }
+
     if (input.copyAllScoresIfNoChange != null) {
       labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index a5a0034..f3b2bad 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ProjectOwnerGroupsProvider;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -60,6 +61,7 @@
 import java.util.List;
 import java.util.concurrent.locks.Lock;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Repository;
 
@@ -79,6 +81,8 @@
   private final PluginItemContext<ProjectNameLockManager> lockManager;
   private final ProjectCreator projectCreator;
 
+  private final Config gerritConfig;
+
   @Inject
   CreateProject(
       ProjectCreator projectCreator,
@@ -90,7 +94,8 @@
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
       AllUsersName allUsers,
-      PluginItemContext<ProjectNameLockManager> lockManager) {
+      PluginItemContext<ProjectNameLockManager> lockManager,
+      @GerritServerConfig Config gerritConfig) {
     this.projectsCollection = projectsCollection;
     this.projectCreator = projectCreator;
     this.groupResolver = groupResolver;
@@ -101,6 +106,7 @@
     this.allProjects = allProjects;
     this.allUsers = allUsers;
     this.lockManager = lockManager;
+    this.gerritConfig = gerritConfig;
   }
 
   @Override
@@ -190,22 +196,47 @@
 
   private List<String> normalizeBranchNames(List<String> branches) throws BadRequestException {
     if (branches == null || branches.isEmpty()) {
-      return Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
+      // Use host-level default for HEAD or fall back to 'master' if nothing else was specified in
+      // the input.
+      String defaultBranch = gerritConfig.getString("gerrit", null, "defaultBranch");
+      defaultBranch =
+          defaultBranch != null
+              ? normalizeAndValidateBranch(defaultBranch)
+              : Constants.R_HEADS + Constants.MASTER;
+      return Collections.singletonList(defaultBranch);
     }
-
     List<String> normalizedBranches = new ArrayList<>();
     for (String branch : branches) {
-      while (branch.startsWith("/")) {
-        branch = branch.substring(1);
-      }
-      branch = RefNames.fullName(branch);
-      if (!Repository.isValidRefName(branch)) {
-        throw new BadRequestException(String.format("Branch \"%s\" is not a valid name.", branch));
-      }
+      branch = normalizeAndValidateBranch(branch);
       if (!normalizedBranches.contains(branch)) {
         normalizedBranches.add(branch);
       }
     }
     return normalizedBranches;
   }
+
+  private String normalizeAndValidateBranch(String branch) throws BadRequestException {
+    while (branch.startsWith("/")) {
+      branch = branch.substring(1);
+    }
+    branch = RefNames.fullName(branch);
+    if (!Repository.isValidRefName(branch)) {
+      throw new BadRequestException(String.format("Branch \"%s\" is not a valid name.", branch));
+    }
+    return branch;
+  }
+
+  static class ValidBranchListener implements ProjectCreationValidationListener {
+    @Override
+    public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+      for (String branch : args.branch) {
+        if (RefNames.isGerritRef(branch)) {
+          throw new ValidationException(
+              String.format(
+                  "Cannot create a project with branch %s. Branches in the Gerrit internal refs namespace are not allowed",
+                  branch));
+        }
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index 5cfb118..b552ff5 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -102,6 +102,8 @@
     try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
       ObjectId revid = RefUtil.parseBaseRevision(repo, resource.getNameKey(), input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
+      // Reachability through tags does not influence a commit's visibility, so no need to check for
+      // visibility.
       RevObject object = rw.parseAny(revid);
       rw.reset();
       boolean isAnnotated = Strings.emptyToNull(input.message) != null;
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 2395bdd..4e13ba9 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -160,7 +160,7 @@
    *
    * @param projectState the {@code ProjectState} of the project whose refs are to be deleted.
    * @param refsToDelete the refs to be deleted.
-   * @param prefix the prefix of the refs.
+   * @param prefix the prefix to add to abbreviated refs, eg. "refs/heads/".
    * @throws IOException
    * @throws ResourceConflictException
    * @throws PermissionBackendException
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTag.java b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
index 545b752..8d0a3d3 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTag.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.entities.RefNames.isConfigRef;
-
+import com.google.common.base.Preconditions;
 import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -27,6 +25,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import org.eclipse.jgit.lib.Constants;
 
 @Singleton
 public class DeleteTag implements RestModifyView<TagResource, Input> {
@@ -43,10 +42,7 @@
       throws RestApiException, IOException, PermissionBackendException {
     String tag = RefUtil.normalizeTagRef(resource.getTagInfo().ref);
 
-    if (isConfigRef(tag)) {
-      // Never allow to delete the meta config branch.
-      throw new MethodNotAllowedException("not allowed to delete " + tag);
-    }
+    Preconditions.checkState(tag.startsWith(Constants.R_TAGS));
 
     deleteRef.deleteSingleRef(resource.getProjectState(), tag);
     return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTags.java b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
index 6e8ec37..7ac3aff 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTags.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
@@ -43,6 +43,10 @@
     if (input == null || input.tags == null || input.tags.isEmpty()) {
       throw new BadRequestException("tags must be specified");
     }
+
+    // If input.tags = ["refs/heads/bla"], this will actually delete the 'ref/heads/bla' branch,
+    // rather than refs/tags/refs/heads/bla.
+    // Since this is checked against DELETE permissions for refs/heads/bla, we'll let it go through.
     deleteRef.deleteMultipleRefs(
         project.getProjectState(), ImmutableSet.copyOf(input.tags), R_TAGS);
     return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 0d5ab88..7bee2f2 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -27,7 +26,6 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.change.FileInfoJson;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.CommitResource;
 import com.google.gerrit.server.project.FileResource;
@@ -39,6 +37,10 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.kohsuke.args4j.Option;
 
+/**
+ * like {@link FilesCollection}, but for commits that are specified as hex ID, rather than branch
+ * names.
+ */
 @Singleton
 public class FilesInCommitCollection implements ChildCollection<CommitResource, FileResource> {
   private final DynamicMap<RestView<FileResource>> views;
@@ -85,21 +87,18 @@
       this.fileInfoJson = fileInfoJson;
     }
 
+    public ListFiles setParent(int parentNum) {
+      this.parentNum = parentNum;
+      return this;
+    }
+
     @Override
     public Response<Map<String, FileInfo>> apply(CommitResource resource)
         throws ResourceConflictException, PatchListNotAvailableException {
       RevCommit commit = resource.getCommit();
-      PatchListKey key;
-
-      if (parentNum > 0) {
-        key =
-            PatchListKey.againstParentNum(
-                parentNum, commit, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
-      } else {
-        key = PatchListKey.againstCommit(null, commit, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
-      }
-
-      return Response.ok(fileInfoJson.toFileInfoMap(resource.getProjectState().getNameKey(), key));
+      return Response.ok(
+          fileInfoJson.getFileInfoMap(
+              resource.getProjectState().getNameKey(), commit, parentNum - 1));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetConfig.java b/java/com/google/gerrit/server/restapi/project/GetConfig.java
index ad66587..8ffd5ec 100644
--- a/java/com/google/gerrit/server/restapi/project/GetConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/GetConfig.java
@@ -67,7 +67,7 @@
             .project(resource.getNameKey())
             .test(ProjectPermission.READ_CONFIG);
     return Response.ok(
-        new ConfigInfoImpl(
+        ConfigInfoCreator.constructInfo(
             serverEnableSignedPush,
             resource.getProjectState(),
             resource.getUser(),
diff --git a/java/com/google/gerrit/server/restapi/project/ListBranches.java b/java/com/google/gerrit/server/restapi/project/ListBranches.java
index fecdc8e..2c26933 100644
--- a/java/com/google/gerrit/server/restapi/project/ListBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/ListBranches.java
@@ -143,7 +143,11 @@
   BranchInfo toBranchInfo(BranchResource rsrc)
       throws IOException, ResourceNotFoundException, PermissionBackendException {
     try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
-      Ref r = db.exactRef(rsrc.getRef());
+      String refName = rsrc.getRef();
+      if (RefNames.isRefsUsersSelf(refName, rsrc.getProjectState().isAllUsers())) {
+        refName = RefNames.refsUsers(rsrc.getUser().getAccountId());
+      }
+      Ref r = db.exactRef(refName);
       if (r == null) {
         throw new ResourceNotFoundException();
       }
diff --git a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
index 0bd053e..fd18c8d 100644
--- a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
@@ -23,9 +23,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
@@ -41,16 +39,11 @@
   @Option(name = "--limit", usage = "maximum number of parents projects to list")
   private int limit;
 
-  private final PermissionBackend permissionBackend;
   private final ChildProjects childProjects;
   private final Provider<QueryProjects> queryProvider;
 
   @Inject
-  ListChildProjects(
-      PermissionBackend permissionBackend,
-      ChildProjects childProjects,
-      Provider<QueryProjects> queryProvider) {
-    this.permissionBackend = permissionBackend;
+  ListChildProjects(ChildProjects childProjects, Provider<QueryProjects> queryProvider) {
     this.childProjects = childProjects;
     this.queryProvider = queryProvider;
   }
@@ -83,10 +76,7 @@
   }
 
   private List<ProjectInfo> directChildProjects(Project.NameKey parent) throws RestApiException {
-    PermissionBackend.WithUser currentUser = permissionBackend.currentUser();
     return queryProvider.get().withQuery("parent:" + parent.get()).withLimit(limit).apply().stream()
-        .filter(
-            p -> currentUser.project(Project.nameKey(p.name)).testOrFalse(ProjectPermission.ACCESS))
         .collect(toList());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 5418876..c4ae33a 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -90,7 +90,11 @@
 import org.eclipse.jgit.lib.Repository;
 import org.kohsuke.args4j.Option;
 
-/** List projects visible to the calling user. */
+/**
+ * List projects visible to the calling user.
+ *
+ * <p>Implement {@code GET /projects/}, without a {@code query=} parameter.
+ */
 public class ListProjects implements RestReadView<TopLevelResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -486,7 +490,7 @@
                 continue;
               }
 
-              List<Ref> refs = retieveBranchRefs(e);
+              List<Ref> refs = retrieveBranchRefs(e);
               if (!hasValidRef(refs)) {
                 continue;
               }
@@ -574,7 +578,7 @@
     }
   }
 
-  private List<Ref> retieveBranchRefs(ProjectState e) throws PermissionBackendException {
+  private List<Ref> retrieveBranchRefs(ProjectState e) throws PermissionBackendException {
     boolean canReadAllRefs = e.statePermitsRead();
     if (canReadAllRefs) {
       try {
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index ee3914d..9217077 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -29,8 +29,10 @@
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.project.RefValidationHelper;
 import com.google.gerrit.server.restapi.change.CherryPickCommit;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 
 public class Module extends RestApiModule {
+
   @Override
   protected void configure() {
     bind(ProjectsCollection.class);
@@ -46,6 +48,8 @@
     DynamicMap.mapOf(binder(), LABEL_KIND);
 
     DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
+    DynamicSet.bind(binder(), ProjectCreationValidationListener.class)
+        .to(CreateProject.ValidBranchListener.class);
 
     create(PROJECT_KIND).to(CreateProject.class);
     put(PROJECT_KIND).to(PutProject.class);
@@ -57,7 +61,7 @@
     get(PROJECT_KIND, "access").to(GetAccess.class);
     post(PROJECT_KIND, "access").to(SetAccess.class);
     put(PROJECT_KIND, "access:review").to(CreateAccessChange.class);
-    get(PROJECT_KIND, "check.access").to(CheckAccessReadView.class);
+    get(PROJECT_KIND, "check.access").to(CheckAccess.class);
 
     post(PROJECT_KIND, "check").to(Check.class);
 
@@ -79,10 +83,7 @@
 
     put(PROJECT_KIND, "ban").to(BanCommit.class);
 
-    get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
-    post(PROJECT_KIND, "gc").to(GarbageCollect.class);
     post(PROJECT_KIND, "index").to(Index.class);
-    post(PROJECT_KIND, "index.changes").to(IndexChanges.class);
 
     child(PROJECT_KIND, "branches").to(BranchesCollection.class);
     create(BRANCH_KIND).to(CreateBranch.class);
@@ -121,4 +122,14 @@
 
     factory(ProjectNode.Factory.class);
   }
+
+  /** Separately bind batch functionality. */
+  public static class BatchModule extends RestApiModule {
+    @Override
+    protected void configure() {
+      get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
+      post(PROJECT_KIND, "gc").to(GarbageCollect.class);
+      post(PROJECT_KIND, "index.changes").to(IndexChanges.class);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index f92624f..efc739c 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -41,11 +41,9 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 
-@Singleton
 public class ProjectsCollection
     implements RestCollection<TopLevelResource, ProjectResource>, NeedsParams {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 3bf432b..0770bda 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -176,7 +176,7 @@
       }
 
       ProjectState state = projectStateFactory.create(projectConfigFactory.read(md).getCacheable());
-      return new ConfigInfoImpl(
+      return ConfigInfoCreator.constructInfo(
           serverEnableSignedPush,
           state,
           user.get(),
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index e4f7df5..a9d818d 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -36,6 +36,7 @@
 import java.util.List;
 import org.kohsuke.args4j.Option;
 
+/** Implements the {@code GET /projects/?query=QUERY} endpoint. */
 public class QueryProjects implements RestReadView<TopLevelResource> {
   private final ProjectIndexCollection indexes;
   private final ProjectQueryBuilder queryBuilder;
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index 1e04dc4..34b6812 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -189,6 +189,12 @@
       dirty = true;
     }
 
+    if (input.copyAllScoresIfListOfFilesDidNotChange != null) {
+      labelTypeBuilder.setCopyAllScoresIfListOfFilesDidNotChange(
+          input.copyAllScoresIfListOfFilesDidNotChange);
+      dirty = true;
+    }
+
     if (input.copyAllScoresIfNoChange != null) {
       labelTypeBuilder.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
       dirty = true;
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 4592100..e670dc2 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.rules;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
@@ -22,7 +23,6 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.project.ProjectCache;
@@ -76,32 +76,17 @@
     SubmitRecord submitRecord = new SubmitRecord();
     submitRecord.status = SubmitRecord.Status.OK;
 
-    List<LabelType> labelTypes;
-    List<PatchSetApproval> approvals;
-    try {
-      labelTypes = cd.getLabelTypes().getLabelTypes();
-      approvals = cd.currentApprovals();
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Unable to fetch labels and approvals for change %s", cd.getId());
-
-      submitRecord.errorMessage = "Unable to fetch labels and approvals for the change";
-      submitRecord.status = SubmitRecord.Status.RULE_ERROR;
-      return Optional.of(submitRecord);
-    }
-
+    List<LabelType> labelTypes = cd.getLabelTypes().getLabelTypes();
+    List<PatchSetApproval> approvals = cd.currentApprovals();
     submitRecord.labels = new ArrayList<>(labelTypes.size());
 
     for (LabelType t : labelTypes) {
       LabelFunction labelFunction = t.getFunction();
-      if (labelFunction == null) {
-        logger.atSevere().log(
-            "Unable to find the LabelFunction for label %s, change %s", t.getName(), cd.getId());
-
-        submitRecord.errorMessage = "Unable to find the LabelFunction for label " + t.getName();
-        submitRecord.status = SubmitRecord.Status.RULE_ERROR;
-        return Optional.of(submitRecord);
-      }
+      checkState(
+          labelFunction != null,
+          "Unable to find the LabelFunction for label %s, change %s",
+          t.getName(),
+          cd.getId());
 
       Collection<PatchSetApproval> approvalsForLabel = getApprovalsForLabel(approvals, t);
       SubmitRecord.Label label = labelFunction.check(t, approvalsForLabel);
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
index b2bfbd5..08335df 100644
--- a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -17,14 +17,12 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
@@ -40,11 +38,6 @@
  */
 @Singleton
 public class IgnoreSelfApprovalRule implements SubmitRule {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-  private static final String E_UNABLE_TO_FETCH_UPLOADER = "Unable to fetch uploader";
-  private static final String E_UNABLE_TO_FETCH_LABELS =
-      "Unable to fetch labels and approvals for the change";
-
   public static class Module extends AbstractModule {
     @Override
     public void configure() {
@@ -56,16 +49,8 @@
 
   @Override
   public Optional<SubmitRecord> evaluate(ChangeData cd) {
-    List<LabelType> labelTypes;
-    List<PatchSetApproval> approvals;
-    try {
-      labelTypes = cd.getLabelTypes().getLabelTypes();
-      approvals = cd.currentApprovals();
-    } catch (StorageException e) {
-      logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_LABELS);
-      return ruleError(E_UNABLE_TO_FETCH_LABELS);
-    }
-
+    List<LabelType> labelTypes = cd.getLabelTypes().getLabelTypes();
+    List<PatchSetApproval> approvals = cd.currentApprovals();
     boolean shouldIgnoreSelfApproval =
         labelTypes.stream().anyMatch(LabelType::isIgnoreSelfApproval);
     if (!shouldIgnoreSelfApproval) {
@@ -73,14 +58,7 @@
       return Optional.empty();
     }
 
-    Account.Id uploader;
-    try {
-      uploader = cd.currentPatchSet().uploader();
-    } catch (StorageException e) {
-      logger.atWarning().withCause(e).log(E_UNABLE_TO_FETCH_UPLOADER);
-      return ruleError(E_UNABLE_TO_FETCH_UPLOADER);
-    }
-
+    Account.Id uploader = cd.currentPatchSet().uploader();
     SubmitRecord submitRecord = new SubmitRecord();
     submitRecord.status = SubmitRecord.Status.OK;
     submitRecord.labels = new ArrayList<>(labelTypes.size());
@@ -112,7 +90,7 @@
         // Add an additional requirement to be more descriptive on why the label counts as not
         // approved.
         submitRecord.requirements.add(
-            SubmitRequirement.builder()
+            LegacySubmitRequirement.builder()
                 .setFallbackText("Approval from non-uploader required")
                 .setType("non_uploader_approval")
                 .build());
@@ -140,13 +118,6 @@
     return false;
   }
 
-  private static Optional<SubmitRecord> ruleError(String reason) {
-    SubmitRecord submitRecord = new SubmitRecord();
-    submitRecord.errorMessage = reason;
-    submitRecord.status = SubmitRecord.Status.RULE_ERROR;
-    return Optional.of(submitRecord);
-  }
-
   @VisibleForTesting
   static Collection<PatchSetApproval> filterOutPositiveApprovalsOfUser(
       Collection<PatchSetApproval> approvals, Account.Id user) {
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 57c4832..95e0829 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -16,9 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static com.google.gerrit.server.project.SubmitRuleEvaluator.createRuleError;
-import static com.google.gerrit.server.project.SubmitRuleEvaluator.defaultRuleError;
-import static com.google.gerrit.server.project.SubmitRuleEvaluator.defaultTypeError;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
@@ -61,6 +58,9 @@
  */
 public class PrologRuleEvaluator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String DEFAULT_MSG = "Error evaluating project rules, check server log";
+
   /**
    * List of characters to allow in the label name, when an invalid name is used. Dash is allowed as
    * it can't be the first character: we use a prefix.
@@ -289,6 +289,13 @@
     return VALID_LABEL_MATCHER.retainFrom(name);
   }
 
+  private static SubmitRecord createRuleError(String err) {
+    SubmitRecord rec = new SubmitRecord();
+    rec.status = SubmitRecord.Status.RULE_ERROR;
+    rec.errorMessage = err;
+    return rec;
+  }
+
   private SubmitRecord invalidResult(Term rule, Term record, String reason) {
     return ruleError(
         String.format(
@@ -311,7 +318,7 @@
   private SubmitRecord ruleError(String err, Exception e) {
     if (opts.logErrors()) {
       logger.atSevere().withCause(e).log(err);
-      return defaultRuleError();
+      return createRuleError(DEFAULT_MSG);
     }
     return createRuleError(err);
   }
@@ -389,7 +396,6 @@
   private SubmitTypeRecord typeError(String err, Exception e) {
     if (opts.logErrors()) {
       logger.atSevere().withCause(e).log(err);
-      return defaultTypeError();
     }
     return SubmitTypeRecord.error(err);
   }
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 6faaec5..9907b1c 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -176,14 +176,16 @@
           grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
         });
 
+    config.upsertAccessSection(
+        "refs/meta/version",
+        version -> {
+          grant(config, version, Permission.READ, anonymous);
+        });
+
     grant(config, heads, codeReviewLabel, -1, 1, registered);
     grant(config, heads, Permission.FORGE_AUTHOR, registered);
-
-    config.upsertAccessSection(
-        "refs/*",
-        all -> {
-          grant(config, all, Permission.REVERT, registered);
-        });
+    grant(config, heads, Permission.READ, anonymous);
+    grant(config, heads, Permission.REVERT, registered);
 
     config.upsertAccessSection(
         "refs/for/" + AccessSection.ALL,
@@ -213,7 +215,7 @@
     config.upsertAccessSection(
         AccessSection.ALL,
         all -> {
-          grant(config, all, Permission.READ, adminsGroup, anonymous);
+          grant(config, all, Permission.READ, adminsGroup);
         });
 
     config.upsertAccessSection(
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 90973fb..3588860 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
@@ -83,7 +84,8 @@
   @UsedAt(UsedAt.Project.GOOGLE)
   public AllUsersCreator setCodeReviewLabel(LabelType labelType) {
     checkArgument(
-        labelType.getName().equals("Code-Review"), "label should have 'Code-Review' as its name");
+        labelType.getName().equals(LabelId.CODE_REVIEW),
+        "label should have 'Code-Review' as its name");
     this.codeReviewLabel = labelType;
     return this;
   }
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index afa9d1a..bda3dc4 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -18,17 +18,18 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.GroupUuid;
+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.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.AuditLogFormatter;
 import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.GroupNameNotes;
@@ -91,7 +92,7 @@
   @Override
   public void create() throws IOException, ConfigInvalidException {
     GroupReference admins = createGroupReference("Administrators");
-    GroupReference serviceUsers = createGroupReference("Service Users");
+    GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
 
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
diff --git a/java/com/google/gerrit/server/schema/Schema_184.java b/java/com/google/gerrit/server/schema/Schema_184.java
index d0ca3d0..85d4740 100644
--- a/java/com/google/gerrit/server/schema/Schema_184.java
+++ b/java/com/google/gerrit/server/schema/Schema_184.java
@@ -17,11 +17,12 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.AuditLogFormatter;
 import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.GroupNameNotes;
@@ -44,7 +45,7 @@
   @Override
   public void upgrade(Arguments args, UpdateUI ui) throws Exception {
     try (Repository allUsersRepo = args.repoManager.openRepository(args.allUsers)) {
-      AccountGroup.NameKey newName = AccountGroup.nameKey("Service Users");
+      AccountGroup.NameKey newName = AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS);
       Optional<GroupReference> nonInteractiveUsers =
           GroupNameNotes.loadAllGroups(allUsersRepo).stream()
               .filter(g -> g.getName().equals("Non-Interactive Users"))
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 5485192..39e3a59 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -54,14 +54,14 @@
       ImmutableList.of(
           "[access \"refs/*\"]",
           "  read = group Administrators",
-          "  read = group Anonymous Users",
-          "  revert = group Registered Users",
           "[access \"refs/for/*\"]",
           "  addPatchSet = group Registered Users",
           "[access \"refs/for/refs/*\"]",
           "  push = group Registered Users",
           "  pushMerge = group Registered Users",
           "[access \"refs/heads/*\"]",
+          "  read = group Anonymous Users",
+          "  revert = group Registered Users",
           "  create = group Administrators",
           "  create = group Project Owners",
           "  editTopicName = +force group Administrators",
@@ -88,6 +88,8 @@
           "  read = group Project Owners",
           "  submit = group Administrators",
           "  submit = group Project Owners",
+          "[access \"refs/meta/version\"]",
+          "  read = group Anonymous Users",
           "[access \"refs/tags/*\"]",
           "  create = group Administrators",
           "  create = group Project Owners",
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/server/ssh/HostKey.java
similarity index 60%
copy from java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
copy to java/com/google/gerrit/server/ssh/HostKey.java
index 08d6ce7..9397612 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
+++ b/java/com/google/gerrit/server/ssh/HostKey.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2019 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,12 +12,22 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.change;
+package com.google.gerrit.server.ssh;
 
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.List;
+public class HostKey {
+  private final String host;
+  private final byte[] key;
 
-public interface PluginDefinedAttributesFactory {
-  List<PluginDefinedInfo> create(ChangeData cd);
+  public HostKey(String host, byte[] key) {
+    this.host = host;
+    this.key = key;
+  }
+
+  public String getHost() {
+    return host;
+  }
+
+  public byte[] getKey() {
+    return key;
+  }
 }
diff --git a/java/com/google/gerrit/server/ssh/NoSshInfo.java b/java/com/google/gerrit/server/ssh/NoSshInfo.java
index 91a949b..a716398 100644
--- a/java/com/google/gerrit/server/ssh/NoSshInfo.java
+++ b/java/com/google/gerrit/server/ssh/NoSshInfo.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.ssh;
 
-import com.jcraft.jsch.HostKey;
 import java.util.Collections;
 import java.util.List;
 
diff --git a/java/com/google/gerrit/server/ssh/SshInfo.java b/java/com/google/gerrit/server/ssh/SshInfo.java
index 430846d..ec5a579 100644
--- a/java/com/google/gerrit/server/ssh/SshInfo.java
+++ b/java/com/google/gerrit/server/ssh/SshInfo.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.ssh;
 
-import com.jcraft.jsch.HostKey;
 import java.util.List;
 
 public interface SshInfo {
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 4efa4c8..109c9c3 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -31,6 +31,7 @@
 import com.google.inject.Inject;
 import com.google.inject.OutOfScopeException;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 
@@ -43,7 +44,8 @@
         Change change,
         Account.Id submitter,
         NotifyResolver.Result notify,
-        RepoView repoView);
+        RepoView repoView,
+        String stickyApprovalDiff);
   }
 
   private final ExecutorService sendEmailsExecutor;
@@ -57,6 +59,7 @@
   private final Account.Id submitter;
   private final NotifyResolver.Result notify;
   private final RepoView repoView;
+  private final String stickyApprovalDiff;
 
   @Inject
   EmailMerge(
@@ -69,7 +72,8 @@
       @Assisted Change change,
       @Assisted @Nullable Account.Id submitter,
       @Assisted NotifyResolver.Result notify,
-      @Assisted RepoView repoView) {
+      @Assisted RepoView repoView,
+      @Assisted String stickyApprovalDiff) {
     this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
     this.requestContext = requestContext;
@@ -80,6 +84,7 @@
     this.submitter = submitter;
     this.notify = notify;
     this.repoView = repoView;
+    this.stickyApprovalDiff = stickyApprovalDiff;
   }
 
   void sendAsync() {
@@ -91,7 +96,8 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      MergedSender emailSender = mergedSenderFactory.create(project, change.getId());
+      MergedSender emailSender =
+          mergedSenderFactory.create(project, change.getId(), Optional.of(stickyApprovalDiff));
       if (submitter != null) {
         emailSender.setFrom(submitter);
       }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index fdf3664..a8a8675 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -35,13 +35,15 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Change.Status;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitTypeRecord;
+import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -118,7 +120,7 @@
 
   private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS = SubmitRuleOptions.builder().build();
   private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_ALLOW_CLOSED =
-      SUBMIT_RULE_OPTIONS.toBuilder().allowClosed(true).build();
+      SUBMIT_RULE_OPTIONS.toBuilder().recomputeOnClosedChanges(true).build();
 
   public static class CommitStatus {
     private final ImmutableMap<Change.Id, ChangeData> changes;
@@ -187,7 +189,7 @@
       // date by this point.
       ChangeData cd = requireNonNull(changes.get(id), () -> String.format("ChangeData for %s", id));
       return requireNonNull(
-          cd.getSubmitRecords(submitRuleOptions(allowClosed)),
+          cd.submitRecords(submitRuleOptions(allowClosed)),
           "getSubmitRecord only valid after submit rules are evalutated");
     }
 
@@ -232,7 +234,7 @@
   private final SubmitStrategyFactory submitStrategyFactory;
   private final SubscriptionGraph.Factory subscriptionGraphFactory;
   private final SubmoduleCommits.Factory submoduleCommitsFactory;
-  private final SubmissionListener superprojectUpdateSubmissionListener;
+  private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
   private final Provider<MergeOpRepoManager> ormProvider;
   private final NotifyResolver notifyResolver;
   private final RetryHelper retryHelper;
@@ -264,7 +266,8 @@
       SubmitStrategyFactory submitStrategyFactory,
       SubmoduleCommits.Factory submoduleCommitsFactory,
       SubscriptionGraph.Factory subscriptionGraphFactory,
-      @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
+      @SuperprojectUpdateOnSubmission
+          ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
       Provider<MergeOpRepoManager> ormProvider,
       NotifyResolver notifyResolver,
       TopicMetrics topicMetrics,
@@ -279,7 +282,7 @@
     this.submitStrategyFactory = submitStrategyFactory;
     this.submoduleCommitsFactory = submoduleCommitsFactory;
     this.subscriptionGraphFactory = subscriptionGraphFactory;
-    this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
+    this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
     this.ormProvider = ormProvider;
     this.notifyResolver = notifyResolver;
     this.retryHelper = retryHelper;
@@ -388,8 +391,9 @@
     return Joiner.on("; ").join(labelResults);
   }
 
-  private static String describeSubmitRequirement(SubmitRequirement submitRequirement) {
-    return String.format("Submit requirement not fulfilled: %s", submitRequirement.fallbackText());
+  private static String describeSubmitRequirement(LegacySubmitRequirement legacySubmitRequirement) {
+    return String.format(
+        "Submit requirement not fulfilled: %s", legacySubmitRequirement.fallbackText());
   }
 
   private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
@@ -491,18 +495,35 @@
         logger.atFine().log("Calculated to merge %s", indexBackedChangeSet);
 
         // Reload ChangeSet so that we don't rely on (potentially) stale index data for merging
-        ChangeSet cs = reloadChanges(indexBackedChangeSet);
+        ChangeSet noteDbChangeSet = reloadChanges(indexBackedChangeSet);
+
+        // At this point, any change that isn't new can be filtered out since they were only here
+        // in the first place due to stale index.
+        List<ChangeData> filteredChanges = new ArrayList<>();
+        for (ChangeData changeData : noteDbChangeSet.changes()) {
+          if (!changeData.change().getStatus().equals(Status.NEW)) {
+            logger.atFine().log(
+                "Change %s has status %s due to stale index, so it is skipped during submit",
+                changeData.getId().toString(), changeData.change().getStatus().name());
+            continue;
+          }
+          filteredChanges.add(changeData);
+        }
+
+        // There are no hidden changes (or else we would have thrown AuthException above).
+        ChangeSet filteredNoteDbChangeSet =
+            new ChangeSet(filteredChanges, /* hiddenChanges= */ ImmutableList.of());
 
         // Count cross-project submissions outside of the retry loop. The chance of a single project
         // failing increases with the number of projects, so the failure count would be inflated if
         // this metric were incremented inside of integrateIntoHistory.
-        int projects = cs.projects().size();
+        int projects = filteredNoteDbChangeSet.projects().size();
         if (projects > 1) {
           topicMetrics.topicSubmissions.increment();
         }
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListener);
+            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListeners);
         RetryTracker retryTracker = new RetryTracker();
         retryHelper
             .changeUpdate(
@@ -515,22 +536,25 @@
                     this.ts = TimeUtil.nowTs();
                     openRepoManager();
                   }
-                  this.commitStatus = new CommitStatus(cs, isRetry);
+                  this.commitStatus = new CommitStatus(filteredNoteDbChangeSet, isRetry);
                   if (checkSubmitRules) {
                     logger.atFine().log("Checking submit rules and state");
-                    checkSubmitRulesAndState(cs, isRetry);
+                    checkSubmitRulesAndState(filteredNoteDbChangeSet, isRetry);
                   } else {
                     logger.atFine().log("Bypassing submit rules");
-                    bypassSubmitRules(cs, isRetry);
+                    bypassSubmitRules(filteredNoteDbChangeSet, isRetry);
                   }
-                  integrateIntoHistory(cs, submissionExecutor);
+                  integrateIntoHistory(filteredNoteDbChangeSet, submissionExecutor);
                   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.
-            .defaultTimeoutMultiplier(cs.projects().size())
+            // submit. Times 2 to retry more persistently, to increase success rate.
+            .defaultTimeoutMultiplier(filteredNoteDbChangeSet.projects().size() * 2)
+            // By default, we only retry lock failures. Here it's better to also retry unexpected
+            // runtime exceptions.
+            .retryOn(t -> t instanceof RuntimeException)
             .call();
         submissionExecutor.afterExecutions(orm);
 
@@ -672,7 +696,7 @@
       if (e.getCause() instanceof IntegrationConflictException) {
         throw (IntegrationConflictException) e.getCause();
       }
-      throw new StorageException(genericMergeError(cs), e);
+      throw new InternalServerWithUserMessageException(genericMergeError(cs), e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 93c78a8..67f2907 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -18,19 +18,16 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginContext;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -55,8 +52,6 @@
  * included.
  */
 public class MergeSuperSet {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final ChangeData.Factory changeDataFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Provider<MergeOpRepoManager> repoManagerProvider;
@@ -64,7 +59,6 @@
   private final PermissionBackend permissionBackend;
   private final Config cfg;
   private final ProjectCache projectCache;
-  private final ChangeNotes.Factory notesFactory;
 
   private MergeOpRepoManager orm;
   private boolean closeOrm;
@@ -77,8 +71,7 @@
       Provider<MergeOpRepoManager> repoManagerProvider,
       DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation,
       PermissionBackend permissionBackend,
-      ProjectCache projectCache,
-      ChangeNotes.Factory notesFactory) {
+      ProjectCache projectCache) {
     this.cfg = cfg;
     this.changeDataFactory = changeDataFactory;
     this.queryProvider = queryProvider;
@@ -86,7 +79,6 @@
     this.mergeSuperSetComputation = mergeSuperSetComputation;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
-    this.notesFactory = notesFactory;
   }
 
   public static boolean wholeTopicEnabled(Config config) {
@@ -212,24 +204,8 @@
     if (!projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false)) {
       return false;
     }
-
-    ChangeNotes notes;
     try {
-      notes = cd.notes();
-    } catch (NoSuchChangeException e) {
-      // The change was found in the index but is missing in NoteDb.
-      // This can happen in systems with multiple primary nodes when the replication of the index
-      // documents is faster than the replication of the Git data.
-      // Instead of failing, create the change notes from the index data so that the read permission
-      // check can be performed successfully.
-      logger.atWarning().log(
-          "Got change %d of project %s from index, but couldn't find it in NoteDb",
-          cd.getId().get(), cd.project().get());
-      notes = notesFactory.createFromIndexedChange(cd.change());
-    }
-
-    try {
-      permissionBackend.user(user).change(notes).check(ChangePermission.READ);
+      permissionBackend.user(user).change(cd).check(ChangePermission.READ);
       return true;
     } catch (AuthException e) {
       return false;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 21ff2fc..530c53f 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.patch.SubmitWithStickyApprovalDiff;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -119,6 +120,7 @@
     final Provider<InternalChangeQuery> queryProvider;
     final ProjectConfig.Factory projectConfigFactory;
     final SetPrivateOp.Factory setPrivateOpFactory;
+    final SubmitWithStickyApprovalDiff submitWithStickyApprovalDiff;
 
     final BranchNameKey destBranch;
     final CodeReviewRevWalk rw;
@@ -159,6 +161,7 @@
         Provider<InternalChangeQuery> queryProvider,
         ProjectConfig.Factory projectConfigFactory,
         SetPrivateOp.Factory setPrivateOpFactory,
+        SubmitWithStickyApprovalDiff submitWithStickyApprovalDiff,
         @Assisted BranchNameKey destBranch,
         @Assisted CommitStatus commitStatus,
         @Assisted CodeReviewRevWalk rw,
@@ -188,6 +191,7 @@
       this.tagCache = tagCache;
       this.queryProvider = queryProvider;
       this.setPrivateOpFactory = setPrivateOpFactory;
+      this.submitWithStickyApprovalDiff = submitWithStickyApprovalDiff;
 
       this.serverIdent = serverIdent;
       this.destBranch = destBranch;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index dfee2f8..3a04b82 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
@@ -41,6 +42,8 @@
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -73,6 +76,7 @@
   private Change updatedChange;
   private CodeReviewCommit alreadyMergedCommit;
   private boolean changeAlreadyMerged;
+  private String stickyApprovalDiff;
 
   protected SubmitStrategyOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
     this.args = args;
@@ -391,7 +395,9 @@
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s) {
+  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s)
+      throws AuthException, IOException, PermissionBackendException,
+          InvalidChangeOperationException {
     requireNonNull(s, "CommitMergeStatus may not be null");
     String txt = s.getDescription();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
@@ -431,9 +437,16 @@
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId, String body) {
+  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId, String body)
+      throws AuthException, IOException, PermissionBackendException,
+          InvalidChangeOperationException {
+    stickyApprovalDiff = args.submitWithStickyApprovalDiff.apply(ctx.getNotes(), ctx.getUser());
     return ChangeMessagesUtil.newMessage(
-        psId, ctx.getUser(), ctx.getWhen(), body, ChangeMessagesUtil.TAG_MERGED);
+        psId,
+        ctx.getUser(),
+        ctx.getWhen(),
+        body + stickyApprovalDiff,
+        ChangeMessagesUtil.TAG_MERGED);
   }
 
   private void setMerged(ChangeContext ctx, ChangeMessage msg) {
@@ -441,7 +454,6 @@
     logger.atFine().log("Setting change %s merged", c.getId());
     c.setStatus(Change.Status.MERGED);
     c.setSubmissionId(args.submissionId.toString());
-
     // TODO(dborowitz): We need to be able to change the author of the message,
     // which is not the user from the update context. addMergedMessage was able
     // to do this in the past.
@@ -461,9 +473,12 @@
       // If we naively execute postUpdate even if the change is already merged when updateChange
       // being, then we are subject to a race where postUpdate steps are run twice if two submit
       // processes run at the same time.
-      logger.atFine().log("Skipping post-update steps for change %s", getId());
+      logger.atFine().log(
+          "Skipping post-update steps for change %s; submitter is %s", getId(), submitter);
       return;
     }
+    logger.atFine().log(
+        "Begin post-update steps for change %s; submitter is %s", getId(), submitter);
     postUpdateImpl(ctx);
 
     if (command != null) {
@@ -483,6 +498,9 @@
       }
     }
 
+    logger.atFine().log(
+        "Begin sending emails for submitting change %s; submitter is %s", getId(), submitter);
+
     // Assume the change must have been merged at this point, otherwise we would
     // have failed fast in one of the other steps.
     try {
@@ -492,7 +510,8 @@
               toMerge.change(),
               submitter.accountId(),
               ctx.getNotify(getId()),
-              ctx.getRepoView())
+              ctx.getRepoView(),
+              stickyApprovalDiff)
           .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index 2db625b..409c808 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -481,7 +481,7 @@
                   if (!traceContext.isTracing()) {
                     String traceId = "retry-on-failure-" + new RequestId();
                     traceContext.addTag(RequestId.Type.TRACE_ID, traceId).forceLogging();
-                    logger.atFine().withCause(t).log(
+                    logger.atWarning().withCause(t).log(
                         "AutoRetry: %s failed, retry with tracing enabled (cause = %s)",
                         actionName, cause);
                     opts.onAutoTrace().ifPresent(c -> c.accept(traceId));
@@ -492,7 +492,7 @@
                   // A non-recoverable failure occurred. We retried the operation with tracing
                   // enabled and it failed again. Log the failure so that admin can see if it
                   // differs from the failure that triggered the retry.
-                  logger.atFine().withCause(t).log(
+                  logger.atWarning().withCause(t).log(
                       "AutoRetry: auto-retry of %s has failed (cause = %s)", actionName, cause);
                   metrics.failuresOnAutoRetryCount.increment(actionType, actionName, cause);
                   return false;
@@ -504,7 +504,7 @@
       return executeWithTimeoutCount(actionType, action, opts, retryerBuilder.build(), listener);
     } finally {
       if (listener.getAttemptCount() > 1) {
-        logger.atFine().log("%s was attempted %d times", actionType, listener.getAttemptCount());
+        logger.atWarning().log("%s was attempted %d times", actionType, listener.getAttemptCount());
         metrics.attemptCounts.incrementBy(
             actionType,
             opts.actionName().orElse("N/A"),
diff --git a/java/com/google/gerrit/server/update/SubmissionExecutor.java b/java/com/google/gerrit/server/update/SubmissionExecutor.java
index 5a3a789..39eda58 100644
--- a/java/com/google/gerrit/server/update/SubmissionExecutor.java
+++ b/java/com/google/gerrit/server/update/SubmissionExecutor.java
@@ -17,7 +17,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.submit.MergeOpRepoManager;
-import java.util.Arrays;
 import java.util.Collection;
 import java.util.Optional;
 import java.util.stream.Collectors;
@@ -28,14 +27,9 @@
   private final boolean dryrun;
   private ImmutableList<BatchUpdateListener> additionalListeners = ImmutableList.of();
 
-  public SubmissionExecutor(
-      boolean dryrun, SubmissionListener listener, SubmissionListener... otherListeners) {
+  public SubmissionExecutor(boolean dryrun, ImmutableList<SubmissionListener> submissionListeners) {
     this.dryrun = dryrun;
-    this.submissionListeners =
-        ImmutableList.<SubmissionListener>builder()
-            .add(listener)
-            .addAll(Arrays.asList(otherListeners))
-            .build();
+    this.submissionListeners = submissionListeners;
     if (dryrun) {
       submissionListeners.forEach(SubmissionListener::setDryrun);
     }
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
index dffdff0..4c65c80 100644
--- a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.submit.SubmoduleOp;
 import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
@@ -39,11 +40,11 @@
   private boolean dryrun;
 
   public static class Module extends AbstractModule {
-    @Override
-    protected void configure() {
-      bind(SubmissionListener.class)
-          .annotatedWith(SuperprojectUpdateOnSubmission.class)
-          .to(SuperprojectUpdateSubmissionListener.class);
+    @Provides
+    @SuperprojectUpdateOnSubmission
+    ImmutableList<SubmissionListener> provideSubmissionListeners(
+        SuperprojectUpdateSubmissionListener listener) {
+      return ImmutableList.of(listener);
     }
   }
 
diff --git a/java/com/google/gerrit/server/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
index 62cad3f..26c862d 100644
--- a/java/com/google/gerrit/server/util/AttentionSetUtil.java
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -16,11 +16,16 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.io.IOException;
 import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Common helpers for dealing with attention set data structures. */
 public class AttentionSetUtil {
@@ -49,5 +54,45 @@
     }
   }
 
+  /**
+   * Returns the {@code Account.Id} of {@code user} if the user is active on the change, and exists.
+   * If the user doesn't exist or is not active on the change, the same exception is thrown to
+   * disallow probing for account existence based on exception type.
+   */
+  public static Account.Id resolveAccount(
+      AccountResolver accountResolver, ChangeNotes changeNotes, String user)
+      throws ConfigInvalidException, IOException, BadRequestException {
+    // We will throw this exception if the account doesn't exist, or if the account is not active.
+    // This is purposely the same exception so that users can't probe for account existence based on
+    // the thrown exception.
+    BadRequestException possibleExceptionForNotFoundOrInactiveAccount =
+        new BadRequestException(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, "
+                    + "reviewer, or cc so they can't be added to the attention set",
+                user));
+    Account.Id attentionUserId;
+    try {
+      attentionUserId = accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      possibleExceptionForNotFoundOrInactiveAccount.initCause(ex);
+      throw possibleExceptionForNotFoundOrInactiveAccount;
+    }
+    if (!isActiveOnTheChange(changeNotes, attentionUserId)) {
+      throw possibleExceptionForNotFoundOrInactiveAccount;
+    }
+    return attentionUserId;
+  }
+
+  /**
+   * Returns whether {@code attentionUserId} is active on a change. Activity is defined as being a
+   * part of the reviewers, an uploader, or an owner of a change.
+   */
+  private static boolean isActiveOnTheChange(ChangeNotes changeNotes, Account.Id attentionUserId) {
+    return changeNotes.getChange().getOwner().equals(attentionUserId)
+        || changeNotes.getCurrentPatchSet().uploader().equals(attentionUserId)
+        || changeNotes.getReviewers().all().stream().anyMatch(id -> id.equals(attentionUserId));
+  }
+
   private AttentionSetUtil() {}
 }
diff --git a/java/com/google/gerrit/server/util/CommitMessageUtil.java b/java/com/google/gerrit/server/util/CommitMessageUtil.java
index 1c8ce0c..55e3951 100644
--- a/java/com/google/gerrit/server/util/CommitMessageUtil.java
+++ b/java/com/google/gerrit/server/util/CommitMessageUtil.java
@@ -22,13 +22,19 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.util.ChangeIdUtil;
 
 /** Utility functions to manipulate commit messages. */
 public class CommitMessageUtil {
   private static final SecureRandom rng;
+  private static final Pattern changeIdFooterPattern =
+      Pattern.compile("Change-Id: *(I[a-f0-9]{40})");
 
   static {
     try {
@@ -71,6 +77,31 @@
   }
 
   public static Change.Key generateKey() {
-    return Change.key("I" + generateChangeId().name());
+    return Change.key(getChangeIdFromObjectId(generateChangeId()));
+  }
+
+  public static String getChangeIdFromObjectId(ObjectId objectId) {
+    return "I" + objectId.name();
+  }
+
+  /**
+   * Return the value of Change-Id from the commit message footer.
+   *
+   * <p>The behaviour matches {@link org.eclipse.jgit.util.ChangeIdUtil}. If more than one matching
+   * Change-Id footer is found, return the value of the last one.
+   *
+   * @param commitMessage commit message to get Change-Id from.
+   * @return {@link Optional} value of Change-Id footer in the commit message.
+   */
+  public static Optional<String> getChangeIdFromCommitMessageFooter(String commitMessage) {
+    int indexOfChangeId = ChangeIdUtil.indexOfChangeId(commitMessage, "\n");
+    if (indexOfChangeId == -1) {
+      return Optional.empty();
+    }
+    Matcher matcher = changeIdFooterPattern.matcher(commitMessage);
+    if (matcher.find(indexOfChangeId)) {
+      return Optional.of(matcher.group(1));
+    }
+    return Optional.empty();
   }
 }
diff --git a/java/com/google/gerrit/server/util/PluginLogFile.java b/java/com/google/gerrit/server/util/PluginLogFile.java
index de8b3aa..345e1b3 100644
--- a/java/com/google/gerrit/server/util/PluginLogFile.java
+++ b/java/com/google/gerrit/server/util/PluginLogFile.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
-import org.apache.log4j.AsyncAppender;
 import org.apache.log4j.Layout;
 import org.apache.log4j.LogManager;
 import org.apache.log4j.Logger;
@@ -38,10 +37,14 @@
 
   @Override
   public void start() {
-    AsyncAppender asyncAppender = systemLog.createAsyncAppender(logName, layout, true, true);
     Logger logger = LogManager.getLogger(logName);
-    logger.removeAppender(logName);
-    logger.addAppender(asyncAppender);
+    if (logger.getAppender(logName) == null) {
+      synchronized (systemLog) {
+        if (logger.getAppender(logName) == null) {
+          logger.addAppender(systemLog.createAsyncAppender(logName, layout, true, true));
+        }
+      }
+    }
     logger.setAdditivity(false);
   }
 
diff --git a/java/com/google/gerrit/sshd/AbstractGitCommand.java b/java/com/google/gerrit/sshd/AbstractGitCommand.java
index 9efcff2..b3753fd 100644
--- a/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -64,8 +65,8 @@
       startThread(
           new ProjectCommandRunnable() {
             @Override
-            public void executeParseCommand() throws Exception {
-              parseCommandLine();
+            public void executeParseCommand(DynamicOptions pluginOptions) throws Exception {
+              parseCommandLine(pluginOptions);
             }
 
             @Override
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index a5b88b4..0668c1e 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -4,6 +4,7 @@
     name = "sshd",
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
+    runtime_deps = ["//lib:jsch"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
@@ -28,7 +29,7 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:jgit-archive",
-        "//lib:jsch",
+        "//lib:jgit-ssh-apache",
         "//lib:servlet-api",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index 85d9eb2..48a5512 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.EndOfOptionsHandler;
@@ -102,9 +101,9 @@
   @PluginName
   private String pluginName;
 
-  @Inject private Injector injector;
+  @Inject protected Injector injector;
 
-  @Inject private DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
+  @Inject protected DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
 
   /** The task, as scheduled on a worker thread. */
   private final AtomicReference<Future<?>> task;
@@ -212,12 +211,13 @@
    *
    * <p>This method must be explicitly invoked to cause a parse.
    *
+   * @param pluginOptions which helps to define and parse options provided from plugins
    * @throws UnloggedFailure if the command line arguments were invalid.
    * @see Option
    * @see Argument
    */
-  protected void parseCommandLine() throws UnloggedFailure {
-    parseCommandLine(this);
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
+    parseCommandLine(this, pluginOptions);
   }
 
   /**
@@ -227,13 +227,16 @@
    *
    * @param options object whose fields declare Option and Argument annotations to describe the
    *     parameters of the command. Usually {@code this}.
+   * @param pluginOptions which helps to define and parse options provided from plugins
    * @throws UnloggedFailure if the command line arguments were invalid.
    * @see Option
    * @see Argument
    */
-  protected void parseCommandLine(Object options) throws UnloggedFailure {
+  protected void parseCommandLine(Object options, DynamicOptions pluginOptions)
+      throws UnloggedFailure {
     final CmdLineParser clp = newCmdLineParser(options);
-    DynamicOptions pluginOptions = new DynamicOptions(options, injector, dynamicBeans);
+    pluginOptions.setBean(options);
+    pluginOptions.startLifecycleListeners();
     pluginOptions.parseDynamicBeans(clp);
     pluginOptions.setDynamicBeans();
     pluginOptions.onBeanParseStart();
@@ -476,16 +479,19 @@
         context.getSession().setAccessPath(accessPath);
         final Context old = sshScope.set(context);
         try {
-          context.started = TimeUtil.nowMs();
+          context.start();
           thisThread.setName("SSH " + taskName);
 
-          if (thunk instanceof ProjectCommandRunnable) {
-            ((ProjectCommandRunnable) thunk).executeParseCommand();
-            projectName = ((ProjectCommandRunnable) thunk).getProjectName();
-          }
-
           try {
-            thunk.run();
+            if (thunk instanceof ProjectCommandRunnable) {
+              try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
+                ((ProjectCommandRunnable) thunk).executeParseCommand(pluginOptions);
+                projectName = ((ProjectCommandRunnable) thunk).getProjectName();
+                thunk.run();
+              }
+            } else {
+              thunk.run();
+            }
           } catch (NoSuchProjectException e) {
             throw new UnloggedFailure(1, e.getMessage());
           } catch (NoSuchChangeException e) {
@@ -548,7 +554,7 @@
   public interface ProjectCommandRunnable extends CommandRunnable {
     // execute parser command before running, in order to be able to retrieve
     // project name
-    void executeParseCommand() throws Exception;
+    void executeParseCommand(DynamicOptions pluginOptions) throws Exception;
 
     Project.NameKey getProjectName();
   }
diff --git a/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index 38ac26d..bb5494b 100644
--- a/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -39,11 +39,11 @@
 import java.util.concurrent.atomic.AtomicReference;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.ExitCallback;
-import org.apache.sshd.server.SessionAware;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.Command;
 import org.apache.sshd.server.command.CommandFactory;
 import org.apache.sshd.server.session.ServerSession;
+import org.apache.sshd.server.session.ServerSessionAware;
 import org.eclipse.jgit.lib.Config;
 
 /** Creates a CommandFactory using commands registered by {@link CommandModule}. */
@@ -102,7 +102,7 @@
     };
   }
 
-  private class Trampoline implements Command, SessionAware {
+  private class Trampoline implements Command, ServerSessionAware {
     private final String commandLine;
     private final String[] argv;
     private InputStream in;
@@ -185,8 +185,8 @@
           cmd.setExitCallback(
               new ExitCallback() {
                 @Override
-                public void onExit(int rc, String exitMessage) {
-                  exit.onExit(translateExit(rc), exitMessage);
+                public void onExit(int rc, String exitMessage, boolean closeImmediately) {
+                  exit.onExit(translateExit(rc), exitMessage, closeImmediately);
                   log(rc, exitMessage);
                 }
 
diff --git a/java/com/google/gerrit/sshd/DispatchCommand.java b/java/com/google/gerrit/sshd/DispatchCommand.java
index 7db65bd..54171a3 100644
--- a/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -20,6 +20,7 @@
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.args4j.SubcommandHandler;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -71,8 +72,8 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try {
-      parseCommandLine();
+    try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
+      parseCommandLine(pluginOptions);
       if (Strings.isNullOrEmpty(commandName)) {
         StringWriter msg = new StringWriter();
         msg.write(usage());
diff --git a/java/com/google/gerrit/sshd/HostKeyProvider.java b/java/com/google/gerrit/sshd/HostKeyProvider.java
index 3578fb9..0e9b46b 100644
--- a/java/com/google/gerrit/sshd/HostKeyProvider.java
+++ b/java/com/google/gerrit/sshd/HostKeyProvider.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -22,11 +23,13 @@
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
+import org.apache.sshd.common.config.keys.KeyUtils;
 import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
 
 class HostKeyProvider implements Provider<KeyPairProvider> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private final SitePaths site;
 
   @Inject
@@ -63,7 +66,11 @@
     if (Files.exists(objKey)) {
       if (stdKeys.isEmpty()) {
         SimpleGeneratorHostKeyProvider p = new SimpleGeneratorHostKeyProvider();
+        p.setAlgorithm(KeyUtils.RSA_ALGORITHM);
         p.setPath(objKey.toAbsolutePath());
+        logger.atWarning().log(
+            "Defaulting to RSA algorithm for SSH key exchange."
+                + "This is a weak security setting, consider changing it (see 'sshd.kex' documentation section).");
         return p;
       }
       // Both formats of host key exist, we don't know which format
diff --git a/java/com/google/gerrit/sshd/NoShell.java b/java/com/google/gerrit/sshd/NoShell.java
index 2a29a62..ffac946 100644
--- a/java/com/google/gerrit/sshd/NoShell.java
+++ b/java/com/google/gerrit/sshd/NoShell.java
@@ -32,11 +32,11 @@
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.ExitCallback;
-import org.apache.sshd.server.SessionAware;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.AsyncCommand;
 import org.apache.sshd.server.command.Command;
 import org.apache.sshd.server.session.ServerSession;
+import org.apache.sshd.server.session.ServerSessionAware;
 import org.apache.sshd.server.shell.ShellFactory;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.util.SystemReader;
@@ -65,7 +65,7 @@
    *
    * @see org.apache.sshd.server.command.AsyncCommand
    */
-  static class SendMessage implements AsyncCommand, SessionAware {
+  static class SendMessage implements AsyncCommand, ServerSessionAware {
     private final Provider<MessageFactory> messageFactory;
     private final SshScope sshScope;
 
@@ -132,7 +132,7 @@
       } finally {
         sshScope.set(old);
       }
-      err.writePacket(new ByteArrayBuffer(Constants.encode(message)));
+      err.writeBuffer(new ByteArrayBuffer(Constants.encode(message)));
 
       in.close();
       out.close();
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index e60ba6d..c94b25c 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -49,19 +50,21 @@
   public void start(ChannelSession channel, Environment env) throws IOException {
     startThread(
         () -> {
-          parseCommandLine();
-          stdout = toPrintWriter(out);
-          stderr = toPrintWriter(err);
-          try (TraceContext traceContext = enableTracing();
-              PerformanceLogContext performanceLogContext =
-                  new PerformanceLogContext(config, performanceLoggers)) {
-            RequestInfo requestInfo =
-                RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
-            requestListeners.runEach(l -> l.onRequest(requestInfo));
-            SshCommand.this.run();
-          } finally {
-            stdout.flush();
-            stderr.flush();
+          try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
+            parseCommandLine(pluginOptions);
+            stdout = toPrintWriter(out);
+            stderr = toPrintWriter(err);
+            try (TraceContext traceContext = enableTracing();
+                PerformanceLogContext performanceLogContext =
+                    new PerformanceLogContext(config, performanceLoggers)) {
+              RequestInfo requestInfo =
+                  RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
+              requestListeners.runEach(l -> l.onRequest(requestInfo));
+              SshCommand.this.run();
+            } finally {
+              stdout.flush();
+              stderr.flush();
+            }
           }
         },
         AccessPath.SSH_COMMAND);
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index fa3529c..553287ec 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -17,7 +17,15 @@
 import static com.google.gerrit.server.ssh.SshAddressesModule.IANA_SSH_PORT;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.apache.sshd.common.channel.ChannelOutputStream.WAIT_FOR_SPACE_TIMEOUT;
+import static org.apache.sshd.core.CoreModuleProperties.AUTH_TIMEOUT;
+import static org.apache.sshd.core.CoreModuleProperties.IDLE_TIMEOUT;
+import static org.apache.sshd.core.CoreModuleProperties.MAX_AUTH_REQUESTS;
+import static org.apache.sshd.core.CoreModuleProperties.MAX_CONCURRENT_SESSIONS;
+import static org.apache.sshd.core.CoreModuleProperties.NIO2_READ_TIMEOUT;
+import static org.apache.sshd.core.CoreModuleProperties.REKEY_BYTES_LIMIT;
+import static org.apache.sshd.core.CoreModuleProperties.REKEY_TIME_LIMIT;
+import static org.apache.sshd.core.CoreModuleProperties.SERVER_IDENTIFICATION;
+import static org.apache.sshd.core.CoreModuleProperties.WAIT_FOR_SPACE_TIMEOUT;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
@@ -29,6 +37,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.ssh.HostKey;
 import com.google.gerrit.server.ssh.SshAdvertisedAddresses;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.server.ssh.SshListenAddresses;
@@ -36,32 +45,23 @@
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import com.jcraft.jsch.HostKey;
-import com.jcraft.jsch.JSchException;
 import java.io.File;
 import java.io.IOException;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
 import java.net.UnknownHostException;
-import java.nio.file.FileStore;
-import java.nio.file.FileSystem;
-import java.nio.file.Path;
-import java.nio.file.PathMatcher;
-import java.nio.file.WatchService;
-import java.nio.file.attribute.UserPrincipalLookupService;
-import java.nio.file.spi.FileSystemProvider;
 import java.security.GeneralSecurityException;
 import java.security.InvalidKeyException;
 import java.security.KeyPair;
 import java.security.PublicKey;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
-import java.util.Set;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.TimeUnit;
@@ -73,6 +73,7 @@
 import org.apache.sshd.common.cipher.Cipher;
 import org.apache.sshd.common.compression.BuiltinCompressions;
 import org.apache.sshd.common.compression.Compression;
+import org.apache.sshd.common.file.nonefs.NoneFileSystemFactory;
 import org.apache.sshd.common.forward.DefaultForwarderFactory;
 import org.apache.sshd.common.future.CloseFuture;
 import org.apache.sshd.common.future.SshFutureListener;
@@ -81,9 +82,8 @@
 import org.apache.sshd.common.io.IoServiceFactory;
 import org.apache.sshd.common.io.IoServiceFactoryFactory;
 import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.io.mina.MinaServiceFactoryFactory;
-import org.apache.sshd.common.io.mina.MinaSession;
 import org.apache.sshd.common.io.nio2.Nio2ServiceFactoryFactory;
+import org.apache.sshd.common.kex.BuiltinDHFactories;
 import org.apache.sshd.common.kex.KeyExchangeFactory;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.common.mac.Mac;
@@ -96,6 +96,8 @@
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.common.util.net.SshdSocketAddress;
 import org.apache.sshd.common.util.security.SecurityUtils;
+import org.apache.sshd.mina.MinaServiceFactoryFactory;
+import org.apache.sshd.mina.MinaSession;
 import org.apache.sshd.server.ServerBuilder;
 import org.apache.sshd.server.SshServer;
 import org.apache.sshd.server.auth.UserAuthFactory;
@@ -170,45 +172,38 @@
     this.advertised = advertised;
     keepAlive = cfg.getBoolean("sshd", "tcpkeepalive", true);
 
-    getProperties()
-        .put(
-            SERVER_IDENTIFICATION,
-            "GerritCodeReview_"
-                + Version.getVersion() //
-                + " ("
-                + super.getVersion()
-                + ")");
-
-    getProperties().put(MAX_AUTH_REQUESTS, String.valueOf(cfg.getInt("sshd", "maxAuthTries", 6)));
-
-    getProperties()
-        .put(
-            AUTH_TIMEOUT,
-            String.valueOf(
-                MILLISECONDS.convert(
-                    ConfigUtil.getTimeUnit(cfg, "sshd", null, "loginGraceTime", 120, SECONDS),
-                    SECONDS)));
+    SERVER_IDENTIFICATION.set(
+        this,
+        "GerritCodeReview_"
+            + Version.getVersion() //
+            + " ("
+            + super.getVersion()
+            + ")");
+    MAX_AUTH_REQUESTS.set(this, cfg.getInt("sshd", "maxAuthTries", 6));
+    AUTH_TIMEOUT.set(
+        this,
+        Duration.ofSeconds(
+            MILLISECONDS.convert(
+                ConfigUtil.getTimeUnit(cfg, "sshd", null, "loginGraceTime", 120, SECONDS),
+                SECONDS)));
 
     long idleTimeoutSeconds = ConfigUtil.getTimeUnit(cfg, "sshd", null, "idleTimeout", 0, SECONDS);
-    getProperties().put(IDLE_TIMEOUT, String.valueOf(SECONDS.toMillis(idleTimeoutSeconds)));
-    getProperties().put(NIO2_READ_TIMEOUT, String.valueOf(SECONDS.toMillis(idleTimeoutSeconds)));
+    IDLE_TIMEOUT.set(this, Duration.ofSeconds(SECONDS.toMillis(idleTimeoutSeconds)));
+    NIO2_READ_TIMEOUT.set(this, Duration.ofSeconds(SECONDS.toMillis(idleTimeoutSeconds)));
 
     long rekeyTimeLimit =
         ConfigUtil.getTimeUnit(cfg, "sshd", null, "rekeyTimeLimit", 3600, SECONDS);
-    getProperties().put(REKEY_TIME_LIMIT, String.valueOf(SECONDS.toMillis(rekeyTimeLimit)));
+    REKEY_TIME_LIMIT.set(this, Duration.ofSeconds(SECONDS.toMillis(rekeyTimeLimit)));
 
-    getProperties()
-        .put(
-            REKEY_BYTES_LIMIT,
-            String.valueOf(cfg.getLong("sshd", "rekeyBytesLimit", 1024 * 1024 * 1024 /* 1GB */)));
+    REKEY_BYTES_LIMIT.set(
+        this, cfg.getLong("sshd", "rekeyBytesLimit", 1024 * 1024 * 1024 /* 1GB */));
 
     long waitTimeoutSeconds = ConfigUtil.getTimeUnit(cfg, "sshd", null, "waitTimeout", 30, SECONDS);
-    getProperties()
-        .put(WAIT_FOR_SPACE_TIMEOUT, String.valueOf(SECONDS.toMillis(waitTimeoutSeconds)));
+    WAIT_FOR_SPACE_TIMEOUT.set(this, Duration.ofSeconds(SECONDS.toMillis(waitTimeoutSeconds)));
 
     final int maxConnectionsPerUser = cfg.getInt("sshd", "maxConnectionsPerUser", 64);
     if (0 < maxConnectionsPerUser) {
-      getProperties().put(MAX_CONCURRENT_SESSIONS, String.valueOf(maxConnectionsPerUser));
+      MAX_CONCURRENT_SESSIONS.set(this, maxConnectionsPerUser);
     }
 
     final String kerberosKeytab = cfg.getString("sshd", null, "kerberosKeytab");
@@ -442,12 +437,7 @@
       byte[] keyBin = buf.getCompactData();
 
       for (String addr : advertised) {
-        try {
-          r.add(new HostKey(addr, keyBin));
-        } catch (JSchException e) {
-          logger.atWarning().log(
-              "Cannot format SSHD host key [%s]: %s", pub.getAlgorithm(), e.getMessage());
-        }
+        r.add(new HostKey(addr, keyBin));
       }
     }
 
@@ -490,7 +480,13 @@
   }
 
   private void initKeyExchanges(Config cfg) {
-    List<KeyExchangeFactory> a = ServerBuilder.setUpDefaultKeyExchanges(true);
+    List<KeyExchangeFactory> a =
+        NamedFactory.setUpTransformedFactories(
+            true,
+            cfg.getBoolean("sshd", null, "enableDeprecatedKexAlgorithms", false)
+                ? BuiltinDHFactories.VALUES
+                : BaseBuilder.DEFAULT_KEX_PREFERENCE,
+            ServerBuilder.DH2KEX);
     setKeyExchangeFactories(filter(cfg, "kex", a.toArray(new KeyExchangeFactory[a.size()])));
   }
 
@@ -774,66 +770,6 @@
   }
 
   private void initFileSystemFactory() {
-    setFileSystemFactory(
-        session ->
-            new FileSystem() {
-              @Override
-              public void close() throws IOException {}
-
-              @Override
-              public Iterable<FileStore> getFileStores() {
-                return null;
-              }
-
-              @Override
-              public Path getPath(String arg0, String... arg1) {
-                return null;
-              }
-
-              @Override
-              public PathMatcher getPathMatcher(String arg0) {
-                return null;
-              }
-
-              @Override
-              public Iterable<Path> getRootDirectories() {
-                return null;
-              }
-
-              @Override
-              public String getSeparator() {
-                return null;
-              }
-
-              @Override
-              public UserPrincipalLookupService getUserPrincipalLookupService() {
-                return null;
-              }
-
-              @Override
-              public boolean isOpen() {
-                return false;
-              }
-
-              @Override
-              public boolean isReadOnly() {
-                return false;
-              }
-
-              @Override
-              public WatchService newWatchService() throws IOException {
-                return null;
-              }
-
-              @Override
-              public FileSystemProvider provider() {
-                return null;
-              }
-
-              @Override
-              public Set<String> supportedFileAttributeViews() {
-                return null;
-              }
-            });
+    setFileSystemFactory(NoneFileSystemFactory.INSTANCE);
   }
 }
diff --git a/java/com/google/gerrit/sshd/SshLog.java b/java/com/google/gerrit/sshd/SshLog.java
index 2b3052c..616f7d1 100644
--- a/java/com/google/gerrit/sshd/SshLog.java
+++ b/java/com/google/gerrit/sshd/SshLog.java
@@ -57,6 +57,9 @@
   protected static final String P_STATUS = "status";
   protected static final String P_AGENT = "agent";
   protected static final String P_MESSAGE = "message";
+  protected static final String P_TOTAL_CPU = "totalCpu";
+  protected static final String P_USER_CPU = "userCpu";
+  protected static final String P_MEMORY = "memory";
 
   private final Provider<SshSession> session;
   private final Provider<Context> context;
@@ -171,13 +174,16 @@
 
   void onExecute(DispatchCommand dcmd, int exitValue, SshSession sshSession, String message) {
     final Context ctx = context.get();
-    ctx.finished = TimeUtil.nowMs();
+    ctx.finish();
 
     String cmd = extractWhat(dcmd);
 
     final LoggingEvent event = log(cmd);
-    event.setProperty(P_WAIT, (ctx.started - ctx.created) + "ms");
-    event.setProperty(P_EXEC, (ctx.finished - ctx.started) + "ms");
+    event.setProperty(P_WAIT, ctx.getWait() + "ms");
+    event.setProperty(P_EXEC, ctx.getExec() + "ms");
+    event.setProperty(P_TOTAL_CPU, ctx.getTotalCpu() + "ms");
+    event.setProperty(P_USER_CPU, ctx.getUserCpu() + "ms");
+    event.setProperty(P_MEMORY, String.valueOf(ctx.getAllocatedMemory()));
 
     final String status;
     switch (exitValue) {
@@ -328,7 +334,7 @@
       SshSession session = ctx.getSession();
       sessionId = HexFormat.fromInt(session.getSessionId());
       currentUser = session.getUser();
-      created = ctx.created;
+      created = ctx.getCreated();
     }
     auditService.dispatch(new SshAuditEvent(sessionId, currentUser, cmd, created, params, result));
   }
diff --git a/java/com/google/gerrit/sshd/SshLogJsonLayout.java b/java/com/google/gerrit/sshd/SshLogJsonLayout.java
index 488a4c5..fca0a5a 100644
--- a/java/com/google/gerrit/sshd/SshLogJsonLayout.java
+++ b/java/com/google/gerrit/sshd/SshLogJsonLayout.java
@@ -17,9 +17,12 @@
 import static com.google.gerrit.sshd.SshLog.P_ACCOUNT_ID;
 import static com.google.gerrit.sshd.SshLog.P_AGENT;
 import static com.google.gerrit.sshd.SshLog.P_EXEC;
+import static com.google.gerrit.sshd.SshLog.P_MEMORY;
 import static com.google.gerrit.sshd.SshLog.P_MESSAGE;
 import static com.google.gerrit.sshd.SshLog.P_SESSION;
 import static com.google.gerrit.sshd.SshLog.P_STATUS;
+import static com.google.gerrit.sshd.SshLog.P_TOTAL_CPU;
+import static com.google.gerrit.sshd.SshLog.P_USER_CPU;
 import static com.google.gerrit.sshd.SshLog.P_USER_NAME;
 import static com.google.gerrit.sshd.SshLog.P_WAIT;
 
@@ -44,6 +47,9 @@
     public String message;
     public String waitTime;
     public String execTime;
+    public String totalCpu;
+    public String userCpu;
+    public String memory;
     public String status;
     public String agent;
     public String timeNegotiating;
@@ -67,6 +73,9 @@
       this.message = (String) event.getMessage();
       this.waitTime = getMdcString(event, P_WAIT);
       this.execTime = getMdcString(event, P_EXEC);
+      this.totalCpu = getMdcString(event, P_TOTAL_CPU);
+      this.userCpu = getMdcString(event, P_USER_CPU);
+      this.memory = getMdcString(event, P_MEMORY);
       this.status = getMdcString(event, P_STATUS);
       this.agent = getMdcString(event, P_AGENT);
 
diff --git a/java/com/google/gerrit/sshd/SshLogLayout.java b/java/com/google/gerrit/sshd/SshLogLayout.java
index 1dda068..a1f2c40 100644
--- a/java/com/google/gerrit/sshd/SshLogLayout.java
+++ b/java/com/google/gerrit/sshd/SshLogLayout.java
@@ -17,9 +17,12 @@
 import static com.google.gerrit.sshd.SshLog.P_ACCOUNT_ID;
 import static com.google.gerrit.sshd.SshLog.P_AGENT;
 import static com.google.gerrit.sshd.SshLog.P_EXEC;
+import static com.google.gerrit.sshd.SshLog.P_MEMORY;
 import static com.google.gerrit.sshd.SshLog.P_MESSAGE;
 import static com.google.gerrit.sshd.SshLog.P_SESSION;
 import static com.google.gerrit.sshd.SshLog.P_STATUS;
+import static com.google.gerrit.sshd.SshLog.P_TOTAL_CPU;
+import static com.google.gerrit.sshd.SshLog.P_USER_CPU;
 import static com.google.gerrit.sshd.SshLog.P_USER_NAME;
 import static com.google.gerrit.sshd.SshLog.P_WAIT;
 
@@ -56,11 +59,17 @@
     buf.append(' ');
     buf.append(event.getMessage());
 
-    opt(P_WAIT, buf, event);
-    opt(P_EXEC, buf, event);
-    opt(P_MESSAGE, buf, event);
-    opt(P_STATUS, buf, event);
-    opt(P_AGENT, buf, event);
+    String msg = (String) event.getMessage();
+    if (!(msg.startsWith("LOGIN") || msg.equals("LOGOUT"))) {
+      req(P_WAIT, buf, event);
+      req(P_EXEC, buf, event);
+      req(P_MESSAGE, buf, event);
+      req(P_STATUS, buf, event);
+      req(P_AGENT, buf, event);
+      req(P_TOTAL_CPU, buf, event);
+      req(P_USER_CPU, buf, event);
+      req(P_MEMORY, buf, event);
+    }
 
     buf.append('\n');
     return buf.toString();
@@ -81,14 +90,6 @@
     }
   }
 
-  private void opt(String key, StringBuffer buf, LoggingEvent event) {
-    Object val = event.getMDC(key);
-    if (val != null) {
-      buf.append(' ');
-      buf.append(val);
-    }
-  }
-
   @Override
   public boolean ignoresThrowable() {
     return true;
diff --git a/java/com/google/gerrit/sshd/SshScope.java b/java/com/google/gerrit/sshd/SshScope.java
index 59f3f0c..b85cf6d 100644
--- a/java/com/google/gerrit/sshd/SshScope.java
+++ b/java/com/google/gerrit/sshd/SshScope.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.gerrit.metrics.proc.ThreadMXBeanFactory;
+import com.google.gerrit.metrics.proc.ThreadMXBeanInterface;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RequestCleanup;
@@ -32,16 +34,24 @@
 /** Guice scopes for state during an SSH connection. */
 public class SshScope {
   private static final Key<RequestCleanup> RC_KEY = Key.get(RequestCleanup.class);
+  private static final ThreadMXBeanInterface threadMxBean = ThreadMXBeanFactory.create();
 
   class Context implements RequestContext {
+
     private final RequestCleanup cleanup = new RequestCleanup();
     private final Map<Key<?>, Object> map = new HashMap<>();
     private final SshSession session;
     private final String commandLine;
 
-    final long created;
-    volatile long started;
-    volatile long finished;
+    private final long created;
+    private volatile long started;
+    private volatile long finished;
+    private volatile long startedTotalCpu;
+    private volatile long finishedTotalCpu;
+    private volatile long startedUserCpu;
+    private volatile long finishedUserCpu;
+    private volatile long startedMemory;
+    private volatile long finishedMemory;
 
     private IdentifiedUser identifiedUser;
 
@@ -49,6 +59,9 @@
       session = s;
       commandLine = c;
       created = started = finished = at;
+      startedTotalCpu = threadMxBean.getCurrentThreadCpuTime();
+      startedUserCpu = threadMxBean.getCurrentThreadUserTime();
+      startedMemory = threadMxBean.getCurrentThreadAllocatedBytes();
       map.put(RC_KEY, cleanup);
     }
 
@@ -56,6 +69,50 @@
       this(s, c, p.created);
       started = p.started;
       finished = p.finished;
+      startedTotalCpu = p.startedTotalCpu;
+      finishedTotalCpu = p.finishedTotalCpu;
+      startedUserCpu = p.startedUserCpu;
+      finishedUserCpu = p.finishedUserCpu;
+      startedMemory = p.startedMemory;
+      finishedMemory = p.finishedMemory;
+    }
+
+    void start() {
+      started = TimeUtil.nowMs();
+      startedTotalCpu = threadMxBean.getCurrentThreadCpuTime();
+      startedUserCpu = threadMxBean.getCurrentThreadUserTime();
+      startedMemory = threadMxBean.getCurrentThreadAllocatedBytes();
+    }
+
+    void finish() {
+      finished = TimeUtil.nowMs();
+      finishedTotalCpu = threadMxBean.getCurrentThreadCpuTime();
+      finishedUserCpu = threadMxBean.getCurrentThreadUserTime();
+      finishedMemory = threadMxBean.getCurrentThreadAllocatedBytes();
+    }
+
+    public long getCreated() {
+      return created;
+    }
+
+    public long getWait() {
+      return started - created;
+    }
+
+    public long getExec() {
+      return finished - started;
+    }
+
+    public long getTotalCpu() {
+      return (finishedTotalCpu - startedTotalCpu) / 1_000_000;
+    }
+
+    public long getUserCpu() {
+      return (finishedUserCpu - startedUserCpu) / 1_000_000;
+    }
+
+    public long getAllocatedMemory() {
+      return finishedMemory - startedMemory;
     }
 
     String getCommandLine() {
diff --git a/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java b/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java
new file mode 100644
index 0000000..1cdf923
--- /dev/null
+++ b/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import static com.google.gerrit.server.config.SshClientImplementation.APACHE;
+
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.transport.SshSessionFactory;
+import org.eclipse.jgit.transport.sshd.DefaultProxyDataFactory;
+import org.eclipse.jgit.transport.sshd.JGitKeyCache;
+import org.eclipse.jgit.transport.sshd.SshdSessionFactory;
+import org.eclipse.jgit.util.FS;
+
+public class SshSessionFactoryInitializer {
+  public static void init(Config config) {
+    if (APACHE == config.getEnum("ssh", null, "clientImplementation", APACHE)) {
+      SshdSessionFactory factory =
+          new SshdSessionFactory(new JGitKeyCache(), new DefaultProxyDataFactory());
+      factory.setHomeDirectory(FS.DETECTED.userHome());
+      SshSessionFactory.setInstance(factory);
+    }
+  }
+
+  private SshSessionFactoryInitializer() {}
+}
diff --git a/java/com/google/gerrit/sshd/SuExec.java b/java/com/google/gerrit/sshd/SuExec.java
index ea163d5..3c6e8c2 100644
--- a/java/com/google/gerrit/sshd/SuExec.java
+++ b/java/com/google/gerrit/sshd/SuExec.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.config.AuthConfig;
@@ -92,9 +93,9 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try {
+    try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
       checkCanRunAs();
-      parseCommandLine();
+      parseCommandLine(pluginOptions);
 
       final Context ctx = callingContext.subContext(newSession(), join(args));
       final Context old = sshScope.set(ctx);
diff --git a/java/com/google/gerrit/sshd/commands/ConvertRefStorage.java b/java/com/google/gerrit/sshd/commands/ConvertRefStorage.java
new file mode 100644
index 0000000..98626de
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ConvertRefStorage.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.git.DelegateRepository;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@CommandMetaData(
+    name = "convert-ref-storage",
+    description = "Convert ref storage to reftable (experimental)",
+    runsAt = MASTER_OR_SLAVE)
+public class ConvertRefStorage extends SshCommand {
+  @Inject private GitRepositoryManager repoManager;
+
+  private enum StorageFormatOption {
+    reftable,
+    refdir,
+  }
+
+  @Option(name = "--format", usage = "storage format to convert to (reftable or refdir)")
+  private StorageFormatOption storageFormat = StorageFormatOption.reftable;
+
+  @Option(
+      name = "--backup",
+      aliases = {"-b"},
+      usage = "create backup of old ref storage format",
+      handler = ExplicitBooleanOptionHandler.class)
+  private boolean backup = true;
+
+  @Option(
+      name = "--reflogs",
+      aliases = {"-r"},
+      usage = "write reflogs to reftable",
+      handler = ExplicitBooleanOptionHandler.class)
+  private boolean writeLogs = true;
+
+  @Option(
+      name = "--project",
+      aliases = {"-p"},
+      metaVar = "PROJECT",
+      required = true,
+      usage = "project for which the storage format should be changed")
+  private ProjectState projectState;
+
+  @Override
+  public void run() throws Exception {
+    enableGracefulStop();
+    Project.NameKey projectName = projectState.getNameKey();
+    try (Repository repo = repoManager.openRepository(projectName)) {
+      if (repo instanceof DelegateRepository) {
+        ((DelegateRepository) repo).convertRefStorage(storageFormat.name(), writeLogs, backup);
+      } else {
+        checkState(
+            repo instanceof FileRepository, "Repository is not an instance of FileRepository!");
+        ((FileRepository) repo).convertRefStorage(storageFormat.name(), writeLogs, backup);
+      }
+    } catch (RepositoryNotFoundException e) {
+      throw die("'" + projectName + "': not a git archive", e);
+    } catch (IOException e) {
+      throw die("Error converting: '" + projectName + "': " + e.getMessage(), e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index f2ab4e8..e2d554d 100644
--- a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -143,7 +143,7 @@
       name = "--branch",
       aliases = {"-b"},
       metaVar = "BRANCH",
-      usage = "initial branch name\n(default: master)")
+      usage = "initial branch name\n(default: gerrit.defaultProject)")
   private List<String> branch;
 
   @Option(name = "--empty-commit", usage = "to create initial empty commit")
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index cfd17f4..8ee6a0d 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -47,6 +47,7 @@
     command(gerrit, AproposCommand.class);
     command(gerrit, BanCommitCommand.class);
     command(gerrit, CloseConnection.class);
+    command(gerrit, ConvertRefStorage.class);
     command(gerrit, FlushCaches.class);
     command(gerrit, ListProjectsCommand.class);
     command(gerrit, ListMembersCommand.class);
diff --git a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
index 7bf42eb..35699af 100644
--- a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -19,10 +19,10 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.ioutil.ColumnFormatter;
 import com.google.gerrit.server.restapi.group.ListGroups;
 import com.google.gerrit.sshd.CommandMetaData;
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index 37f4245..fd18656 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -19,11 +19,12 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.ioutil.ColumnFormatter;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.ListMembers;
@@ -50,8 +51,8 @@
   }
 
   @Override
-  protected void parseCommandLine() throws UnloggedFailure {
-    parseCommandLine(impl);
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
+    parseCommandLine(impl, pluginOptions);
   }
 
   private static class ListMembersCommandImpl extends ListMembers {
diff --git a/java/com/google/gerrit/sshd/commands/PatchSetParser.java b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
index 4ebf15e..65d48dd 100644
--- a/java/com/google/gerrit/sshd/commands/PatchSetParser.java
+++ b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
@@ -18,6 +18,7 @@
 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.git.ObjectIds;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeFinder;
@@ -59,6 +60,7 @@
     if (token.matches("^([0-9a-fA-F]{4," + ObjectIds.STR_LEN + "})$")) {
       InternalChangeQuery query = queryProvider.get();
       List<ChangeData> cds;
+      branch = branch != null ? RefNames.fullName(branch) : null;
       if (projectState != null) {
         Project.NameKey p = projectState.getNameKey();
         if (branch != null) {
diff --git a/java/com/google/gerrit/sshd/commands/Query.java b/java/com/google/gerrit/sshd/commands/Query.java
index 772eabe..da19153 100644
--- a/java/com/google/gerrit/sshd/commands/Query.java
+++ b/java/com/google/gerrit/sshd/commands/Query.java
@@ -116,9 +116,9 @@
   }
 
   @Override
-  protected void parseCommandLine() throws UnloggedFailure {
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
     processor.setOutput(out, OutputFormat.TEXT);
-    super.parseCommandLine();
+    super.parseCommandLine(pluginOptions);
     if (processor.getIncludeFiles()
         && !(processor.getIncludePatchSets() || processor.getIncludeCurrentPatchSet())) {
       throw die("--files option needs --patch-sets or --current-patch-set");
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index b58cc45..4c84bd3 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -320,7 +321,7 @@
   }
 
   @Override
-  protected void parseCommandLine() throws UnloggedFailure {
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
     optionMap = new LinkedHashMap<>();
     customLabels = new HashMap<>();
 
@@ -341,7 +342,7 @@
       optionMap.put(newApproveOption(type, usage.toString()), new LabelSetter(type));
     }
 
-    super.parseCommandLine();
+    super.parseCommandLine(pluginOptions);
   }
 
   private static String asOptionName(LabelType type) {
diff --git a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index db8e42a..f788f14 100644
--- a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -28,7 +29,6 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.GroupResource;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.restapi.group.AddMembers;
 import com.google.gerrit.server.restapi.group.AddSubgroups;
 import com.google.gerrit.server.restapi.group.DeleteMembers;
diff --git a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
index 70700f1..35cb3ba 100644
--- a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
@@ -16,12 +16,11 @@
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.TopicInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.restapi.change.SetTopicOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.sshd.ChangeArgumentParser;
@@ -74,18 +73,17 @@
 
   @Override
   public void run() throws Exception {
-    TopicInput input = new TopicInput();
     if (topic != null) {
-      input.topic = topic.trim();
+      topic = topic.trim();
     }
 
-    if (input.topic != null && input.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
+    if (topic != null && topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
       throw new BadRequestException(
           String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
     }
 
     for (ChangeResource r : changes.values()) {
-      SetTopicOp op = topicOpFactory.create(input);
+      SetTopicOp op = topicOpFactory.create(topic);
       try (BatchUpdate u =
           updateFactory.create(r.getChange().getProject(), user, TimeUtil.nowTs())) {
         u.addOp(r.getId(), op);
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 219ceaa..02956f7 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -49,7 +49,7 @@
 import java.util.Map;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.io.mina.MinaSession;
+import org.apache.sshd.mina.MinaSession;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.kohsuke.args4j.Option;
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index d271364..7eeb770 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -39,10 +39,10 @@
 import java.util.Optional;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.io.mina.MinaAcceptor;
-import org.apache.sshd.common.io.mina.MinaSession;
 import org.apache.sshd.common.io.nio2.Nio2Acceptor;
 import org.apache.sshd.common.session.helpers.AbstractSession;
+import org.apache.sshd.mina.MinaAcceptor;
+import org.apache.sshd.mina.MinaSession;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.kohsuke.args4j.Option;
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index 45540a0..c47d24c 100644
--- a/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.EventGson;
@@ -107,59 +108,62 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try {
-      parseCommandLine();
-    } catch (UnloggedFailure e) {
-      String msg = e.getMessage();
-      if (!msg.endsWith("\n")) {
-        msg += "\n";
+    try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
+      try {
+        parseCommandLine(pluginOptions);
+      } catch (UnloggedFailure e) {
+        String msg = e.getMessage();
+        if (!msg.endsWith("\n")) {
+          msg += "\n";
+        }
+        err.write(msg.getBytes(UTF_8));
+        err.flush();
+        onExit(1);
+        return;
       }
-      err.write(msg.getBytes(UTF_8));
-      err.flush();
-      onExit(1);
-      return;
-    }
 
-    PrintWriter stdout = toPrintWriter(out);
-    CancelableRunnable writer =
-        new CancelableRunnable() {
-          @Override
-          public void run() {
-            writeEvents(this, stdout);
-          }
-
-          @Override
-          public void cancel() {
-            onExit(0);
-          }
-
-          @Override
-          public String toString() {
-            StringBuilder b = new StringBuilder();
-            b.append("Stream Events");
-            if (currentUser.getUserName().isPresent()) {
-              b.append(" (").append(currentUser.getUserName().get()).append(")");
+      PrintWriter stdout = toPrintWriter(out);
+      CancelableRunnable writer =
+          new CancelableRunnable() {
+            @Override
+            public void run() {
+              writeEvents(this, stdout);
             }
-            return b.toString();
-          }
-        };
 
-    eventListenerRegistration =
-        eventListeners.add(
-            "gerrit",
-            new UserScopedEventListener() {
-              @Override
-              public void onEvent(Event event) {
-                if (subscribedToEvents.isEmpty() || subscribedToEvents.contains(event.getType())) {
-                  offer(writer, event);
+            @Override
+            public void cancel() {
+              onExit(0);
+            }
+
+            @Override
+            public String toString() {
+              StringBuilder b = new StringBuilder();
+              b.append("Stream Events");
+              if (currentUser.getUserName().isPresent()) {
+                b.append(" (").append(currentUser.getUserName().get()).append(")");
+              }
+              return b.toString();
+            }
+          };
+
+      eventListenerRegistration =
+          eventListeners.add(
+              "gerrit",
+              new UserScopedEventListener() {
+                @Override
+                public void onEvent(Event event) {
+                  if (subscribedToEvents.isEmpty()
+                      || subscribedToEvents.contains(event.getType())) {
+                    offer(writer, event);
+                  }
                 }
-              }
 
-              @Override
-              public CurrentUser getUser() {
-                return currentUser;
-              }
-            });
+                @Override
+                public CurrentUser getUser() {
+                  return currentUser;
+                }
+              });
+    }
   }
 
   private void removeEventListenerRegistration() {
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java
index 67dc5a5..0eda433 100644
--- a/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -73,6 +73,14 @@
     @Option(name = "--prefix", usage = "Prepend <prefix>/ to each filename in the archive.")
     private String prefix;
 
+    @Option(
+        name = "--compression-level",
+        usage =
+            "Controls compression for different formats. The value is in [0-9] with 0 for fast levels"
+                + " with medium compressions, and 9 for the highest compression. Note that higher"
+                + " compressions require more memory.")
+    private int compressionLevel = -1;
+
     @Option(name = "-0", usage = "Store the files instead of deflating them.")
     private boolean level0;
 
@@ -223,6 +231,9 @@
   }
 
   private Map<String, Object> getFormatOptions(ArchiveFormatInternal f) {
+    if (options.compressionLevel != -1) {
+      return ImmutableMap.of("compression-level", options.compressionLevel);
+    }
     if (f == ArchiveFormatInternal.ZIP) {
       int value =
           Arrays.asList(
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 16c15ad..93c996e8 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -15,11 +15,13 @@
     deps = [
         "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/acceptance/testsuite/project",
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
+        "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/lifecycle",
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 1779a18..1c322b2 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -22,10 +22,12 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
+import com.google.gerrit.auth.AuthModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
 import com.google.gerrit.gpg.GpgModule;
+import com.google.gerrit.httpd.auth.restapi.OAuthRestModule;
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
@@ -41,12 +43,14 @@
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DefaultUrlFormatter;
@@ -170,6 +174,9 @@
     bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
     install(cfgInjector.getInstance(GerritGlobalModule.class));
+
+    AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
+    install(new AuthModule(authConfig));
     install(new GerritApiModule());
     factory(PluginUser.Factory.class);
     install(new PluginApiModule());
@@ -239,7 +246,9 @@
     bind(ServerInformationImpl.class);
     bind(ServerInformation.class).to(ServerInformationImpl.class);
     install(new RestApiModule());
+    install(new OAuthRestModule());
     install(new DefaultProjectNameLockManager.Module());
+    install(new FileInfoJsonModule(cfg));
 
     bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
   }
diff --git a/java/com/google/gerrit/testing/TestLoggingActivator.java b/java/com/google/gerrit/testing/TestLoggingActivator.java
index a766429..6b5d8fd 100644
--- a/java/com/google/gerrit/testing/TestLoggingActivator.java
+++ b/java/com/google/gerrit/testing/TestLoggingActivator.java
@@ -31,6 +31,7 @@
 
           // Silence non-critical messages from MINA SSHD.
           .put("org.apache.mina", Level.WARN)
+          .put("org.apache.sshd.client", Level.WARN)
           .put("org.apache.sshd.common", Level.WARN)
           .put("org.apache.sshd.server", Level.WARN)
           .put("org.apache.sshd.common.keyprovider.FileKeyPairProvider", Level.INFO)
@@ -61,6 +62,8 @@
           // Silence non-critical messages from JGit.
           .put("org.eclipse.jgit.transport.PacketLineIn", Level.WARN)
           .put("org.eclipse.jgit.transport.PacketLineOut", Level.WARN)
+          .put("org.eclipse.jgit.internal.transport.sshd", Level.WARN)
+          .put("org.eclipse.jgit.util.FileUtils", Level.WARN)
           .put("org.eclipse.jgit.internal.storage.file.FileSnapshot", Level.WARN)
           .put("org.eclipse.jgit.util.FS", Level.WARN)
           .put("org.eclipse.jgit.util.SystemReader", Level.WARN)
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index e002eeb..7c42797 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -563,20 +563,22 @@
      *
      * @param name name
      * @return the {@code OptionHandler} or {@code null}
-     *     <p>Note: this is cut & pasted from the parent class in arg4j, it was private and it
-     *     needed to be exposed.
+     *     <p>Note: this was originally cut & pasted from the parent class in arg4j, it was private
+     *     and it needed to be exposed.
      */
     @SuppressWarnings("rawtypes")
     public OptionHandler findOptionByName(String name) {
       for (OptionHandler h : optionsList) {
-        NamedOptionDef option = (NamedOptionDef) h.option;
-        if (name.equals(option.name())) {
-          return h;
-        }
-        for (String alias : option.aliases()) {
-          if (name.equals(alias)) {
+        if (h.option instanceof NamedOptionDef) {
+          NamedOptionDef option = (NamedOptionDef) h.option;
+          if (name.equals(option.name())) {
             return h;
           }
+          for (String alias : option.aliases()) {
+            if (name.equals(alias)) {
+              return h;
+            }
+          }
         }
       }
       return null;
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index 7dbf751..db831b7 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -5,7 +5,6 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
diff --git a/java/gerrit/PRED_commit_delta_4.java b/java/gerrit/PRED_commit_delta_4.java
index 6e971fc..502b15b 100644
--- a/java/gerrit/PRED_commit_delta_4.java
+++ b/java/gerrit/PRED_commit_delta_4.java
@@ -14,11 +14,10 @@
 
 package gerrit;
 
-import com.google.gerrit.entities.Patch;
-import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
@@ -28,8 +27,15 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.io.IOException;
 import java.util.Iterator;
+import java.util.List;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /**
  * Given a regular expression, checks it against the file list in the most recent patchset of a
@@ -76,10 +82,22 @@
     engine.r3 = arg3;
     engine.r4 = arg4;
 
-    PatchList pl = StoredValues.PATCH_LIST.get(engine);
-    Iterator<PatchListEntry> iter = pl.getPatches().iterator();
+    Repository repository = StoredValues.REPOSITORY.get(engine);
 
-    engine.r5 = new JavaObjectTerm(iter);
+    try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+      diffFormatter.setRepository(repository);
+      // Do not detect renames; that would require reading file contents, which is slow for large
+      // files.
+      RevCommit commit = StoredValues.COMMIT.get(engine);
+      List<DiffEntry> diffEntries =
+          diffFormatter.scan(
+              // In case of a merge commit, i.e. >1 parents, we use parent #0 by convention. So
+              // parent #0 is always the right choice, if it exists.
+              commit.getParentCount() > 0 ? commit.getParent(0) : null, commit);
+      engine.r5 = new JavaObjectTerm(diffEntries.iterator());
+    } catch (IOException e) {
+      throw new JavaException(e);
+    }
 
     return engine.jtry5(commit_delta_check, commit_delta_next);
   }
@@ -95,23 +113,22 @@
 
       Pattern regex = (Pattern) ((JavaObjectTerm) a1).object();
       @SuppressWarnings("unchecked")
-      Iterator<PatchListEntry> iter = (Iterator<PatchListEntry>) ((JavaObjectTerm) a5).object();
+      Iterator<DiffEntry> iter = (Iterator<DiffEntry>) ((JavaObjectTerm) a5).object();
       while (iter.hasNext()) {
-        PatchListEntry patch = iter.next();
-        String newName = patch.getNewName();
-        String oldName = patch.getOldName();
-        Patch.ChangeType changeType = patch.getChangeType();
+        DiffEntry diffEntry = iter.next();
+        String newName = diffEntry.getNewPath();
+        String oldName = diffEntry.getOldPath();
+        DiffEntry.ChangeType changeType = diffEntry.getChangeType();
 
-        if (Patch.isMagic(newName)) {
-          continue;
-        }
-
-        if (regex.matcher(newName).find() || (oldName != null && regex.matcher(oldName).find())) {
+        if ((!isNull(newName) && regex.matcher(newName).find())
+            || (!isNull(oldName) && regex.matcher(oldName).find())) {
           SymbolTerm changeSym = getTypeSymbol(changeType);
-          SymbolTerm newSym = SymbolTerm.create(newName);
-          SymbolTerm oldSym = Prolog.Nil;
-          if (oldName != null) {
-            oldSym = SymbolTerm.create(oldName);
+          SymbolTerm newSym = isNull(newName) ? Prolog.Nil : SymbolTerm.create(newName);
+          SymbolTerm oldSym = isNull(oldName) ? Prolog.Nil : SymbolTerm.create(oldName);
+          // For compatibility with legacy semantics:
+          if (changeSym.equals(delete)) {
+            newSym = oldSym;
+            oldSym = Prolog.Nil;
           }
 
           if (!a2.unify(changeSym, engine.trail)) {
@@ -130,6 +147,10 @@
     }
   }
 
+  private static boolean isNull(String path) {
+    return path.equals("/dev/null");
+  }
+
   private static final class PRED_commit_delta_next extends Operation {
     @Override
     public Operation exec(Prolog engine) {
@@ -152,20 +173,18 @@
     }
   }
 
-  private static SymbolTerm getTypeSymbol(Patch.ChangeType type) {
+  private static SymbolTerm getTypeSymbol(DiffEntry.ChangeType type) {
     switch (type) {
-      case ADDED:
+      case ADD:
         return add;
-      case MODIFIED:
+      case MODIFY:
         return modify;
-      case DELETED:
+      case DELETE:
         return delete;
-      case RENAMED:
+      case RENAME:
         return rename;
-      case COPIED:
+      case COPY:
         return copy;
-      case REWRITE:
-        break;
     }
     throw new IllegalArgumentException("ChangeType not recognized");
   }
diff --git a/java/com/google/gerrit/acceptance/WaitUtilTest.java b/javatests/com/google/gerrit/acceptance/WaitUtilTest.java
similarity index 100%
rename from java/com/google/gerrit/acceptance/WaitUtilTest.java
rename to javatests/com/google/gerrit/acceptance/WaitUtilTest.java
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 1b55652..7495e63 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -75,6 +75,7 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
@@ -150,9 +151,9 @@
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.security.KeyPair;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -2001,7 +2002,7 @@
 
       // Add a new key
       sender.clear();
-      String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
+      String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), admin.email());
       gApi.accounts().self().addSshKey(newKey);
       info = gApi.accounts().self().listSshKeys();
       assertThat(info).hasSize(2);
@@ -2023,7 +2024,7 @@
 
       // Add another new key
       sender.clear();
-      String newKey2 = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
+      String newKey2 = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), admin.email());
       gApi.accounts().self().addSshKey(newKey2);
       info = gApi.accounts().self().listSshKeys();
       assertThat(info).hasSize(3);
@@ -2074,7 +2075,7 @@
 
       // Add a new key
       sender.clear();
-      String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), user.email());
+      String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), user.email());
       gApi.accounts().id(user.username()).addSshKey(newKey);
       info = gApi.accounts().id(user.username()).listSshKeys();
       assertThat(info).hasSize(2);
@@ -2103,7 +2104,7 @@
   @Test
   @UseSsh
   public void userCannotAddSshKeyToOtherAccount() throws Exception {
-    String newKey = TestSshKeys.publicKey(TestSshKeys.genSshKey(), admin.email());
+    String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), admin.email());
     requestScopeOperations.setApiUser(user.id());
     assertThrows(AuthException.class, () -> gApi.accounts().id(admin.username()).addSshKey(newKey));
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 24b7998..9b77b01 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.ContributorAgreement;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -49,7 +50,6 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 7c504b8..a4f91d7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -47,6 +47,7 @@
 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 static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -91,8 +92,10 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
@@ -141,8 +144,10 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -154,6 +159,7 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -176,6 +182,7 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -235,7 +242,7 @@
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
     assertThat(c.subject).isEqualTo("test commit");
     assertThat(c.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
-    assertThat(c.mergeable).isTrue();
+    assertThat(c.mergeable).isNull();
     assertThat(c.changeId).isEqualTo(r.getChangeId());
     assertThat(c.created).isEqualTo(c.updated);
     assertThat(c._number).isEqualTo(r.getChange().getId().get());
@@ -611,7 +618,7 @@
     ReviewInput in =
         ReviewInput.approve()
             .reviewer(user.email())
-            .label("Code-Review", 1)
+            .label(LabelId.CODE_REVIEW, 1)
             .setWorkInProgress(true);
     gApi.changes().id(r.getChangeId()).current().review(in);
 
@@ -619,7 +626,8 @@
     assertThat(info.workInProgress).isTrue();
     assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
         .containsExactly(admin.id().get(), user.id().get());
-    assertThat(info.labels.get("Code-Review").recommended._accountId).isEqualTo(admin.id().get());
+    assertThat(info.labels.get(LabelId.CODE_REVIEW).recommended._accountId)
+        .isEqualTo(admin.id().get());
   }
 
   @Test
@@ -694,6 +702,8 @@
   @Test
   public void reviewWithReadyByNonOwnerReturnsError() throws Exception {
     PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+
     ReviewInput in = ReviewInput.noScore().setReady(true);
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
@@ -774,7 +784,7 @@
     assertThat(description).isEqualTo("Rebase");
 
     // ...and the approval was copied
-    LabelInfo cr = c2.labels.get("Code-Review");
+    LabelInfo cr = c2.labels.get(LabelId.CODE_REVIEW);
     assertThat(cr).isNotNull();
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).value).isEqualTo(1);
@@ -1338,9 +1348,135 @@
             "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
     PushOneCommit.Result r2 = push.to("refs/for/master");
     r2.assertOkStatus();
-    assertThrows(
-        ResourceConflictException.class,
-        () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase());
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "The change could not be rebased due to a conflict during merge.\n\n"
+                    + "merge conflict(s):\n%s",
+                PushOneCommit.FILE_NAME));
+  }
+
+  @Test
+  public void rebaseDoesNotAddWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // change is still ready for review after rebase
+    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isNull();
+  }
+
+  @Test
+  public void rebaseDoesNotRemoveWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // change is still work in progress after rebase
+    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
+  }
+
+  @Test
+  public void rebaseConflict_conflictsAllowed() throws Exception {
+    String patchSetSubject = "patch set change";
+    String patchSetContent = "patch set content";
+    String baseSubject = "base change";
+    String baseContent = "base content";
+
+    PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+    testRepo.reset("HEAD~1");
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(), testRepo, patchSetSubject, PushOneCommit.FILE_NAME, patchSetContent);
+    PushOneCommit.Result r2 = push.to("refs/for/master");
+    r2.assertOkStatus();
+
+    String changeId = r2.getChangeId();
+    RevCommit patchSet = r2.getCommit();
+    RevCommit base = r1.getCommit();
+
+    TestWorkInProgressStateChangedListener wipStateChangedListener =
+        new TestWorkInProgressStateChangedListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.allowConflicts = true;
+      ChangeInfo changeInfo =
+          gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput);
+      assertThat(changeInfo.containsGitConflicts).isTrue();
+      assertThat(changeInfo.workInProgress).isTrue();
+    }
+    assertThat(wipStateChangedListener.invoked).isTrue();
+    assertThat(wipStateChangedListener.wip).isTrue();
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(base.name());
+
+    // Verify that the file content in the created patch set is correct.
+    // We expect that it has conflict markers to indicate the conflict.
+    BinaryResult bin =
+        gApi.changes().id(changeId).current().file(PushOneCommit.FILE_NAME).content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String fileContent = new String(os.toByteArray(), UTF_8);
+    String patchSetSha1 = abbreviateName(patchSet, 6);
+    String baseSha1 = abbreviateName(base, 6);
+    assertThat(fileContent)
+        .isEqualTo(
+            "<<<<<<< PATCH SET ("
+                + patchSetSha1
+                + " "
+                + patchSetSubject
+                + ")\n"
+                + patchSetContent
+                + "\n"
+                + "=======\n"
+                + baseContent
+                + "\n"
+                + ">>>>>>> BASE      ("
+                + baseSha1
+                + " "
+                + baseSubject
+                + ")\n");
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            "Patch Set 2: Patch Set 1 was rebased\n\n"
+                + "The following files contain Git conflicts:\n"
+                + "* "
+                + PushOneCommit.FILE_NAME
+                + "\n");
   }
 
   @Test
@@ -1504,19 +1640,19 @@
     assertThat(commit.author.email).isEqualTo(user.email());
     assertThat(commit.committer.email).isEqualTo(user.email());
 
-    // check that the author/committer was added as reviewer
-    Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
+    // check that the author/committer was added as cc
+    Collection<AccountInfo> reviewers = change.reviewers.get(CC);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
-    assertThat(change.reviewers.get(CC)).isNull();
+    assertThat(change.reviewers.get(REVIEWER)).isNull();
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.from().name()).isEqualTo("Administrator (Code Review)");
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
-    assertThat(m.body()).contains("I'd like you to do a code review");
+    assertThat(m.body()).contains("has uploaded this change for review");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
     assertMailReplyTo(m, admin.email());
   }
@@ -2193,7 +2329,7 @@
         gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).votes();
 
     assertThat(m).hasSize(1);
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 2));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 2));
 
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.dislike());
@@ -2201,7 +2337,7 @@
     m = gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).votes();
 
     assertThat(m).hasSize(1);
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) -1));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) -1));
   }
 
   @Test
@@ -2215,18 +2351,18 @@
     // check finding by address works
     Map<String, Short> m = gApi.changes().id(r.getChangeId()).reviewer(admin.email()).votes();
     assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
+    assertThat(m).containsEntry(LabelId.CODE_REVIEW, Short.valueOf((short) 2));
 
     // check finding by id works
     m = gApi.changes().id(r.getChangeId()).reviewer(admin.id().toString()).votes();
     assertThat(m).hasSize(1);
-    assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 2));
+    assertThat(m).containsEntry(LabelId.CODE_REVIEW, Short.valueOf((short) 2));
   }
 
   @Test
   public void removeReviewerNoVotes() throws Exception {
     LabelType verified =
-        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().upsertLabelType(verified);
       u.save();
@@ -2258,6 +2394,10 @@
     assertThat(message.body()).contains("Removed reviewer " + user.fullName() + ".");
     assertThat(message.body()).doesNotContain("with the following votes");
 
+    // Make sure the change message for removing a reviewer is correct.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).messages()).message)
+        .contains("Removed reviewer " + user.fullName());
+
     // Make sure the reviewer can still be added again.
     gApi.changes().id(changeId).addReviewer(user.id().toString());
     c = gApi.changes().id(changeId).get();
@@ -2273,6 +2413,31 @@
   }
 
   @Test
+  public void removeCC() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    // Add a cc
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = CC;
+    addReviewerInput.reviewer = user.id().toString();
+    gApi.changes().id(changeId).addReviewer(addReviewerInput);
+
+    // Remove a cc
+    sender.clear();
+    gApi.changes().id(changeId).reviewer(user.id().toString()).remove();
+    assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
+
+    // Make sure the email for removing a cc is correct.
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = sender.getMessages().get(0);
+    assertThat(message.body()).contains("Removed cc " + user.fullName() + ".");
+
+    // Make sure the change message for removing a reviewer is correct.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).messages()).message)
+        .contains("Removed cc " + user.fullName());
+  }
+
+  @Test
   public void removeReviewer() throws Exception {
     testRemoveReviewer(true);
   }
@@ -2403,7 +2568,10 @@
 
     requestScopeOperations.setApiUser(admin.id());
     sender.clear();
-    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote("Code-Review");
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
@@ -2417,7 +2585,7 @@
         gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).votes();
 
     // Dummy 0 approval on the change to block vote copying to this patch set.
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 0));
 
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
@@ -2439,7 +2607,7 @@
     requestScopeOperations.setApiUser(admin.id());
     sender.clear();
     DeleteVoteInput in = new DeleteVoteInput();
-    in.label = "Code-Review";
+    in.label = LabelId.CODE_REVIEW;
     in.notify = NotifyHandling.NONE;
     gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote(in);
     assertThat(sender.getMessages()).isEmpty();
@@ -2451,7 +2619,7 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     DeleteVoteInput in = new DeleteVoteInput();
-    in.label = "Code-Review";
+    in.label = LabelId.CODE_REVIEW;
     in.notify = NotifyHandling.NONE;
 
     // notify unrelated account as TO
@@ -2505,14 +2673,14 @@
                 gApi.changes()
                     .id(r.getChangeId())
                     .reviewer(admin.id().toString())
-                    .deleteVote("Code-Review"));
+                    .deleteVote(LabelId.CODE_REVIEW));
     assertThat(thrown).hasMessageThat().contains("delete vote not permitted");
   }
 
   @Test
   public void nonVotingReviewerStaysAfterSubmit() throws Exception {
     LabelType verified =
-        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().upsertLabelType(verified);
@@ -2522,7 +2690,7 @@
         .project(project)
         .forUpdate()
         .add(allowLabel(verified.getName()).ref(heads).group(CHANGE_OWNER).range(-1, 1))
-        .add(allowLabel("Code-Review").ref(heads).group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS).range(-2, +2))
         .update();
 
     // Set Code-Review+2 and Verified+1 as admin (change owner)
@@ -2691,7 +2859,7 @@
                 .withOptions(
                     ALL_REVISIONS, CHANGE_ACTIONS, CURRENT_ACTIONS, DETAILED_LABELS, MESSAGES)
                 .get());
-    assertThat(Iterables.getOnlyElement(result.labels.keySet())).isEqualTo("Code-Review");
+    assertThat(Iterables.getOnlyElement(result.labels.keySet())).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(result.messages).hasSize(1);
     assertThat(result.actions).isNotEmpty();
 
@@ -2874,7 +3042,7 @@
   @Test
   public void commitFooters() throws Exception {
     LabelType verified =
-        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     LabelType custom1 =
         label("Custom1", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     LabelType custom2 =
@@ -2902,8 +3070,8 @@
     r2.assertOkStatus();
 
     ReviewInput in = new ReviewInput();
-    in.label("Code-Review", 1);
-    in.label("Verified", 1);
+    in.label(LabelId.CODE_REVIEW, 1);
+    in.label(LabelId.VERIFIED, 1);
     in.label("Custom1", -1);
     in.label("Custom2", 1);
     gApi.changes().id(r2.getChangeId()).current().review(in);
@@ -2942,14 +3110,19 @@
   public void customCommitFooters() throws Exception {
     PushOneCommit.Result change = createChange();
     ChangeInfo actual;
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                (newCommitMessage, original, mergeTip, destination) -> {
-                  assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
-                  return newCommitMessage + "Custom: " + destination.branch();
-                })) {
+    ChangeMessageModifier link =
+        new ChangeMessageModifier() {
+          @Override
+          public String onSubmit(
+              String newCommitMessage,
+              RevCommit original,
+              RevCommit mergeTip,
+              BranchNameKey destination) {
+            assertThat(original.getName()).isNotEqualTo(mergeTip.getName());
+            return newCommitMessage + "Custom: " + destination.branch();
+          }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
       actual = gApi.changes().id(change.getChangeId()).get(ALL_REVISIONS, COMMIT_FOOTERS);
     }
     List<String> footers =
@@ -3006,7 +3179,7 @@
     String triplet = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(triplet).addReviewer(user.username());
     ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    LabelInfo codeReview = c.labels.get("Code-Review");
+    LabelInfo codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3015,11 +3188,15 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
         .update();
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    codeReview = c.labels.get("Code-Review");
+    codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3210,8 +3387,8 @@
     PushOneCommit.Result r = createChange();
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
 
     // add new label and assert that it's returned for existing changes
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
@@ -3229,10 +3406,11 @@
         .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review", "Verified");
-    assertPermitted(change, "Code-Review", -2, -1, 0, 1, 2);
-    assertPermitted(change, "Verified", -1, 0, 1);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertThat(change.permittedLabels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.CODE_REVIEW, -2, -1, 0, 1, 2);
+    assertPermitted(change, LabelId.VERIFIED, -1, 0, 1);
 
     // add an approval on the new label
     gApi.changes()
@@ -3256,15 +3434,15 @@
         .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
 
     // abandon the change and see that the returned labels stay the same
     // while all permitted labels disappear.
     gApi.changes().id(r.getChangeId()).abandon();
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels).isEmpty();
   }
 
@@ -3277,9 +3455,9 @@
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(change.submissionId).isNotNull();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 2);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
 
     LabelType verified = TestLabels.verified();
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
@@ -3297,10 +3475,11 @@
         .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review", "Verified");
-    assertPermitted(change, "Code-Review", 2);
-    assertPermitted(change, "Verified", 0, 1);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertThat(change.permittedLabels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
+    assertPermitted(change, LabelId.VERIFIED, 0, 1);
 
     // ignore the new label by Prolog submit rule and assert that the label is
     // no longer returned
@@ -3316,8 +3495,8 @@
     push2.to(RefNames.REFS_CONFIG);
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertPermitted(change, "Code-Review", 2);
-    assertPermitted(change, "Verified");
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
+    assertPermitted(change, LabelId.VERIFIED);
 
     // add an approval on the new label and assert that the label is now
     // returned although it is ignored by the Prolog submit rule and hence not
@@ -3328,9 +3507,9 @@
         .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Verified");
-    assertPermitted(change, "Code-Review", 2);
-    assertPermitted(change, "Verified");
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
+    assertPermitted(change, LabelId.VERIFIED);
 
     // remove label and assert that it's no longer returned for existing
     // changes, even if there is an approval for it
@@ -3345,9 +3524,9 @@
         .update();
 
     change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 2);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
   }
 
   @Test
@@ -3440,9 +3619,10 @@
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(change.submissionId).isNotNull();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review", "Non-Author-Code-Review");
-    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 0, 1, 2);
+    assertThat(change.labels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, "Non-Author-Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
   }
 
   @Test
@@ -3456,8 +3636,8 @@
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(change.submissionId).isNotNull();
-    assertThat(change.labels.keySet()).containsExactly("Code-Review");
-    assertPermitted(change, "Code-Review", 0, 1, 2);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
   }
 
   @Test
@@ -3494,7 +3674,7 @@
     gApi.changes().id(triplet).addReviewer(user.username());
 
     ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    LabelInfo codeReview = c.labels.get("Code-Review");
+    LabelInfo codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3507,14 +3687,14 @@
         .project(project)
         .forUpdate()
         .add(
-            allowLabel("Code-Review")
+            allowLabel(LabelId.CODE_REVIEW)
                 .ref(heads)
                 .group(REGISTERED_USERS)
                 .range(minPermittedValue, maxPermittedValue))
         .update();
 
     c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    codeReview = c.labels.get("Code-Review");
+    codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3528,7 +3708,11 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
         .update();
 
     PushOneCommit.Result r = createChange();
@@ -3537,7 +3721,7 @@
     gApi.changes().id(triplet).addReviewer(user.username());
 
     ChangeInfo c = gApi.changes().id(triplet).get(DETAILED_LABELS);
-    LabelInfo codeReview = c.labels.get("Code-Review");
+    LabelInfo codeReview = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(codeReview.all).hasSize(1);
     ApprovalInfo approval = codeReview.all.get(0);
     assertThat(approval._accountId).isEqualTo(user.id().get());
@@ -3554,7 +3738,7 @@
 
     Map<String, Short> votes =
         gApi.changes().id(changeId).current().reviewer(admin.email()).votes();
-    assertThat(votes.keySet()).containsExactly("Code-Review");
+    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(votes.values()).containsExactly((short) 2);
   }
 
@@ -3563,7 +3747,7 @@
     String changeId = createChange().getChangeId();
 
     // Add a review with invalid label values.
-    ReviewInput input = new ReviewInput().label("Code-Review", 3);
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 3);
     gApi.changes().id(changeId).current().review(input);
 
     assertThrows(
@@ -3587,7 +3771,7 @@
   @GerritConfig(name = "change.strictLabels", value = "true")
   public void strictLabelWithInvalidValue() throws Exception {
     String changeId = createChange().getChangeId();
-    ReviewInput in = new ReviewInput().label("Code-Review", 3);
+    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 3);
 
     BadRequestException thrown =
         assertThrows(
@@ -3811,7 +3995,7 @@
   }
 
   private void submittableAfterLosingPermissions(String label) throws Exception {
-    String codeReviewLabel = "Code-Review";
+    String codeReviewLabel = LabelId.CODE_REVIEW;
     AccountGroup.UUID registered = REGISTERED_USERS;
     projectOperations
         .project(project)
@@ -4328,4 +4512,17 @@
   private interface AddReviewerCaller {
     void call(String changeId, String reviewer) throws RestApiException;
   }
+
+  private static class TestWorkInProgressStateChangedListener
+      implements WorkInProgressStateChangedListener {
+    boolean invoked;
+    Boolean wip;
+
+    @Override
+    public void onWorkInProgressStateChanged(Event event) {
+      this.invoked = true;
+      this.wip =
+          event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeReviewIT.java
new file mode 100644
index 0000000..2b04e56
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeReviewIT.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseSsh;
+import org.junit.Test;
+
+@UseSsh
+public class ChangeReviewIT extends AbstractDaemonTest {
+
+  @Test
+  public void testGerritReviewCommandWithShortNameBranch() throws Exception {
+    PushOneCommit.Result r = createChange();
+    adminSshSession.exec(
+        "gerrit review --project "
+            + r.getChange().change().getProject().get()
+            + " --branch "
+            + r.getChange().change().getDest().shortName()
+            + " --code-review 1 "
+            + r.getCommit().getName());
+    adminSshSession.assertSuccess();
+  }
+
+  @Test
+  public void testGerritReviewCommandWithoutProject() throws Exception {
+    PushOneCommit.Result r = createChange();
+    adminSshSession.exec(
+        "gerrit review"
+            + " --branch "
+            + r.getChange().change().getDest().shortName()
+            + " --code-review 1 "
+            + r.getCommit().getName());
+    adminSshSession.assertSuccess();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
index eb5b9b0..c6b57da 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -19,13 +19,14 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
@@ -35,13 +36,17 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.junit.Test;
 
 public class ChangeSubmitRequirementIT extends AbstractDaemonTest {
-  private static final SubmitRequirement req =
-      SubmitRequirement.builder().setType("custom_rule").setFallbackText("Fallback text").build();
-  private static final SubmitRequirementInfo reqInfo =
-      new SubmitRequirementInfo("NOT_READY", "Fallback text", "custom_rule");
+  private static final LegacySubmitRequirement req =
+      LegacySubmitRequirement.builder()
+          .setType("custom_rule")
+          .setFallbackText("Fallback text")
+          .build();
+  private static final LegacySubmitRequirementInfo reqInfo =
+      new LegacySubmitRequirementInfo("NOT_READY", "Fallback text", "custom_rule");
 
   @Override
   public Module createModule() {
@@ -55,7 +60,7 @@
     };
   }
 
-  @Inject CustomSubmitRule rule;
+  @Inject private CustomSubmitRule rule;
 
   @Test
   public void submitRequirementIsPropagated() throws Exception {
@@ -167,6 +172,35 @@
     assertThat(result.get(0).changeId).isEqualTo(change.info().changeId);
   }
 
+  @Test
+  public void submitRuleIsInvokedOnlyOnceWhenGettingChangeDetails() throws Exception {
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    rule.numberOfEvaluations.set(0);
+    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);
+  }
+
+  @Test
+  public void submitRuleIsNotInvokedWhenQueryingChange() throws Exception {
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    rule.numberOfEvaluations.set(0);
+    gApi.changes()
+        .query(changeId)
+        .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS)
+        .get();
+
+    // Submit rule evaluation results from the change index are reused
+    assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
+  }
+
   private List<ChangeInfo> queryIsSubmittable() throws Exception {
     return gApi.changes().query("is:submittable project:" + project.get()).get();
   }
@@ -186,6 +220,7 @@
   @Singleton
   private static class CustomSubmitRule implements SubmitRule {
     private Optional<SubmitRecord.Status> recordStatus = Optional.empty();
+    private AtomicInteger numberOfEvaluations = new AtomicInteger();
 
     public void block(boolean block) {
       this.status(block ? Optional.of(SubmitRecord.Status.NOT_READY) : Optional.empty());
@@ -197,6 +232,7 @@
 
     @Override
     public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      numberOfEvaluations.incrementAndGet();
       if (this.recordStatus.isPresent()) {
         SubmitRecord record = new SubmitRecord();
         record.labels = new ArrayList<>();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
index 31198d5..cebce0b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
@@ -16,9 +16,6 @@
 
 import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
 import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.extensions.annotations.Exports;
-import com.google.gerrit.server.change.ChangeAttributeFactory;
-import com.google.inject.AbstractModule;
 import java.util.Arrays;
 import org.junit.Test;
 
@@ -27,30 +24,6 @@
   // No tests for /detail via the extension API, since the extension API doesn't have that method.
 
   @Test
-  public void queryChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
-  }
-
-  @Test
-  public void getChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
-  }
-
-  @Test
-  public void queryChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
-  }
-
-  @Test
-  public void getChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
-  }
-
-  @Test
   public void querySingleChangeWithBulkAttribute() throws Exception {
     getSingleChangeWithPluginDefinedBulkAttribute(
         id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()));
@@ -63,22 +36,6 @@
   }
 
   @Test
-  public void queryChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()),
-        (id, opts) ->
-            pluginInfoFromSingletonList(
-                gApi.changes().query(id.toString()).withPluginOptions(opts).get()));
-  }
-
-  @Test
-  public void getChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get()),
-        (id, opts) -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get(opts)));
-  }
-
-  @Test
   public void queryChangeWithOptionBulkAttribute() throws Exception {
     getChangeWithPluginDefinedBulkAttributeOption(
         id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()),
@@ -108,12 +65,6 @@
   }
 
   @Test
-  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
-    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
-  }
-
-  @Test
   public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
     getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
         () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
@@ -124,23 +75,4 @@
     getChangeWithPluginDefinedBulkAttributeWithException(
         id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get())));
   }
-
-  static class SimpleAttributeWithExplicitExportModule extends AbstractModule {
-    @Override
-    public void configure() {
-      bind(ChangeAttributeFactory.class)
-          .annotatedWith(Exports.named("simple"))
-          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
-    }
-  }
-
-  @Test
-  public void getChangeWithSimpleAttributeWithExplicitExport() throws Exception {
-    // For backwards compatibility with old plugins, allow modules to bind into the
-    // DynamicSet<ChangeAttributeFactory> as if it were a DynamicMap. We only need one variant of
-    // this test to prove that the mapping works.
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()),
-        SimpleAttributeWithExplicitExportModule.class);
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 7d73374..c8b1715 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -29,9 +29,17 @@
 import com.google.common.collect.Iterables;
 import com.google.common.truth.Correspondence;
 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.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -44,11 +52,15 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.testing.TestCommentHelper;
@@ -58,6 +70,7 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -69,6 +82,7 @@
   @Inject private CommentValidator mockCommentValidator;
   @Inject private TestCommentHelper testCommentHelper;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   private static final String COMMENT_TEXT = "The comment text";
   private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION =
@@ -447,7 +461,7 @@
     // User adds themselves and changes state
     requestScopeOperations.setApiUser(user.id());
 
-    ReviewInput input = new ReviewInput().label("Code-Review", 1);
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
     gApi.changes().id(r.getChangeId()).current().review(input);
 
     Map<ReviewerState, Collection<AccountInfo>> reviewers =
@@ -474,6 +488,204 @@
     assertThat(reviewer._accountId).isEqualTo(user.id().get());
   }
 
+  @Test
+  public void extendChangeMessageFromPlugin() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    String testMessage = "hello from plugin";
+    TestOnPostReview testOnPostReview = new TestOnPostReview(testMessage);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
+      assertThat(Iterables.getLast(messages).message)
+          .isEqualTo(String.format("Patch Set 1: Code-Review+1\n\n%s\n", testMessage));
+    }
+  }
+
+  @Test
+  public void extendChangeMessageFromMultiplePlugins() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    String testMessage1 = "hello from plugin 1";
+    String testMessage2 = "message from plugin 2";
+    TestOnPostReview testOnPostReview1 = new TestOnPostReview(testMessage1);
+    TestOnPostReview testOnPostReview2 = new TestOnPostReview(testMessage2);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testOnPostReview1).add(testOnPostReview2)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
+      assertThat(Iterables.getLast(messages).message)
+          .isEqualTo(
+              String.format(
+                  "Patch Set 1: Code-Review+1\n\n%s\n\n%s\n", testMessage1, testMessage2));
+    }
+  }
+
+  @Test
+  public void onPostReviewExtensionThatDoesntExtendTheChangeMessage() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
+      assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
+    }
+  }
+
+  @Test
+  public void onPostReviewCallbackGetsCorrectChangeAndPatchSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChange(r.getChangeId());
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+
+      // Vote on current patch set.
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertChangeAndPatchSet(r.getChange().getId(), 2);
+
+      // Vote on old patch set.
+      gApi.changes().id(r.getChangeId()).revision(1).review(input);
+      testOnPostReview.assertChangeAndPatchSet(r.getChange().getId(), 1);
+    }
+  }
+
+  @Test
+  public void onPostReviewCallbackGetsCorrectUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+
+      // Vote from admin.
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertUser(admin);
+
+      // Vote from user.
+      requestScopeOperations.setApiUser(user.id());
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertUser(user);
+    }
+  }
+
+  @Test
+  public void onPostReviewCallbackGetsCorrectApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      // Add a new vote.
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ 0, /* expectedNewValue= */ 1);
+
+      // Update an existing vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ 1, /* expectedNewValue= */ 2);
+
+      // Post without changing the vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ null, /* expectedNewValue= */ 2);
+
+      // Delete the vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 0);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ 2, /* expectedNewValue= */ 0);
+    }
+  }
+
+  @Test
+  public void votingTheSameVoteSecondTime() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    sender.clear();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(r.getChange().approvals().values()).hasSize(1);
+
+    // Post without changing the vote.
+    input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Second vote replaced the original vote, so still only one vote.
+    assertThat(r.getChange().approvals().values()).hasSize(1);
+    List<ChangeMessageInfo> changeMessages = gApi.changes().id(r.getChangeId()).messages();
+
+    // Only the last change message is about Code-Review+2
+    assertThat(Iterables.getLast(changeMessages).message).isEqualTo("Patch Set 1: Code-Review+2");
+    changeMessages.remove(changeMessages.size() - 1);
+    assertThat(Iterables.getLast(changeMessages).message)
+        .isNotEqualTo("Patch Set 1: Code-Review+2");
+
+    // Only one email is about Code-Review +2 was sent.
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains("Patch Set 1: Code-Review+2");
+  }
+
+  @Test
+  public void votingTheSameVoteSecondTimeExtendsOnPostReviewWithOldNullValue() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(r.getChange().approvals().values()).hasSize(1);
+
+    TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
+    try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
+      // Post without changing the vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+
+      testOnPostReview.assertApproval(
+          LabelId.CODE_REVIEW, /* expectedOldValue= */ null, /* expectedNewValue= */ 2);
+    }
+  }
+
+  @Test
+  public void votingTheSameVoteSecondTimeDoesNotFireOnCommentAdded() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(r.getChange().approvals().values()).hasSize(1);
+
+    TestListener testListener = new TestListener();
+    try (Registration registration = extensionRegistry.newRegistration().add(testListener)) {
+      // Post without changing the vote.
+      input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+
+      // Event not fired.
+      assertThat(testListener.lastCommentAddedEvent).isNull();
+    }
+  }
+
+  private static class TestListener implements CommentAddedListener {
+    public CommentAddedListener.Event lastCommentAddedEvent;
+
+    @Override
+    public void onCommentAdded(Event event) {
+      lastCommentAddedEvent = event;
+    }
+  }
+
   private List<RobotCommentInfo> getRobotComments(String changeId) throws RestApiException {
     return gApi.changes().id(changeId).robotComments().values().stream()
         .flatMap(Collection::stream)
@@ -495,4 +707,50 @@
         .comparingElementsUsing(COMMENT_CORRESPONDENCE)
         .containsExactly(commentsForValidation);
   }
+
+  private static class TestOnPostReview implements OnPostReview {
+    private final Optional<String> message;
+
+    private Change.Id changeId;
+    private PatchSet.Id patchSetId;
+    private Account.Id accountId;
+    private Map<String, Short> oldApprovals;
+    private Map<String, Short> approvals;
+
+    TestOnPostReview(@Nullable String message) {
+      this.message = Optional.ofNullable(message);
+    }
+
+    @Override
+    public Optional<String> getChangeMessageAddOn(
+        IdentifiedUser user,
+        ChangeNotes changeNotes,
+        PatchSet patchSet,
+        Map<String, Short> oldApprovals,
+        Map<String, Short> approvals) {
+      this.changeId = changeNotes.getChangeId();
+      this.patchSetId = patchSet.id();
+      this.accountId = user.getAccountId();
+      this.oldApprovals = oldApprovals;
+      this.approvals = approvals;
+      return message;
+    }
+
+    public void assertChangeAndPatchSet(Change.Id expectedChangeId, int expectedPatchSetNum) {
+      assertThat(changeId).isEqualTo(expectedChangeId);
+      assertThat(patchSetId.get()).isEqualTo(expectedPatchSetNum);
+    }
+
+    public void assertUser(TestAccount expectedUser) {
+      assertThat(accountId).isEqualTo(expectedUser.id());
+    }
+
+    public void assertApproval(
+        String labelName, @Nullable Integer expectedOldValue, int expectedNewValue) {
+      assertThat(oldApprovals)
+          .containsExactly(
+              labelName, expectedOldValue != null ? expectedOldValue.shortValue() : null);
+      assertThat(approvals).containsExactly(labelName, (short) expectedNewValue);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index a3a089f..6cf3f3e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -290,8 +290,28 @@
     gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
     RevertInput revertInput = new RevertInput();
     revertInput.message = "Message from input";
-    assertThat(gApi.changes().id(result.getChangeId()).revert(revertInput).get().subject)
-        .isEqualTo(revertInput.message);
+    ChangeInfo revertChange = gApi.changes().id(result.getChangeId()).revert(revertInput).get();
+    assertThat(revertChange.subject).isEqualTo(revertInput.message);
+    assertThat(gApi.changes().id(revertChange.id).current().commit(false).message)
+        .isEqualTo(String.format("Message from input\n\nChange-Id: %s\n", revertChange.changeId));
+  }
+
+  @Test
+  public void revertWithSetMessageChangeIdIgnored() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
+    RevertInput revertInput = new RevertInput();
+    String fakeChangeId = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String commitSubject = "Message from input";
+    revertInput.message = String.format("%s\n\nChange-Id: %s\n", commitSubject, fakeChangeId);
+    ChangeInfo revertChange = gApi.changes().id(result.getChangeId()).revert(revertInput).get();
+    // ChangeId provided in revert input is ignored.
+    assertThat(revertChange.changeId).isNotEqualTo(fakeChangeId);
+    assertThat(revertChange.subject).isEqualTo(commitSubject);
+    // ChangeId footer was replaced in revert commit message.
+    assertThat(gApi.changes().id(revertChange.id).current().commit(false).message)
+        .isEqualTo(String.format("Message from input\n\nChange-Id: %s\n", revertChange.changeId));
   }
 
   @Test
@@ -825,6 +845,38 @@
   }
 
   @Test
+  public void revertSubmissionWithSetMessageChangeIdIgnored() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    String secondResult = createChange("second change", "b.txt", "message").getChangeId();
+    approve(firstResult);
+    approve(secondResult);
+    gApi.changes().id(secondResult).current().submit();
+    RevertInput revertInput = new RevertInput();
+    String fakeChangeId = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String commitSubject = "Message from input";
+    String revertMessage = String.format("%s\n\nChange-Id: %s\n", commitSubject, fakeChangeId);
+    revertInput.message = revertMessage;
+    List<ChangeInfo> revertChanges =
+        gApi.changes().id(firstResult).revertSubmission(revertInput).revertChanges;
+    assertThat(revertChanges.get(0).subject).isEqualTo("Revert \"first change\"");
+    // ChangeId provided in revert input is ignored.
+    assertThat(revertChanges.get(0).changeId).isNotEqualTo(fakeChangeId);
+    assertThat(revertChanges.get(1).changeId).isNotEqualTo(fakeChangeId);
+    // ChangeId footer was replaced in revert commit message.
+    assertThat(gApi.changes().id(revertChanges.get(0).id).current().commit(false).message)
+        .isEqualTo(
+            String.format(
+                "Revert \"first change\"\n\n%s\n\nChange-Id: %s\n",
+                commitSubject, revertChanges.get(0).changeId));
+    assertThat(revertChanges.get(1).subject).isEqualTo("Revert \"second change\"");
+    assertThat(gApi.changes().id(revertChanges.get(1).id).current().commit(false).message)
+        .isEqualTo(
+            String.format(
+                "Revert \"second change\"\n\n%s\n\nChange-Id: %s\n",
+                commitSubject, revertChanges.get(1).changeId));
+  }
+
+  @Test
   public void revertSubmissionWithoutMessage() throws Exception {
     String firstResult = createChange("first change", "a.txt", "message").getChangeId();
     String secondResult = createChange("second change", "b.txt", "message").getChangeId();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 58ea6ea..4e47bb1 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -37,9 +37,14 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+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.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -68,6 +73,7 @@
 public class StickyApprovalsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ChangeOperations changeOperations;
 
   @Inject
   @Named("change_kind")
@@ -80,7 +86,7 @@
       // This way changes to the "Code Review" label don't affect other tests.
       LabelType.Builder codeReview =
           labelBuilder(
-              "Code-Review",
+              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"),
@@ -90,7 +96,8 @@
       u.getConfig().upsertLabelType(codeReview.build());
 
       LabelType.Builder verified =
-          labelBuilder("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
       verified.setCopyAllScoresIfNoChange(false);
       u.getConfig().upsertLabelType(verified.build());
 
@@ -121,7 +128,7 @@
   @Test
   public void stickyOnAnyScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAnyScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
       u.save();
     }
 
@@ -143,7 +150,7 @@
   @Test
   public void stickyOnMinScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMinScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
       u.save();
     }
 
@@ -165,7 +172,7 @@
   @Test
   public void stickyOnMaxScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
       u.save();
     }
 
@@ -191,7 +198,7 @@
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
           .updateLabelType(
-              "Code-Review", b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
+              LabelId.CODE_REVIEW, b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
       u.save();
     }
 
@@ -215,7 +222,8 @@
   @Test
   public void stickyOnTrivialRebase() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresOnTrivialRebase(true));
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
       u.save();
     }
 
@@ -261,7 +269,7 @@
   @Test
   public void stickyOnNoCodeChange() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -286,7 +294,8 @@
   public void stickyOnMergeFirstParentUpdate() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .updateLabelType("Code-Review", b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
       u.save();
     }
 
@@ -310,7 +319,7 @@
   @Test
   public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresIfNoChange(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfNoChange(true));
       u.save();
     }
 
@@ -325,10 +334,169 @@
   }
 
   @Test
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("new file")
+        .content("new content")
+        .create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+
+    // create "existing file" and submit it.
+    String existingFile = "existing file";
+    Change.Id prep =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(existingFile)
+            .content("content")
+            .create();
+    vote(admin, prep.toString(), 2, 1);
+    gApi.changes().id(prep.get()).current().submit();
+
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file(existingFile)
+        .content("new content")
+        .create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed ("existing file" was added to the
+    // change).
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").delete().create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
+    // configured for that label, and list of files didn't change.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // only code review votes are copied since copyAllScoresIfListOfFilesDidNotChange is
+    // configured for that label, and list of files didn't change.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+  }
+
+  @Test
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    vote(admin, changeId.toString(), 2, 1);
+    vote(user, changeId.toString(), -2, -1);
+
+    changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // no votes are copied since the list of files changed (rename).
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
   public void removedVotesNotSticky() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresOnTrivialRebase(true));
-      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -357,8 +525,8 @@
   @Test
   public void stickyAcrossMultiplePatchSets() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -383,8 +551,8 @@
     // change kind against all prior patch sets. This is a regression that made Gerrit do expensive
     // work in O(num-patch-sets). This test ensures that we aren't regressing.
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -415,8 +583,8 @@
   @Test
   public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMinScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
       u.save();
     }
 
@@ -454,7 +622,7 @@
 
   @Test
   public void deleteStickyVote() throws Exception {
-    String label = "Code-Review";
+    String label = LabelId.CODE_REVIEW;
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().updateLabelType(label, b -> b.setCopyMaxScore(true));
       u.save();
@@ -468,10 +636,37 @@
     assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
 
     // Delete vote that was copied via sticky approval
-    deleteVote(admin, changeId, "Code-Review");
+    deleteVote(admin, changeId, label);
     assertVotes(detailedChange(changeId), admin, label, 0, REWORK);
   }
 
+  @Test
+  public void canVoteMultipleTimesOnNewPatchsets() throws Exception {
+    // Code-Review will be sticky.
+    String label = LabelId.CODE_REVIEW;
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
+      u.save();
+    }
+
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Make a new patchset, keeping the Code-Review +2 vote.
+    amendChange(r.getChangeId());
+
+    // Post without changing the vote.
+    input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // There is a vote both on patchset 1 and on patchset 2, although both votes are Code-Review +2.
+    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 1))).hasSize(1);
+    assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 2))).hasSize(1);
+  }
+
   private void assertChangeKindCacheContains(ObjectId prior, ObjectId next) {
     ChangeKind kind =
         changeKindCache.getIfPresent(ChangeKindCacheImpl.Key.create(prior, next, "recursive"));
@@ -592,7 +787,7 @@
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
     RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    ReviewInput in = new ReviewInput().label("Code-Review", 2).label("Verified", 1);
+    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
     revision.review(in);
     revision.submit();
 
@@ -704,7 +899,9 @@
       throws Exception {
     requestScopeOperations.setApiUser(user.id());
     ReviewInput in =
-        new ReviewInput().label("Code-Review", codeReviewVote).label("Verified", verifiedVote);
+        new ReviewInput()
+            .label(LabelId.CODE_REVIEW, codeReviewVote)
+            .label(LabelId.VERIFIED, verifiedVote);
     gApi.changes().id(changeId).current().review(in);
   }
 
@@ -719,8 +916,8 @@
 
   private void assertVotes(
       ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote, ChangeKind changeKind) {
-    assertVotes(c, user, "Code-Review", codeReviewVote, changeKind);
-    assertVotes(c, user, "Verified", verifiedVote, changeKind);
+    assertVotes(c, user, LabelId.CODE_REVIEW, codeReviewVote, changeKind);
+    assertVotes(c, user, LabelId.VERIFIED, verifiedVote, changeKind);
   }
 
   private void assertVotes(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
new file mode 100644
index 0000000..bc9f50a5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.inject.Inject;
+import java.util.List;
+import org.junit.Test;
+
+public class SubmitRuleIT extends AbstractDaemonTest {
+  @Inject private SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+
+  @Test
+  public void submitRecordsForClosedChanges_parsedBackByDefault() throws Exception {
+    SubmitRuleEvaluator submitRuleEvaluator =
+        submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+    List<SubmitRecord> recordsBeforeSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    // Add a new label that blocks submission if not granted. In case we reevaluate the rules,
+    // this would show up as blocking submission.
+    setupCustomBlockingLabel();
+    List<SubmitRecord> recordsAfterSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    recordsBeforeSubmission.forEach(
+        sr -> sr.status = SubmitRecord.Status.CLOSED); // Set status to closed
+    assertThat(recordsBeforeSubmission).isEqualTo(recordsAfterSubmission);
+  }
+
+  @Test
+  public void submitRecordsForClosedChanges_recomputedIfRequested() throws Exception {
+    SubmitRuleEvaluator submitRuleEvaluator =
+        submitRuleEvaluatorFactory.create(
+            SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build());
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+    List<SubmitRecord> recordsBeforeSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    // Add a new label that blocks submission if not granted. In case we reevaluate the rules,
+    // this would show up as blocking submission.
+    setupCustomBlockingLabel();
+    List<SubmitRecord> recordsAfterSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    assertThat(recordsBeforeSubmission).isNotEqualTo(recordsAfterSubmission);
+    assertThat(recordsAfterSubmission).hasSize(1);
+    List<SubmitRecord.Label> recordLabels = recordsAfterSubmission.get(0).labels;
+
+    assertThat(recordLabels).hasSize(2);
+    assertCodeReviewApproved(recordLabels);
+    assertMyLabelNeed(recordLabels);
+  }
+
+  private void assertCodeReviewApproved(List<SubmitRecord.Label> recordLabels) {
+    SubmitRecord.Label haveCodeReview = new SubmitRecord.Label();
+    haveCodeReview.label = "Code-Review";
+    haveCodeReview.status = SubmitRecord.Label.Status.OK;
+    haveCodeReview.appliedBy = admin.id();
+    assertThat(recordLabels).contains(haveCodeReview);
+  }
+
+  private void assertMyLabelNeed(List<SubmitRecord.Label> recordLabels) {
+    SubmitRecord.Label needCustomLabel = new SubmitRecord.Label();
+    needCustomLabel.label = "My-Label";
+    needCustomLabel.status = SubmitRecord.Label.Status.NEED;
+    assertThat(recordLabels).contains(needCustomLabel);
+  }
+
+  private void setupCustomBlockingLabel() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .upsertLabelType(
+              LabelType.builder(
+                      "My-Label",
+                      ImmutableList.of(
+                          LabelValue.create((short) 0, "Not approved"),
+                          LabelValue.create((short) 1, "Approved")))
+                  .setFunction(LabelFunction.MAX_WITH_BLOCK)
+                  .build());
+      u.save();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
new file mode 100644
index 0000000..5ca7310
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -0,0 +1,399 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.inject.Inject;
+import java.util.Iterator;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SubmitWithStickyApprovalDiffIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private ChangeOperations changeOperations;
+
+  @Before
+  public void setup() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      // Overwrite "Code-Review" label that is inherited from All-Projects.
+      // This way changes to the "Code Review" label don't affect other tests.
+      // Also make the vote sticky.
+      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 that you didn't submit this"),
+              value(-2, "Do not submit"));
+      codeReview.setCopyAnyScore(true);
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_modifiedFileWithReplaces() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\naa\nsF\naa\naaa\nsomething\nfoo\nbla\ndeletedEnd")
+            .create();
+
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("content\naa\nsS\naa\naaa\ndifferent\nfoo\nbla")
+        .create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 3,
+        /* deletions= */ 4,
+        /* edits= */ ImmutableList.of(
+            Edit.create(2, 3, 2, 3), Edit.create(5, 6, 5, 6), Edit.create(7, 9, 7, 8)),
+        /* previousLines= */ ImmutableList.of(
+            "-  sF\n", "-  something\n", "-  bla\n-  " + "deletedEnd\n"),
+        /* newLines= */ ImmutableList.of("+  sS\n", "+  different\n", "+  bla\n"),
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_modifiedFileWithInsertionAndDeletion()
+      throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\naa\nbb\ncc" + "\ndd\nee\nff\nTODELETE1\nTODELETE2\ngg\nend")
+            .create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("content\naa\nbb\ncc\nINSERTION\nINSERTED\nVERY\nLONG\ndd\nee\nff\ngg\nend")
+        .create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 4,
+        /* deletions= */ 2,
+        /* edits= */ ImmutableList.of(Edit.create(4, 4, 4, 8), Edit.create(7, 9, 7, 7)),
+        /* previousLines= */ ImmutableList.of("-  TODELETE1\n-  TODELETE2\n"),
+        /* newLines= */ ImmutableList.of("+  INSERTION\n+  INSERTED\n+  VERY\n+  LONG\n"),
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
+  public void autoGeneratedPostSubmitDiffIsNotPartOfTheCommentSizeLimit() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    String content = new String(new char[800]).replace("\0", "a");
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post a submit diff that is almost the cumulativeCommentSizeLimit
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .doesNotContain("many unreviewed changes");
+
+    // unrelated comment and change message posting works fine, since the post submit diff is not
+    // counted towards the cumulativeCommentSizeLimit for unrelated follow-up comments.
+    // 800 + 400 + 400 > 1k, but 400 + 400 < 1k, hence these comments are accepted (the original
+    // 800 is not counted).
+    String message = new String(new char[400]).replace("\0", "a");
+    ReviewInput reviewInput = new ReviewInput().message(message);
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.message = message;
+    commentInput.path = "file";
+    reviewInput.comments = ImmutableMap.of("file", ImmutableList.of(commentInput));
+
+    gApi.changes().id(changeId.get()).current().review(reviewInput);
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
+  public void postSubmitDiffCannotBeTooBig() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    String content = new String(new char[1100]).replace("\0", "a");
+
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post submit diff is over the cumulativeCommentSizeLimit, so we shorten the message.
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .isEqualTo(
+            "Change has been successfully merged\n\n1 is the latest approved patch-set.\nThe "
+                + "change was submitted "
+                + "with many unreviewed changes (the diff is too large to show). Please review the "
+                + "diff.");
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_addedFile() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("content\nmore content\nlast content")
+        .create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 3,
+        /* deletions= */ 0,
+        /* edits= */ ImmutableList.of(Edit.create(0, 0, 0, 3)),
+        /* previousLines= */ ImmutableList.of(),
+        /* newLines= */ ImmutableList.of("+  content\n+  more content\n+  last content\n"),
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_removedFile() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\nmore content\nlast content")
+            .create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations.change(changeId).newPatchset().file("file").delete().create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 0,
+        /* deletions= */ 3,
+        /* edits= */ ImmutableList.of(Edit.create(0, 3, 0, 0)),
+        /* previousLines= */ ImmutableList.of("-  content\n-  more content\n-  last content\n"),
+        /* newLines= */ ImmutableList.of(),
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_renamedFile() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\nmoreContent")
+            .create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "new_file",
+        /* insertions= */ 0,
+        /* deletions= */ 0,
+        /* edits= */ ImmutableList.of(),
+        /* previousLines= */ ImmutableList.of(),
+        /* newLines= */ ImmutableList.of(),
+        /* oldFileName= */ "file");
+  }
+
+  @Test
+  public void noDiffChangeMessageOnSubmitWhenVotedOnLastPatchset() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content\nmoreContent")
+            .create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations.change(changeId).newPatchset().file("file").renameTo("new_file").create();
+
+    // Approve last patch-set again, although there is already a +2 on the change (since it's
+    // sticky).
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message.trim())
+        .isEqualTo("Change has been successfully merged");
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_approvedPatchset() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    changeOperations.change(changeId).newPatchset().create();
+
+    // approve patch-set 2
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    // create patch-set 3
+    changeOperations.change(changeId).newPatchset().create();
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    // patch-set 2 was the latest approved one.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .contains("2 is the latest approved patch-set.");
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_noChanges() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    // no file changed
+    changeOperations.change(changeId).newPatchset().create();
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    // No other content in the message since the diff is the same.
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .isEqualTo(
+            "Change has been successfully merged\n\n1 is the latest approved patch-set.\n"
+                + "No files were changed between the latest approved patch-set and the submitted"
+                + " one.\n");
+  }
+
+  private void assertDiffChangeMessageAndEmailWithStickyApproval(
+      String message,
+      String file,
+      int insertions,
+      int deletions,
+      List<Edit> edits,
+      List<String> previousLines,
+      List<String> newLines,
+      String oldFileName) {
+    String expectedMessage =
+        "1 is the latest approved patch-set.\n"
+            + "The change was submitted with unreviewed changes in the following files:\n"
+            + "\n"
+            + String.format("The name of the file: %s\n", file)
+            + String.format("Insertions: %d, Deletions: %d.\n\n", insertions, deletions);
+
+    if (oldFileName != null) {
+      expectedMessage += String.format("The file %s was renamed to %s\n", oldFileName, file);
+    }
+
+    Iterator<String> previousLinesIterator = previousLines.iterator();
+    Iterator<String> newLinesIterator = newLines.iterator();
+    if (!edits.isEmpty()) {
+      expectedMessage += "```\n";
+    }
+    for (Edit edit : edits) {
+      if (edit.beginA() == edit.endA()) {
+        // Insertion
+        expectedMessage += String.format("@@ +%d:%d @@\n", edit.beginB(), edit.endB());
+        expectedMessage += newLinesIterator.next();
+        expectedMessage += "\n";
+        continue;
+      }
+      if (edit.beginB() == edit.endB()) {
+        // Deletion
+        expectedMessage += String.format("@@ -%d:%d @@\n", edit.beginA(), edit.endA());
+        expectedMessage += previousLinesIterator.next();
+        expectedMessage += "\n";
+        continue;
+      }
+      // Replace
+      expectedMessage +=
+          String.format(
+              "@@ -%d:%d, +%d:%d @@\n", edit.beginA(), edit.endA(), edit.beginB(), edit.endB());
+      expectedMessage += previousLinesIterator.next();
+      expectedMessage += newLinesIterator.next();
+      expectedMessage += "\n";
+    }
+    if (!edits.isEmpty()) {
+      expectedMessage += "```\n";
+    }
+    String expectedChangeMessage = "Change has been successfully merged\n\n" + expectedMessage;
+    assertThat(message.trim()).isEqualTo(expectedChangeMessage.trim());
+    assertThat(Iterables.getLast(sender.getMessages()).body()).contains(expectedMessage);
+    assertThat(Iterables.getLast(sender.getMessages()).htmlBody()).contains(expectedMessage);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/BUILD b/javatests/com/google/gerrit/acceptance/api/group/BUILD
index 1ba1138..8530a92 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/group/BUILD
@@ -7,6 +7,7 @@
     labels = ["api"],
     deps = [
         ":util",
+        "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/server/group/db/testing",
         "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/util/time",
@@ -19,6 +20,7 @@
     name = "util",
     srcs = ["GroupAssert.java"],
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java b/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
index dd891ce..079d43e9 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
@@ -17,9 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.server.group.InternalGroup;
 import java.util.Set;
 
 public class GroupAssert {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
index 41ae370..87bdee4 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -21,13 +21,13 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.group.testing.InternalGroupSubject;
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 8dbec28..a277f26 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -54,11 +54,14 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.auth.ldap.FakeLdapGroupBackend;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -85,8 +88,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.account.GroupsSnapshotReader;
-import com.google.gerrit.server.auth.ldap.FakeLdapGroupBackend;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.group.db.Groups;
@@ -916,7 +918,9 @@
   @Test
   public void defaultGroupsCreated() throws Exception {
     Iterable<String> names = gApi.groups().list().getAsMap().keySet();
-    assertThat(names).containsAtLeast("Administrators", "Service Users").inOrder();
+    assertThat(names)
+        .containsAtLeast("Administrators", ServiceUserClassifier.SERVICE_USERS)
+        .inOrder();
   }
 
   @Test
@@ -1547,7 +1551,7 @@
         .project(project)
         .forUpdate()
         .add(
-            allowLabel("Code-Review")
+            allowLabel(LabelId.CODE_REVIEW)
                 .ref(RefNames.REFS_GROUPS + "*")
                 .group(REGISTERED_USERS)
                 .range(-2, 2))
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index 6838f8d..18eca27 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -48,18 +48,14 @@
 @NoHttpd
 public class PluginIT extends AbstractDaemonTest {
   private static final String JS_PLUGIN = "Gerrit.install(function(self){});\n";
-  private static final String HTML_PLUGIN =
-      String.format("<dom-module id=\"test\"><script>%s</script></dom-module>", JS_PLUGIN);
   private static final RawInput JS_PLUGIN_CONTENT = RawInputUtil.create(JS_PLUGIN.getBytes(UTF_8));
-  private static final RawInput HTML_PLUGIN_CONTENT =
-      RawInputUtil.create(HTML_PLUGIN.getBytes(UTF_8));
 
   private static final ImmutableList<String> PLUGINS =
       ImmutableList.of(
           "plugin-a.js",
-          "plugin-b.html",
+          "plugin-b.js",
           "plugin-c.js",
-          "plugin-d.html",
+          "plugin-d.js",
           "plugin-normal.jar",
           "plugin-empty.jar",
           "plugin-unset.jar",
@@ -97,7 +93,7 @@
     assertPlugins(list().start(1).limit(2).get(), PLUGINS.subList(1, 3));
 
     // With prefix
-    assertPlugins(list().prefix("plugin-b").get(), ImmutableList.of("plugin-b.html"));
+    assertPlugins(list().prefix("plugin-b").get(), ImmutableList.of("plugin-b.js"));
     assertPlugins(list().prefix("PLUGIN-").get(), ImmutableList.of());
 
     // With substring
@@ -105,7 +101,7 @@
     assertPlugins(list().substring("lugin-").start(1).limit(2).get(), PLUGINS.subList(1, 3));
 
     // With regex
-    assertPlugins(list().regex(".*in-b").get(), ImmutableList.of("plugin-b.html"));
+    assertPlugins(list().regex(".*in-b").get(), ImmutableList.of("plugin-b.js"));
     assertPlugins(list().regex("plugin-.*").get(), PLUGINS.subList(0, PLUGINS.size() - 1));
     assertPlugins(list().regex("plugin-.*").start(1).limit(2).get(), PLUGINS.subList(1, 3));
 
@@ -147,7 +143,7 @@
     com.google.gerrit.extensions.common.InstallPluginInput input =
         new com.google.gerrit.extensions.common.InstallPluginInput();
     input.raw = JS_PLUGIN_CONTENT;
-    gApi.plugins().install("legacy.html", input);
+    gApi.plugins().install("legacy.js", input);
     gApi.plugins().name("legacy").get();
   }
 
@@ -198,9 +194,6 @@
     if (plugin.endsWith(".js")) {
       return JS_PLUGIN_CONTENT;
     }
-    if (plugin.endsWith(".html")) {
-      return HTML_PLUGIN_CONTENT;
-    }
     assertThat(plugin).endsWith(".jar");
     return pluginJarContent(plugin);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index f1d537f..45a895a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -19,14 +19,17 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
@@ -36,9 +39,11 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import java.util.List;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
@@ -48,6 +53,7 @@
 public class CheckAccessIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private GroupOperations groupOperations;
+  @Inject private AllProjectsName allProjectsName;
 
   private Project.NameKey normalProject;
   private Project.NameKey secretProject;
@@ -162,28 +168,37 @@
     String project;
     String permission;
     int want;
+    List<String> expectedDebugLogs;
 
-    static TestCase project(String mail, String project, int want) {
+    static TestCase project(String mail, String project, int want, List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
 
-    static TestCase projectRef(String mail, String project, String ref, int want) {
+    static TestCase projectRef(
+        String mail, String project, String ref, int want, List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
       t.input.ref = ref;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
 
     static TestCase projectRefPerm(
-        String mail, String project, String ref, String permission, int want) {
+        String mail,
+        String project,
+        String ref,
+        String permission,
+        int want,
+        List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
@@ -191,6 +206,7 @@
       t.input.permission = permission;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
   }
@@ -212,32 +228,115 @@
   public void accessible() throws Exception {
     List<TestCase> inputs =
         ImmutableList.of(
+            // Test 1
             TestCase.projectRefPerm(
                 user.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.VIEW_PRIVATE_CHANGES,
-                403),
-            TestCase.project(user.email(), normalProject.get(), 200),
-            TestCase.project(user.email(), secretProject.get(), 403),
+                403,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'user' cannot perform 'viewPrivateChanges' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")),
+            // Test 2
+            TestCase.project(
+                user.email(),
+                normalProject.get(),
+                200,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'")),
+            // Test 3
+            TestCase.project(
+                user.email(),
+                secretProject.get(),
+                403,
+                ImmutableList.of(
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/heads/*' because this permission is blocked",
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/meta/version' because this permission is blocked")),
+            // Test 4
             TestCase.projectRef(
-                user.email(), secretRefProject.get(), "refs/heads/secret/master", 403),
+                user.email(),
+                secretRefProject.get(),
+                "refs/heads/secret/master",
+                403,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/secret/master' because this permission is blocked")),
+            // Test 5
             TestCase.projectRef(
-                privilegedUser.email(), secretRefProject.get(), "refs/heads/secret/master", 200),
-            TestCase.projectRef(privilegedUser.email(), normalProject.get(), null, 200),
-            TestCase.projectRef(privilegedUser.email(), secretProject.get(), null, 200),
+                privilegedUser.email(),
+                secretRefProject.get(),
+                "refs/heads/secret/master",
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/secret/master'")),
+            // Test 6
+            TestCase.projectRef(
+                privilegedUser.email(),
+                normalProject.get(),
+                null,
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'")),
+            // Test 7
+            TestCase.projectRef(
+                privilegedUser.email(),
+                secretProject.get(),
+                null,
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/*'")),
+            // Test 8
             TestCase.projectRefPerm(
                 privilegedUser.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.VIEW_PRIVATE_CHANGES,
-                200),
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'privilegedUser' can perform 'viewPrivateChanges' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")),
+            // Test 9
             TestCase.projectRefPerm(
                 privilegedUser.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.FORGE_SERVER,
-                200));
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'privilegedUser' can perform 'forgeServerAsCommitter' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")));
 
     for (TestCase tc : inputs) {
       String in = newGson().toJson(tc.input);
@@ -273,6 +372,14 @@
         default:
           assertWithMessage(String.format("unknown code %d", want)).fail();
       }
+
+      if (!info.debugLogs.equals(tc.expectedDebugLogs)) {
+        assertWithMessage(
+                String.format(
+                    "check.access(%s, %s) = %s, want %s",
+                    tc.project, in, info.debugLogs, tc.expectedDebugLogs))
+            .fail();
+      }
     }
   }
 
@@ -290,4 +397,34 @@
     assertThat(info.status).isEqualTo(200);
     assertThat(info.message).contains("no branches");
   }
+
+  @Test
+  @Sandboxed
+  public void noRules() throws Exception {
+    normalProject = projectOperations.newProject().create();
+
+    for (AccessSection section :
+        projectOperations.project(allProjectsName).getProjectConfig().getAccessSections()) {
+      if (!section.getName().startsWith(Constants.R_REFS)) {
+        continue;
+      }
+      for (Permission permission : section.getPermissions()) {
+        projectOperations
+            .project(allProjectsName)
+            .forUpdate()
+            .remove(permissionKey(permission.getName()).ref(section.getName()).build())
+            .update();
+      }
+    }
+    AccessCheckInput input = new AccessCheckInput();
+    input.account = privilegedUser.email();
+    input.permission = Permission.READ;
+    input.ref = "refs/heads/main";
+
+    AccessCheckInfo info = gApi.projects().name(normalProject.get()).checkAccess(input);
+    assertThat(info.status).isEqualTo(403);
+
+    assertThat(info.debugLogs).isNotEmpty();
+    assertThat(info.debugLogs.get(0)).contains("Found no rules");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index 3b5ac78..18e192d 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
@@ -41,6 +42,7 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.inject.Inject;
 import java.util.Iterator;
 import java.util.List;
@@ -382,6 +384,41 @@
   }
 
   @Test
+  public void cherryPickCommitWithChangeIdToClosedChange() throws Exception {
+    String destBranch = "refs/heads/foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    PushOneCommit.Result r = createChange("refs/for/" + destBranch);
+    ChangeInfo existingDestChange = info(r.getChangeId());
+    String commitToCherryPick = createChange().getCommit().getName();
+
+    gApi.changes().id(existingDestChange.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(existingDestChange.changeId).current().submit();
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.message =
+        String.format("it goes to foo branch\n\nChange-Id: %s\n", existingDestChange.changeId);
+    input.allowConflicts = true;
+    input.allowEmpty = true;
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).commit(commitToCherryPick).cherryPick(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Cherry-pick with Change-Id %s could not update the existing change %d in "
+                    + "destination branch %s of project %s, because the change was closed (MERGED)",
+                existingDestChange.changeId,
+                existingDestChange._number,
+                destBranch,
+                project.get()));
+  }
+
+  @Test
   public void cherryPickCommitWithSetTopic() throws Exception {
     String branch = "foo";
     RevCommit revCommit = createChange().getCommit();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 2847f64..0f51095 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -115,7 +116,7 @@
         extensionRegistry.newRegistration().add(projectIndexedCounter)) {
       String name = name("foo");
       assertThat(gApi.projects().create(name).get().name).isEqualTo(name);
-
+      assertHead(name, "refs/heads/master");
       RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
       eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
 
@@ -125,6 +126,23 @@
   }
 
   @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "main")
+  public void createProject_WhenDefaultBranchIsSetInConfig() throws Exception {
+    ProjectIndexedCounter projectIndexedCounter = new ProjectIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectIndexedCounter)) {
+      String name = name("foo");
+      assertThat(gApi.projects().create(name).get().name).isEqualTo(name);
+      assertHead(name, "refs/heads/main");
+      RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
+      eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
+
+      eventRecorder.assertRefUpdatedEvents(name, "refs/heads/main", new String[] {});
+      projectIndexedCounter.assertReindexOf(name);
+    }
+  }
+
+  @Test
   public void createProjectWithInitialBranches() throws Exception {
     ProjectIndexedCounter projectIndexedCounter = new ProjectIndexedCounter();
     try (Registration registration =
@@ -134,12 +152,13 @@
       ProjectInput input = new ProjectInput();
       input.name = name;
       input.createEmptyCommit = true;
-      input.branches = ImmutableList.of("master", "foo");
+      input.branches = ImmutableList.of("foo", "master");
       assertThat(gApi.projects().create(input).get().name).isEqualTo(name);
       assertThat(
               gApi.projects().name(name).branches().get().stream().map(b -> b.ref).collect(toSet()))
           .containsExactly("refs/heads/foo", "refs/heads/master", "HEAD", RefNames.REFS_CONFIG);
 
+      assertHead(name, "refs/heads/foo");
       RevCommit head = getRemoteHead(name, RefNames.REFS_CONFIG);
       eventRecorder.assertRefUpdatedEvents(name, RefNames.REFS_CONFIG, null, head);
 
@@ -882,7 +901,7 @@
     cfg.fromText(projectOperations.project(allProjects).getConfig().toText());
     cfg.setStringList(
         "label",
-        "Code-Review",
+        LabelId.CODE_REVIEW,
         "value",
         ImmutableList.of("+1 LGTM", "1 LGTM", "0 No Value", "-1 Looks Bad"));
 
@@ -909,7 +928,7 @@
             cfg ->
                 cfg.setStringList(
                     "label",
-                    "Code-Review",
+                    LabelId.CODE_REVIEW,
                     "value",
                     ImmutableList.of("+1 LGTM", "1 LGTM", "0 No Value", "-1 Looks Bad")))
         .invalidate();
@@ -917,8 +936,8 @@
     // Verify that project info can be retrieved and that the label value "+1 LGTM" appears only
     // once.
     ProjectInfo projectInfo = gApi.projects().name(allProjects.get()).get();
-    assertThat(projectInfo.labels.keySet()).containsExactly("Code-Review");
-    assertThat(projectInfo.labels.get("Code-Review").values)
+    assertThat(projectInfo.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(projectInfo.labels.get(LabelId.CODE_REVIEW).values)
         .containsExactly("+1", "LGTM", " 0", "No Value", "-1", "Looks Bad");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/BUILD b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
index 3bfe2f0..517b041 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
@@ -4,4 +4,12 @@
     srcs = [f],
     group = f[:f.index(".")],
     labels = ["api"],
+    deps = [":revision-diff-it"],
 ) for f in glob(["*IT.java"])]
+
+# This is needed because RevisionDiffIT has subclasses that depend on it
+java_library(
+    name = "revision-diff-it",
+    srcs = ["RevisionDiffIT.java"],
+    deps = ["//java/com/google/gerrit/acceptance:lib"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index d3d8457..7197425 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -20,6 +20,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThatList;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
 import com.google.common.collect.ImmutableList;
@@ -37,6 +38,7 @@
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
@@ -496,6 +498,25 @@
   }
 
   @Test
+  public void anonymousUsersGetAuthExceptionForPortedDrafts() throws Exception {
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchsetId = changeOps.change(changeId).currentPatchset().get().patchsetId();
+
+    requestScopeOps.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(patchsetId.changeId().get())
+                    .revision(patchsetId.get())
+                    .portedDrafts());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("requires authentication; only authenticated users can have drafts");
+  }
+
+  @Test
   public void portedDraftCommentHasNoAuthor() throws Exception {
     // Set up change and patchsets.
     Account.Id authorId = accountOps.newAccount().create();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 2976d78..021a719 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -30,6 +30,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.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
@@ -40,9 +42,11 @@
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.extensions.webui.FileWebLink;
+import com.google.inject.Inject;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -55,7 +59,6 @@
 import java.util.function.Function;
 import java.util.stream.IntStream;
 import javax.imageio.ImageIO;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -66,7 +69,8 @@
 public class RevisionDiffIT extends AbstractDaemonTest {
   // @RunWith(Parameterized.class) can't be used as AbstractDaemonTest is annotated with another
   // runner. Using different configs is a workaround to achieve the same.
-  private static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+  protected static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+
   private static final String CURRENT = "current";
   private static final String FILE_NAME = "some_file.txt";
   private static final String FILE_NAME2 = "another_file.txt";
@@ -76,18 +80,16 @@
           .collect(joining());
   private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
 
+  @Inject private ExtensionRegistry extensionRegistry;
+
   private boolean intraline;
+  private boolean useNewDiffCacheListFiles;
+  private boolean useNewDiffCacheGetDiff;
+
   private ObjectId commit1;
   private String changeId;
   private String initialPatchSetId;
 
-  @ConfigSuite.Config
-  public static Config intralineConfig() {
-    Config config = new Config();
-    config.setBoolean(TEST_PARAMETER_MARKER, null, "intraline", true);
-    return config;
-  }
-
   @Before
   public void setUp() throws Exception {
     // Reduce flakiness of tests. (If tests aren't fast enough, we would use a fall-back
@@ -96,6 +98,10 @@
     baseConfig.setString("cache", "diff_intraline", "timeout", "1 minute");
 
     intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
+    useNewDiffCacheListFiles =
+        baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_ListFiles", false);
+    useNewDiffCacheGetDiff =
+        baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
     ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
     commit1 =
@@ -118,6 +124,12 @@
     assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
   }
 
+  @Ignore
+  @Test
+  public void diffWithRootCommit() throws Exception {
+    // TODO(ghareeb): Implement this test
+  }
+
   @Test
   public void patchsetLevelFileDiffIsEmpty() throws Exception {
     PushOneCommit.Result result = createChange();
@@ -137,6 +149,26 @@
   }
 
   @Test
+  public void gitwebFileWebLinkIncludedInDiff() throws Exception {
+    try (Registration registration = newGitwebFileWebLink()) {
+      String fileName = "foo.txt";
+      String fileContent = "bar\n";
+      PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+      DiffInfo info =
+          gApi.changes()
+              .id(result.getChangeId())
+              .revision(result.getCommit().name())
+              .file(fileName)
+              .diff();
+      assertThat(info.metaB.webLinks).hasSize(1);
+      assertThat(info.metaB.webLinks.get(0).url)
+          .isEqualTo(
+              String.format(
+                  "http://gitweb/?p=%s;hb=%s;f=%s", project, result.getCommit().name(), fileName));
+    }
+  }
+
+  @Test
   public void deletedFileIsIncludedInDiff() throws Exception {
     gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
     gApi.changes().id(changeId).edit().publish();
@@ -446,6 +478,57 @@
   }
 
   @Test
+  public void diffWithThreeParentsMergeCommitChange() throws Exception {
+    // Create a merge commit of 3 files: foo, bar, baz. The merge commit is pointing to 3 different
+    // parents: the merge commit contains foo of parent1, bar of parent2 and baz of parent3.
+    PushOneCommit.Result r =
+        createNParentsMergeCommitChange("refs/for/master", ImmutableList.of("foo", "bar", "baz"));
+
+    DiffInfo diff;
+
+    // parent 1
+    Map<String, FileInfo> changedFiles = gApi.changes().id(r.getChangeId()).current().files(1);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "bar", "baz");
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(1).get();
+    assertThat(diff.diffHeader).isNull();
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(1).get();
+    assertThat(diff.diffHeader).hasSize(4);
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(1).get();
+    assertThat(diff.diffHeader).hasSize(4);
+
+    // parent 2
+    changedFiles = gApi.changes().id(r.getChangeId()).current().files(2);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "baz");
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(2).get();
+    assertThat(diff.diffHeader).hasSize(4);
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(2).get();
+    assertThat(diff.diffHeader).isNull();
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(2).get();
+    assertThat(diff.diffHeader).hasSize(4);
+
+    // parent 3
+    changedFiles = gApi.changes().id(r.getChangeId()).current().files(3);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "bar");
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(3).get();
+    assertThat(diff.diffHeader).hasSize(4);
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(3).get();
+    assertThat(diff.diffHeader).hasSize(4);
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(3).get();
+    assertThat(diff.diffHeader).isNull();
+  }
+
+  @Test
+  public void diffWithThreeParentsMergeCommitAgainstAutoMergeReturnsCommitMsgAndMergeListOnly()
+      throws Exception {
+    PushOneCommit.Result r =
+        createNParentsMergeCommitChange("refs/for/master", ImmutableList.of("foo", "bar", "baz"));
+
+    // Diff against auto-merge returns COMMIT_MSG and MERGE_LIST only
+    Map<String, FileInfo> changedFiles = gApi.changes().id(r.getChangeId()).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST);
+  }
+
+  @Test
   public void diffBetweenPatchSetsOfMergeCommitCanBeRetrievedForCommitMessageAndMergeList()
       throws Exception {
     PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
@@ -464,6 +547,32 @@
   }
 
   @Test
+  public void diffAgainstAutoMergeCanBeRetrievedForCommitMessageAndMergeList() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
+    String changeId = result.getChangeId();
+    addModifiedPatchSet(changeId, "my_file.txt", content -> content.concat("Line I\nLine II\n"));
+
+    DiffInfo commitMessageDiffInfo =
+        getDiffRequest(changeId, CURRENT, COMMIT_MSG)
+            .get(); // diff latest PS against base (auto-merge)
+    DiffInfo mergeListDiffInfo =
+        getDiffRequest(changeId, CURRENT, MERGE_LIST)
+            .get(); // diff latest PS against base (auto-merge)
+
+    assertThat(commitMessageDiffInfo).changeType().isEqualTo(ChangeType.ADDED);
+    assertThat(commitMessageDiffInfo).content().hasSize(1);
+
+    assertThat(mergeListDiffInfo).changeType().isEqualTo(ChangeType.ADDED);
+    assertThat(mergeListDiffInfo).content().hasSize(1);
+    assertThat(mergeListDiffInfo)
+        .content()
+        .element(0)
+        .linesOfB()
+        .element(0)
+        .isEqualTo("Merge List:");
+  }
+
+  @Test
   public void diffOfUnmodifiedFileMarksAllLinesAsCommon() throws Exception {
     String filePath = "a_new_file.txt";
     String fileContent = "Line 1\nLine 2\nLine 3\n";
@@ -874,6 +983,37 @@
   }
 
   @Test
+  public void intralineEditsAreIdentified() throws Exception {
+    // TODO(ghareeb): This test asserts the wrong behavior due to the following issue
+    // bugs.chromium.org/p/gerrit/issues/detail?id=13563
+    // Please remove this comment and assert the correct behavior when the bug is fixed.
+
+    assume().that(intraline).isTrue();
+
+    String orig = "[-9999,9999]";
+    String replace = "[-999,999]";
+
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat(orig));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace(orig, replace));
+
+    // TODO(ghareeb): remove this comment when the issue is fixed.
+    // The returned diff incorrectly contains:
+    // replace [-9999{,99}99] with [-999{,}999].
+    // If this replace edit is done, the resulting string incorrectly becomes [-9999,99].
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+
+    List<List<Integer>> editsA = diffInfo.content.get(1).editA;
+    List<List<Integer>> editsB = diffInfo.content.get(1).editB;
+    String reconstructed = transformStringUsingEditList(orig, replace, editsA, editsB);
+
+    // TODO(ghareeb): assert equals when the issue is fixed.
+    assertThat(reconstructed).isNotEqualTo(replace);
+  }
+
+  @Test
   public void intralineEditsForModifiedLastLineArePreservedWhenNewlineIsAlsoAddedAtEnd()
       throws Exception {
     assume().that(intraline).isTrue();
@@ -1189,6 +1329,9 @@
   @Test
   public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase_WhenEquallyModifiedInBoth()
       throws Exception {
+    // TODO(ghareeb): fix this test for the new diff cache implementation
+    assume().that(useNewDiffCacheListFiles).isFalse();
+
     Function<String, String> contentModification =
         fileContent -> fileContent.replace("1st line\n", "First line\n");
     addModifiedPatchSet(changeId, FILE_NAME2, contentModification);
@@ -1279,6 +1422,9 @@
 
   @Test
   public void filesTouchedByPatchSetsAndContainingOnlyRebaseHunksAreIgnored() throws Exception {
+    // TODO(ghareeb): fix this test for the new diff cache implementation
+    assume().that(useNewDiffCacheListFiles).isFalse();
+
     addModifiedPatchSet(
         changeId, FILE_NAME, fileContent -> fileContent.replace("Line 50\n", "Line fifty\n"));
     addModifiedPatchSet(
@@ -2657,6 +2803,58 @@
   }
 
   @Test
+  public void symlinkConvertedToRegularFileIsIdentifiedAsAdded() throws Exception {
+    // TODO(ghareeb): fix this test for the new diff cache implementation
+    assume().that(useNewDiffCacheListFiles).isFalse();
+    assume().that(useNewDiffCacheGetDiff).isFalse();
+
+    String target = "file.txt";
+    String symlink = "link.lnk";
+
+    // Create a change adding file "FileName" and a symlink "symLink" pointing to the file
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit Subject", target, "content")
+            .addSymlink(symlink, target);
+
+    PushOneCommit.Result result = push.to("refs/for/master");
+    String initialRev = gApi.changes().id(result.getChangeId()).get().currentRevision;
+
+    // Delete the symlink with patchset 2
+    gApi.changes().id(result.getChangeId()).edit().deleteFile(symlink);
+    gApi.changes().id(result.getChangeId()).edit().publish();
+
+    // Re-add the symlink as a regular file with patchset 3
+    gApi.changes()
+        .id(result.getChangeId())
+        .edit()
+        .modifyFile(symlink, RawInputUtil.create("Content of the new file named 'symlink'"));
+    gApi.changes().id(result.getChangeId()).edit().publish();
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(result.getChangeId()).current().files(initialRev);
+
+    assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", symlink);
+    assertThat(changedFiles.get(symlink).status).isEqualTo('W'); // Rewrite
+
+    DiffInfo diffInfo =
+        gApi.changes().id(result.getChangeId()).current().file(symlink).diff(initialRev);
+
+    // The diff logic identifies two entries for the file:
+    // 1. One entry as 'DELETED' for the symlink.
+    // 2. Another entry as 'ADDED' for the new regular file.
+    // Since the diff logic returns a single entry, we prioritize returning the 'ADDED' entry in
+    // this case so that the client is able to see the new content that was added to the file.
+    assertThat(diffInfo.changeType).isEqualTo(ChangeType.ADDED);
+    assertThat(diffInfo.content).hasSize(1);
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .linesOfB()
+        .containsExactly("Content of the new file named 'symlink'");
+  }
+
+  @Test
   public void diffOfNonExistentFileIsAnEmptyDiffResult() throws Exception {
     addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
 
@@ -2704,6 +2902,21 @@
     assertThat(e).hasMessageThat().isEqualTo("edit not allowed as base");
   }
 
+  private Registration newGitwebFileWebLink() {
+    FileWebLink fileWebLink =
+        new FileWebLink() {
+          @Override
+          public WebLinkInfo getFileWebLink(
+              String projectName, String revision, String hash, String fileName) {
+            return new WebLinkInfo(
+                "name",
+                "imageURL",
+                String.format("http://gitweb/?p=%s;hb=%s;f=%s", projectName, hash, fileName));
+          }
+        };
+    return extensionRegistry.newRegistration().add(fileWebLink);
+  }
+
   private String updatedCommitMessage() {
     return "An unchanged patchset\n\nChange-Id: " + changeId;
   }
@@ -2843,4 +3056,29 @@
         .diffRequest()
         .withIntraline(intraline);
   }
+
+  /**
+   * This method transforms the {@code orig} input String using the list of replace edits {@code
+   * editsA}, {@code editsB} and the resulting {@code replace} String. This method currently assumes
+   * that all input edits are replace edits, and that the edits are sorted according to their
+   * indices.
+   *
+   * @return The transformed String after applying the list of replace edits to the original String.
+   */
+  private String transformStringUsingEditList(
+      String orig, String replace, List<List<Integer>> editsA, List<List<Integer>> editsB) {
+    assertThat(editsA).hasSize(editsB.size());
+    StringBuilder process = new StringBuilder(orig);
+    // The edits are processed right to left to avoid recomputation of indices when characters
+    // are removed.
+    for (int i = editsA.size() - 1; i >= 0; i--) {
+      List<Integer> leftEdit = editsA.get(i);
+      List<Integer> rightEdit = editsB.get(i);
+      process.replace(
+          leftEdit.get(0),
+          leftEdit.get(0) + leftEdit.get(1),
+          replace.substring(rightEdit.get(0), rightEdit.get(0) + rightEdit.get(1)));
+    }
+    return process.toString();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIntralineIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIntralineIT.java
new file mode 100644
index 0000000..ff4ac8e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIntralineIT.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+
+/** Runs the {@link RevisionDiffIT} tests with the intraline config enabled. */
+public class RevisionDiffIntralineIT extends RevisionDiffIT {
+  @ConfigSuite.Default
+  public static Config intralineConfig() {
+    Config config = new Config();
+    config.setBoolean(TEST_PARAMETER_MARKER, null, "intraline", true);
+    return config;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 2f9530c..abfd7896 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.BranchOrderSection;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
@@ -87,15 +88,12 @@
 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.ETagView;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
-import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.change.GetRevisionActions;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
@@ -121,7 +119,6 @@
 import org.junit.Test;
 
 public class RevisionIT extends AbstractDaemonTest {
-  @Inject private GetRevisionActions getRevisionActions;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
@@ -165,7 +162,7 @@
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.recommend());
 
-    String label = "Code-Review";
+    String label = LabelId.CODE_REVIEW;
     ApprovalInfo approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
     assertThat(approval.postSubmit).isNull();
@@ -177,10 +174,10 @@
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
     assertThat(approval.postSubmit).isNull();
-    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 1, 2);
+    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), LabelId.CODE_REVIEW, 1, 2);
 
-    // Repeating the current label is allowed. Does not flip the postSubmit bit
-    // due to deduplication codepath.
+    // Repeating the current label is allowed. Does not flip the postSubmit bit due to
+    // deduplication codepath.
     gApi.changes().id(changeId).current().review(ReviewInput.recommend());
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(1);
@@ -203,7 +200,7 @@
     approval = getApproval(changeId, label);
     assertThat(approval.value).isEqualTo(2);
     assertThat(approval.postSubmit).isTrue();
-    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), "Code-Review", 2);
+    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), LabelId.CODE_REVIEW, 2);
 
     // Decreasing to previous post-submit vote is still not allowed.
     thrown =
@@ -230,9 +227,9 @@
     revision(r).review(ReviewInput.recommend());
 
     requestScopeOperations.setApiUser(admin.id());
-    gApi.changes().id(changeId).reviewer(user.username()).deleteVote("Code-Review");
+    gApi.changes().id(changeId).reviewer(user.username()).deleteVote(LabelId.CODE_REVIEW);
     Optional<ApprovalInfo> crUser =
-        get(changeId, DETAILED_LABELS).labels.get("Code-Review").all.stream()
+        get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
             .filter(a -> a._accountId == user.id().get())
             .findFirst();
     assertThat(crUser).isPresent();
@@ -242,12 +239,13 @@
 
     requestScopeOperations.setApiUser(user.id());
     ReviewInput in = new ReviewInput();
-    in.label("Code-Review", 1);
+    in.label(LabelId.CODE_REVIEW, 1);
     in.message = "Still LGTM";
     revision(r).review(in);
 
     ApprovalInfo cr =
-        gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get("Code-Review").all.stream()
+        gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all
+            .stream()
             .filter(a -> a._accountId == user.id().get())
             .findFirst()
             .get();
@@ -262,7 +260,7 @@
     revision(r).submit();
 
     ReviewInput in = new ReviewInput();
-    in.label("Code-Review", 0);
+    in.label(LabelId.CODE_REVIEW, 0);
 
     ResourceConflictException thrown =
         assertThrows(ResourceConflictException.class, () -> revision(r).review(in));
@@ -285,7 +283,7 @@
         Iterators.getOnlyElement(
             cd.currentApprovals().stream().filter(a -> !a.isLegacySubmit()).iterator());
     assertThat(psa.patchSetId().get()).isEqualTo(2);
-    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo(2);
     assertThat(psa.postSubmit()).isFalse();
   }
@@ -386,8 +384,11 @@
 
     // The cherry-pick honors the ChangeId specified in the input message:
     RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    // New change was created.
+    assertThat(changeInfo._number).isGreaterThan(orig.get()._number);
+    assertThat(changeInfo.changeId).isEqualTo(id);
     assertThat(revInfo).isNotNull();
-    assertThat(revInfo.commit.message).endsWith(id + "\n");
+    assertThat(revInfo.commit.message.trim()).endsWith(id);
   }
 
   @Test
@@ -477,6 +478,9 @@
     assertThat(cherryInfo.messages).hasSize(2);
     Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
     assertThat(cherryInfo.cherryPickOfChange).isEqualTo(change.get()._number);
+
+    // Existing change was updated.
+    assertThat(cherryInfo._number).isEqualTo(change.get()._number);
     assertThat(cherryInfo.cherryPickOfPatchSet).isEqualTo(1);
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
@@ -708,12 +712,13 @@
     in.destination = "foo";
     in.message = r3.getCommit().getFullMessage();
     cherry = gApi.changes().id(t1).current().cherryPick(in);
+    assertThat(cherry.get()._number).isEqualTo(info(t2)._number);
     assertThat(cherry.get().cherryPickOfChange).isEqualTo(orig.get()._number);
     assertThat(cherry.get().cherryPickOfPatchSet).isEqualTo(2);
   }
 
   @Test
-  public void cherryPickToExistingChange() throws Exception {
+  public void cherryPickToAbandonedChange() throws Exception {
     PushOneCommit.Result r1 =
         pushFactory
             .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a")
@@ -734,15 +739,17 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = "foo";
     in.message = r1.getCommit().getFullMessage();
-    ResourceConflictException thrown =
+    BadRequestException thrown =
         assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(t1).current().cherryPick(in));
+            BadRequestException.class, () -> gApi.changes().id(t1).current().cherryPick(in));
     assertThat(thrown)
         .hasMessageThat()
         .isEqualTo(
-            "Cannot create new patch set of change "
-                + info(t2)._number
-                + " because it is abandoned");
+            String.format(
+                "Cherry-pick with Change-Id %s could not update the existing change %d in "
+                    + "destination branch refs/heads/foo of project %s, because "
+                    + "the change was closed (ABANDONED)",
+                r1.getChangeId(), info(t2)._number, project.get()));
 
     gApi.changes().id(t2).restore();
     gApi.changes().id(t1).current().cherryPick(in);
@@ -751,6 +758,44 @@
   }
 
   @Test
+  public void cherryPickToExistingMergedChange() throws Exception {
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a")
+            .to("refs/for/master");
+
+    BranchInput bin = new BranchInput();
+    bin.revision = r1.getCommit().getParent(0).name();
+    gApi.projects().name(project.get()).branch("foo").create(bin);
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "b", r1.getChangeId())
+            .to("refs/for/foo");
+    String t2 = project.get() + "~foo~" + r2.getChangeId();
+
+    gApi.changes().id(t2).current().review(ReviewInput.approve());
+    gApi.changes().id(t2).current().submit();
+
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = r1.getCommit().getFullMessage();
+    in.allowConflicts = true;
+    in.allowEmpty = true;
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(t2).current().cherryPick(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Cherry-pick with Change-Id %s could not update the existing change %d in "
+                    + "destination branch refs/heads/foo of project %s, because "
+                    + "the change was closed (MERGED)",
+                r1.getChangeId(), info(t2)._number, project.get()));
+  }
+
+  @Test
   public void cherryPickMergeRelativeToDefaultParent() throws Exception {
     String parent1FileName = "a.txt";
     String parent2FileName = "b.txt";
@@ -872,7 +917,7 @@
     String changeId = project.get() + "~master~" + result.getChangeId();
 
     // 'user' cherry-picks the change to a new branch, the source change's author/committer('admin')
-    // will be added as a reviewer of the newly created change.
+    // will be added as cc of the newly created change.
     requestScopeOperations.setApiUser(user.id());
     CherryPickInput input = new CherryPickInput();
     input.message = "it goes to a new branch";
@@ -882,7 +927,7 @@
     input.notify = NotifyHandling.ALL;
     sender.clear();
     gApi.changes().id(changeId).current().cherryPick(input);
-    assertNotifyTo(admin);
+    assertNotifyCc(admin);
 
     // Disable the notification. 'admin' as a reviewer should not be notified any more.
     input.destination = "branch-2";
@@ -1573,7 +1618,8 @@
     PatchSetWebLink link =
         new PatchSetWebLink() {
           @Override
-          public WebLinkInfo getPatchSetWebLink(String projectName, String commit) {
+          public WebLinkInfo getPatchSetWebLink(
+              String projectName, String commit, String commitMessage, String branchName) {
             return expectedWebLinkInfo;
           }
         };
@@ -1778,23 +1824,6 @@
   }
 
   @Test
-  public void actionsETag() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-
-    String oldETag = checkETag(getRevisionActions, r2, null);
-    current(r2).review(ReviewInput.approve());
-    oldETag = checkETag(getRevisionActions, r2, oldETag);
-
-    // Dependent change is included in ETag.
-    current(r1).review(ReviewInput.approve());
-    oldETag = checkETag(getRevisionActions, r2, oldETag);
-
-    current(r2).submit();
-    checkETag(getRevisionActions, r2, oldETag);
-  }
-
-  @Test
   public void deleteVoteOnNonCurrentPatchSet() throws Exception {
     PushOneCommit.Result r = createChange(); // patch set 1
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
@@ -1816,7 +1845,7 @@
                     .id(r.getChangeId())
                     .revision(r.getCommit().getName())
                     .reviewer(user.id().toString())
-                    .deleteVote("Code-Review"));
+                    .deleteVote(LabelId.CODE_REVIEW));
     assertThat(thrown).hasMessageThat().contains("Cannot access on non-current patch set");
   }
 
@@ -1837,12 +1866,12 @@
         .id(r.getChangeId())
         .current()
         .reviewer(user.id().toString())
-        .deleteVote("Code-Review");
+        .deleteVote(LabelId.CODE_REVIEW);
 
     Map<String, Short> m =
         gApi.changes().id(r.getChangeId()).current().reviewer(user.id().toString()).votes();
 
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 0));
 
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     ChangeMessageInfo message = Iterables.getLast(c.messages);
@@ -1860,8 +1889,8 @@
     assertThat(votes).isEmpty();
     recommend(changeId);
     votes = gApi.changes().id(changeId).current().votes();
-    assertThat(votes.keySet()).containsExactly("Code-Review");
-    List<ApprovalInfo> approvals = votes.get("Code-Review");
+    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    List<ApprovalInfo> approvals = votes.get(LabelId.CODE_REVIEW);
     assertThat(approvals).hasSize(1);
     ApprovalInfo approval = approvals.get(0);
     assertThat(approval._accountId).isEqualTo(admin.id().get());
@@ -1875,8 +1904,8 @@
     // Patch set 1 has 2 votes on Code-Review
     requestScopeOperations.setApiUser(admin.id());
     votes = gApi.changes().id(changeId).current().votes();
-    assertThat(votes.keySet()).containsExactly("Code-Review");
-    approvals = votes.get("Code-Review");
+    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    approvals = votes.get(LabelId.CODE_REVIEW);
     assertThat(approvals).hasSize(2);
     assertThat(approvals.stream().map(a -> a._accountId))
         .containsExactlyElementsIn(ImmutableList.of(admin.id().get(), user.id().get()));
@@ -1888,8 +1917,8 @@
 
     // Votes are still returned for ps 1
     votes = gApi.changes().id(changeId).revision(1).votes();
-    assertThat(votes.keySet()).containsExactly("Code-Review");
-    approvals = votes.get("Code-Review");
+    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    approvals = votes.get(LabelId.CODE_REVIEW);
     assertThat(approvals).hasSize(2);
   }
 
@@ -1965,13 +1994,6 @@
     return gApi.changes().id(r.getChangeId()).current();
   }
 
-  private String checkETag(ETagView<RevisionResource> view, PushOneCommit.Result r, String oldETag)
-      throws Exception {
-    String eTag = view.getETag(parseRevisionResource(r));
-    assertThat(eTag).isNotEqualTo(oldETag);
-    return eTag;
-  }
-
   private PushOneCommit.Result createCherryPickableMerge(
       String parent1FileName, String parent2FileName) throws Exception {
     RevCommit initialCommit = getHead(repo(), "HEAD");
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheForSingleFileIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheForSingleFileIT.java
new file mode 100644
index 0000000..46954e98
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheForSingleFileIT.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Runs the {@link RevisionDiffIT} tests with the new diff cache, enabled for the single file "Get
+ * Diff" endpoint. This is temporary until the new diff cache is fully deployed. The new diff cache
+ * will become the default in the future.
+ */
+public class RevisionNewDiffCacheForSingleFileIT extends RevisionDiffIT {
+  @ConfigSuite.Default
+  public static Config newDiffCacheConfig() {
+    Config config = new Config();
+    config.setBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", true);
+    return config;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
new file mode 100644
index 0000000..ec0bcc6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Runs the {@link RevisionDiffIT} with the list files endpoint using the new diff cache. This is
+ * temporary until the new diff cache is fully deployed. The new diff cache will become the default
+ * in the future.
+ */
+public class RevisionNewDiffCacheIT extends RevisionDiffIT {
+  @ConfigSuite.Default
+  public static Config newDiffCacheConfig() {
+    Config config = new Config();
+    config.setBoolean("cache", "diff_cache", "runNewDiffCache_ListFiles", true);
+    return config;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 5808ea4..8233f0c 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -804,7 +805,7 @@
 
   @Test
   public void editCommitMessageCopiesLabelScores() throws Exception {
-    String cr = "Code-Review";
+    String cr = LabelId.CODE_REVIEW;
     try (ProjectConfigUpdate u = updateProject(project)) {
       LabelType codeReview = TestLabels.codeReview();
       u.getConfig().upsertLabelType(codeReview);
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
index 35f8270..a22759f 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.FakeGroupAuditService;
 import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.pgm.http.jetty.JettyServer;
 import com.google.gerrit.server.audit.HttpAuditEvent;
@@ -27,7 +28,6 @@
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.CredentialsProvider;
 import org.eclipse.jgit.transport.RefSpec;
@@ -48,6 +48,10 @@
     // Don't clear audit events here, since we can't guarantee all test setup has run yet.
   }
 
+  /**
+   * As of today only fetch Protocol V2 is supported on the git client.
+   * https://git.eclipse.org/r/c/jgit/jgit/+/172595
+   */
   @Test
   @Sandboxed
   public void receivePackAuditEventLog() throws Exception {
@@ -77,11 +81,7 @@
   }
 
   @Test
-  public void anonymousUploadPackAuditEventLog() throws Exception {
-    uploadPackAuditEventLog(Constants.DEFAULT_REMOTE_NAME, Optional.empty());
-  }
-
-  @Test
+  @TestProjectInput(createEmptyCommit = false)
   public void authenticatedUploadPackAuditEventLog() throws Exception {
     String remote = "authenticated";
     Config cfg = testRepo.git().getRepository().getConfig();
@@ -93,34 +93,72 @@
     uploadPackAuditEventLog(remote, Optional.of(admin.id()));
   }
 
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void anonymousUploadPackAuditEventLog() throws Exception {
+    String remote = "anonymous";
+    Config cfg = testRepo.git().getRepository().getConfig();
+
+    String uri = server.getUrl() + "/" + project.get();
+    cfg.setString("remote", remote, "url", uri);
+    cfg.setString("remote", remote, "fetch", "+refs/heads/*:refs/remotes/origin/*");
+
+    uploadPackAuditEventLog(remote, Optional.empty());
+  }
+
+  /**
+   * Git client use Protocol V2 fetch by default, see https://git.eclipse.org/r/c/jgit/jgit/+/172595
+   * See {@code org.eclipse.jgit.transport.BasePackFetchConnection#doFetchV2} for the negotiation
+   * details.
+   */
   private void uploadPackAuditEventLog(String remote, Optional<Account.Id> accountId)
       throws Exception {
+    // Make a server-side change to have a common base.
+    createCommit("foo");
+    testRepo.git().fetch().call();
+
+    // Make a server-side change so we have something to fetch.
+    createCommit("bar");
+
     auditService.drainHttpAuditEvents();
-    // testRepo is already a clone. Make a server-side change so we have something to fetch.
-    try (Repository repo = repoManager.openRepository(project);
-        TestRepository<?> testRepo = new TestRepository<>(repo)) {
-      testRepo.branch("master").commit().create();
-    }
     testRepo.git().fetch().setRemote(remote).call();
 
     ImmutableList<HttpAuditEvent> auditEvents = auditService.drainHttpAuditEvents();
-    assertThat(auditEvents).hasSize(2);
+    assertThat(auditEvents).hasSize(3);
 
-    HttpAuditEvent lsRemote = auditEvents.get(0);
-    assertThat(lsRemote.who.toString())
+    // Protocol V2 Capability advertisement
+    // https://git-scm.com/docs/protocol-v2#_capability_advertisement
+    HttpAuditEvent infoRef = auditEvents.get(0);
+
+    assertThat(infoRef.who.toString())
         .isEqualTo(
             accountId.map(id -> "IdentifiedUser[account " + id.get() + "]").orElse("ANONYMOUS"));
-    assertThat(lsRemote.what).endsWith("/info/refs?service=git-upload-pack");
-    assertThat(lsRemote.params).containsExactly("service", "git-upload-pack");
-    assertThat(lsRemote.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
+    assertThat(infoRef.what).endsWith("/info/refs?service=git-upload-pack");
+    assertThat(infoRef.params).containsExactly("service", "git-upload-pack");
+    assertThat(infoRef.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
 
-    HttpAuditEvent uploadPack = auditEvents.get(1);
-    assertThat(uploadPack.who.toString())
-        .isEqualTo(
-            accountId.map(id -> "IdentifiedUser[account " + id.get() + "]").orElse("ANONYMOUS"));
-    assertThat(uploadPack.what).endsWith("/git-upload-pack");
-    assertThat(uploadPack.params).isEmpty();
-    assertThat(uploadPack.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
+    // Smart service negotiations, as described here
+    // https://git-scm.com/docs/http-protocol#_smart_service_git_upload_pack
+    // Protocol V2 client sends command=ls-ref https://git-scm.com/docs/protocol-v2#_ls_refs
+    // followed by command=fetch, thus the request may overflow see
+    // org.eclipse.jgit.transport.MultiRequestService
+    HttpAuditEvent uploadPackLsRef = auditEvents.get(1);
+
+    assertThat(uploadPackLsRef.what).endsWith("/git-upload-pack");
+    assertThat(uploadPackLsRef.params).isEmpty();
+    assertThat(uploadPackLsRef.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
+    HttpAuditEvent uploadPackFetch = auditEvents.get(2);
+
+    assertThat(uploadPackFetch.what).endsWith("/git-upload-pack");
+    assertThat(uploadPackFetch.params).isEmpty();
+    assertThat(uploadPackFetch.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
     assertThat(jettyServer.numActiveSessions()).isEqualTo(0);
   }
+
+  private void createCommit(String message) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      tr.branch("master").commit().message(message).create();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index bb1a2eb..eac0f1b 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -62,12 +62,14 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
@@ -87,16 +89,19 @@
 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.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.receive.NoteDbPushOption;
+import com.google.gerrit.server.git.receive.PluginPushOption;
 import com.google.gerrit.server.git.receive.ReceiveConstants;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -487,26 +492,34 @@
 
   @Test
   public void pushForMasterWithTopic() throws Exception {
-    String topic = "my/topic";
-    // specify topic as option
-    PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, topic);
+    TopicValidator topicValidator = new TopicValidator();
+    try (Registration registration = extensionRegistry.newRegistration().add(topicValidator)) {
+      String topic = "my/topic";
+      // specify topic as option
+      PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
+      r.assertOkStatus();
+      r.assertChange(Change.Status.NEW, topic);
+      assertThat(topicValidator.count()).isEqualTo(1);
+    }
   }
 
   @Test
   public void pushForMasterWithTopicOption() throws Exception {
-    String topicOption = "topic=myTopic";
-    List<String> pushOptions = new ArrayList<>();
-    pushOptions.add(topicOption);
+    TopicValidator topicValidator = new TopicValidator();
+    try (Registration registration = extensionRegistry.newRegistration().add(topicValidator)) {
+      String topicOption = "topic=myTopic";
+      List<String> pushOptions = new ArrayList<>();
+      pushOptions.add(topicOption);
 
-    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
-    push.setPushOptions(pushOptions);
-    PushOneCommit.Result r = push.to("refs/for/master");
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      push.setPushOptions(pushOptions);
+      PushOneCommit.Result r = push.to("refs/for/master");
 
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, "myTopic");
-    r.assertPushOptions(pushOptions);
+      r.assertOkStatus();
+      r.assertChange(Change.Status.NEW, "myTopic");
+      r.assertPushOptions(pushOptions);
+      assertThat(topicValidator.count()).isEqualTo(1);
+    }
   }
 
   @Test
@@ -1040,7 +1053,7 @@
     PushOneCommit.Result r = pushTo("refs/for/master%l=Code-Review");
     r.assertOkStatus();
     ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
-    LabelInfo cr = ci.labels.get("Code-Review");
+    LabelInfo cr = ci.labels.get(LabelId.CODE_REVIEW);
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).name).isEqualTo("Administrator");
     assertThat(cr.all.get(0).value).isEqualTo(1);
@@ -1058,7 +1071,7 @@
     r = push.to("refs/for/master%l=Code-Review+2");
 
     ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
-    cr = ci.labels.get("Code-Review");
+    cr = ci.labels.get(LabelId.CODE_REVIEW);
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 2: Code-Review+2.");
     // Check that the user who pushed the change was added as a reviewer since they added a vote
@@ -1097,7 +1110,7 @@
     r = push.to("refs/for/master%l=Code-Review+2");
 
     ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
-    LabelInfo cr = ci.labels.get("Code-Review");
+    LabelInfo cr = ci.labels.get(LabelId.CODE_REVIEW);
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 2: Code-Review+2.");
 
@@ -1126,7 +1139,7 @@
 
     String changeId = GitUtil.getChangeId(testRepo, c).get();
     assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
-    assertThat(getReviewerEmails(changeId, ReviewerState.REVIEWER))
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC))
         .containsExactly(user.email(), user2.email());
 
     assertThat(sender.getMessages()).hasSize(1);
@@ -1151,7 +1164,7 @@
     pushHead(testRepo, "refs/for/master");
 
     assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
-    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER))
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC))
         .containsExactly(user.email(), user2.email());
 
     assertThat(sender.getMessages()).hasSize(1);
@@ -1183,27 +1196,20 @@
     // Push this commit as "Administrator" (requires Forge Committer Identity)
     pushHead(testRepo, "refs/for/master%l=Code-Review+1", false);
 
-    // Expected Code-Review votes:
-    // 1. 0 from User (committer):
-    //    When the committer is forged, the committer is automatically added as
-    //    reviewer, hence we expect a dummy 0 vote for the committer.
-    // 2. +1 from Administrator (uploader):
-    //    On push Code-Review+1 was specified, hence we expect a +1 vote from
-    //    the uploader.
+    // Expected Code-Review vote:
+    // +1 from Administrator (uploader):
+    // On push Code-Review+1 was specified, hence we expect a +1 vote from the uploader. When the
+    // committer is forged, the committer is automatically added as cc, but that doesn't add votes
+    // (as opposted to being added as reviewer that adds a dummy +0 vote). We ensure there are no
+    // votes from the committer.
     ChangeInfo ci =
         get(GitUtil.getChangeId(testRepo, c).get(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     LabelInfo cr = ci.labels.get("Code-Review");
-    assertThat(cr.all).hasSize(2);
-    int indexAdmin = admin.fullName().equals(cr.all.get(0).name) ? 0 : 1;
-    int indexUser = indexAdmin == 0 ? 1 : 0;
-    assertThat(cr.all.get(indexAdmin).name).isEqualTo(admin.fullName());
-    assertThat(cr.all.get(indexAdmin).value.intValue()).isEqualTo(1);
-    assertThat(cr.all.get(indexUser).name).isEqualTo(user.fullName());
-    assertThat(cr.all.get(indexUser).value.intValue()).isEqualTo(0);
+    ApprovalInfo approvalInfo = Iterables.getOnlyElement(cr.all);
+    assertThat(approvalInfo.name).isEqualTo(admin.fullName());
+    assertThat(approvalInfo.value.intValue()).isEqualTo(1);
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 1: Code-Review+1.");
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
-    assertThatUserIsOnlyReviewer(ci, admin);
   }
 
   @Test
@@ -2360,6 +2366,8 @@
     private final AtomicInteger count = new AtomicInteger();
     private final boolean validateAll;
 
+    @Nullable private CommitReceivedEvent receivedEvent;
+
     TestValidator(boolean validateAll) {
       this.validateAll = validateAll;
     }
@@ -2369,7 +2377,8 @@
     }
 
     @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) {
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receivedEvent) {
+      this.receivedEvent = receivedEvent;
       count.incrementAndGet();
       return Collections.emptyList();
     }
@@ -2382,6 +2391,44 @@
     public int count() {
       return count.get();
     }
+
+    @Nullable
+    public CommitReceivedEvent getReceivedEvent() {
+      return receivedEvent;
+    }
+  }
+
+  private static class TestPluginPushOption implements PluginPushOption {
+    private final String name;
+    private final String description;
+
+    TestPluginPushOption(String name, String description) {
+      this.name = name;
+      this.description = description;
+    }
+
+    @Override
+    public String getName() {
+      return name;
+    }
+
+    @Override
+    public String getDescription() {
+      return description;
+    }
+  }
+
+  private static class TopicValidator implements TopicEditedListener {
+    private final AtomicInteger count = new AtomicInteger();
+
+    @Override
+    public void onTopicEdited(Event event) {
+      count.incrementAndGet();
+    }
+
+    public int count() {
+      return count.get();
+    }
   }
 
   @Test
@@ -2432,6 +2479,38 @@
   }
 
   @Test
+  public void pushOptionsArePassedToCommitValidationListener() throws Exception {
+    TestValidator validator = new TestValidator();
+    PluginPushOption fooOption = new TestPluginPushOption("foo", "some description");
+    PluginPushOption barOption = new TestPluginPushOption("bar", "other description");
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(validator).add(fooOption).add(barOption)) {
+      PushOneCommit push =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push.setPushOptions(ImmutableList.of("trace=123", "gerrit~foo", "gerrit~bar=456"));
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertOkStatus();
+      assertThat(validator.getReceivedEvent().pushOptions)
+          .containsExactly("trace", "123", "gerrit~foo", "", "gerrit~bar", "456");
+    }
+  }
+
+  @Test
+  public void pluginPushOptionsHelp() throws Exception {
+    PluginPushOption fooOption = new TestPluginPushOption("foo", "some description");
+    PluginPushOption barOption = new TestPluginPushOption("bar", "other description");
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(fooOption).add(barOption)) {
+      PushOneCommit push =
+          pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+      push.setPushOptions(ImmutableList.of("help"));
+      PushOneCommit.Result r = push.to("refs/for/master");
+      r.assertErrorStatus("see help");
+      r.assertMessage("-o gerrit~bar: other description\n-o gerrit~foo: some description\n");
+    }
+  }
+
+  @Test
   public void pushNoteDbRef() throws Exception {
     String ref = "refs/changes/34/1234/meta";
     RevCommit c = testRepo.commit().message("Junk NoteDb commit").create();
@@ -2728,6 +2807,22 @@
     assertPushOk(r, "refs/for/otherBranch");
   }
 
+  @Test
+  public void pushWithVoteDoesNotAddToAttentionSet() throws Exception {
+    String pushSpec = "refs/for/master%l=Code-Review+1";
+    PushOneCommit.Result r = pushTo(pushSpec);
+    r.assertOkStatus();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void pushForMasterWithUnknownOption() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("unknown=foo"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("\"--unknown\" is not a valid option");
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
new file mode 100644
index 0000000..46688dd
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
@@ -0,0 +1,189 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.MergeInput;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Ensures that auto merge commits are created when a new patch set or change is uploaded. */
+public class AutoMergeIT extends AbstractDaemonTest {
+  private RevCommit parent1;
+  private RevCommit parent2;
+
+  @Before
+  public void setup() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result p1 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "parent 1",
+                ImmutableMap.of("foo", "foo-1.2", "bar", "bar-1.2"))
+            .to("refs/for/master");
+    parent1 = p1.getCommit();
+
+    // reset HEAD in order to create a sibling of the first change
+    testRepo.reset(initial);
+
+    PushOneCommit.Result p2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "parent 2",
+                ImmutableMap.of("foo", "foo-2.2", "bar", "bar-2.2"))
+            .to("refs/for/master");
+    parent2 = p2.getCommit();
+  }
+
+  @Test
+  public void autoMergeCreatedWhenPushingNewChange() throws Exception {
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "merge", ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(parent1, parent2));
+    PushOneCommit.Result result = m.to("refs/for/master");
+    result.assertOkStatus();
+    assertAutoMergeCreated(result.getCommit());
+  }
+
+  @Test
+  public void autoMergeCreatedWhenPushingNewPatchSet() throws Exception {
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "merge", ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(parent1, parent2));
+    PushOneCommit.Result ps1 = m.to("refs/for/master");
+    RevCommit ps2 =
+        testRepo
+            .amend(ps1.getCommit())
+            .message("PS2")
+            .insertChangeId(ps1.getChangeId().substring(1))
+            .create();
+    testRepo.reset(ps2);
+    GitUtil.pushHead(testRepo, "refs/for/master");
+    // Make sure we have two patch sets
+    assertThat(ps2.getParents().length).isEqualTo(2);
+    assertThat(gApi.changes().id(ps1.getChangeId()).get().revisions.size()).isEqualTo(2);
+    assertAutoMergeCreated(ps2);
+  }
+
+  @Test
+  public void autoMergeCreatedWhenChangeCreatedOnApi() throws Exception {
+    ChangeInput ci = new ChangeInput(project.get(), "master", "Merge commit");
+    ci.merge = new MergeInput();
+    ci.merge.source = parent1.name();
+
+    String newChangePatchSetSha1 = gApi.changes().create(ci).get().currentRevision;
+    assertAutoMergeCreated(ObjectId.fromString(newChangePatchSetSha1));
+  }
+
+  @Test
+  public void autoMergeCreatedWhenNewPatchSetCreatedOnApi() throws Exception {
+    ChangeInput ci = new ChangeInput(project.get(), "master", "Merge commit");
+    ci.merge = new MergeInput();
+    ci.merge.source = parent1.name();
+
+    String changeId = gApi.changes().create(ci).get().changeId;
+    gApi.changes().id(changeId).setMessage("New Commit Message\n\nChange-Id: " + changeId);
+    assertThat(gApi.changes().id(changeId).get().revisions.size()).isEqualTo(2);
+    String newChangePatchSetSha1 = gApi.changes().id(changeId).get().currentRevision;
+    assertAutoMergeCreated(ObjectId.fromString(newChangePatchSetSha1));
+  }
+
+  @Test
+  public void autoMergeCreatedWhenChangeEditIsPublished() throws Exception {
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "merge", ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(parent1, parent2));
+    PushOneCommit.Result result = m.to("refs/for/master");
+    result.assertOkStatus();
+    assertAutoMergeCreated(result.getCommit());
+
+    gApi.changes()
+        .id(result.getChangeId())
+        .edit()
+        .modifyFile("new-file", RawInputUtil.create("content"));
+    gApi.changes().id(result.getChangeId()).edit().publish();
+    assertThat(gApi.changes().id(result.getChangeId()).get().revisions.size()).isEqualTo(2);
+    String newChangePatchSetSha1 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+    assertAutoMergeCreated(ObjectId.fromString(newChangePatchSetSha1));
+  }
+
+  @Test
+  public void noAutoMergeCreatedWhenPushingNonMergeCommit() throws Exception {
+    PushOneCommit.Result change = createChange();
+    change.assertOkStatus();
+    assertNoAutoMergeCreated(change.getCommit());
+  }
+
+  @Test
+  public void autoMergeComputedInMemoryWhenMissing() throws Exception {
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "merge", ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(parent1, parent2));
+    PushOneCommit.Result result = m.to("refs/for/master");
+    result.assertOkStatus();
+    assertAutoMergeCreated(result.getCommit());
+
+    // Delete auto merge branch
+    deleteAutoMergeBranch(result.getCommit());
+    // Trigger AutoMerge computation
+    assertThat(gApi.changes().id(result.getChangeId()).revision(1).file("foo").blameRequest().get())
+        .isNotEmpty();
+    assertNoAutoMergeCreated(result.getCommit());
+  }
+
+  private void assertAutoMergeCreated(ObjectId mergeCommit) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef(RefNames.refsCacheAutomerge(mergeCommit.name()))).isNotNull();
+    }
+  }
+
+  private void assertNoAutoMergeCreated(ObjectId mergeCommit) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(repo.exactRef(RefNames.refsCacheAutomerge(mergeCommit.name()))).isNull();
+    }
+  }
+
+  private void deleteAutoMergeBranch(ObjectId mergeCommit) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      RefUpdate ru = repo.updateRef(RefNames.refsCacheAutomerge(mergeCommit.name()));
+      ru.setForceUpdate(true);
+      assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+    assertNoAutoMergeCreated(mergeCommit);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/BUILD b/javatests/com/google/gerrit/acceptance/git/BUILD
index ef54c92..13311e3 100644
--- a/javatests/com/google/gerrit/acceptance/git/BUILD
+++ b/javatests/com/google/gerrit/acceptance/git/BUILD
@@ -21,7 +21,6 @@
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/git",
-        "//java/com/google/gerrit/mail",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java b/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
deleted file mode 100644
index 6b7adf1..0000000
--- a/javatests/com/google/gerrit/acceptance/git/HttpPushForReviewIT.java
+++ /dev/null
@@ -1,96 +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.git;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.acceptance.FakeGroupAuditService;
-import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.audit.HttpAuditEvent;
-import com.google.inject.Inject;
-import javax.servlet.http.HttpServletResponse;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.CredentialsProvider;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
-import org.junit.Before;
-import org.junit.Test;
-
-public class HttpPushForReviewIT extends AbstractPushForReview {
-  @Inject private FakeGroupAuditService auditService;
-
-  @Before
-  public void selectHttpUrl() throws Exception {
-    CredentialsProvider.setDefault(
-        new UsernamePasswordCredentialsProvider(admin.username(), admin.httpPassword()));
-    selectProtocol(Protocol.HTTP);
-    // Don't clear audit events here, since we can't guarantee all test setup has run yet.
-  }
-
-  @Test
-  public void receivePackAuditEventLog() throws Exception {
-    auditService.drainHttpAuditEvents();
-    testRepo
-        .git()
-        .push()
-        .setRemote("origin")
-        .setRefSpecs(new RefSpec("HEAD:refs/for/master"))
-        .call();
-
-    ImmutableList<HttpAuditEvent> auditEvents = auditService.drainHttpAuditEvents();
-    assertThat(auditEvents).hasSize(2);
-
-    HttpAuditEvent lsRemote = auditEvents.get(0);
-    assertThat(lsRemote.who.getAccountId()).isEqualTo(admin.id());
-    assertThat(lsRemote.what).endsWith("/info/refs?service=git-receive-pack");
-    assertThat(lsRemote.params).containsExactly("service", "git-receive-pack");
-    assertThat(lsRemote.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-
-    HttpAuditEvent receivePack = auditEvents.get(1);
-    assertThat(receivePack.who.getAccountId()).isEqualTo(admin.id());
-    assertThat(receivePack.what).endsWith("/git-receive-pack");
-    assertThat(receivePack.params).isEmpty();
-    assertThat(receivePack.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-  }
-
-  @Test
-  public void uploadPackAuditEventLog() throws Exception {
-    auditService.drainHttpAuditEvents();
-    // testRepo is already a clone. Make a server-side change so we have something to fetch.
-    try (Repository repo = repoManager.openRepository(project);
-        TestRepository<Repository> tr = new TestRepository<>(repo)) {
-      tr.branch("master").commit().create();
-    }
-    testRepo.git().fetch().call();
-
-    ImmutableList<HttpAuditEvent> auditEvents = auditService.drainHttpAuditEvents();
-    assertThat(auditEvents).hasSize(2);
-
-    HttpAuditEvent lsRemote = auditEvents.get(0);
-    // Repo URL doesn't include /a, so fetching doesn't cause authentication.
-    assertThat(lsRemote.who).isInstanceOf(AnonymousUser.class);
-    assertThat(lsRemote.what).endsWith("/info/refs?service=git-upload-pack");
-    assertThat(lsRemote.params).containsExactly("service", "git-upload-pack");
-    assertThat(lsRemote.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-
-    HttpAuditEvent uploadPack = auditEvents.get(1);
-    assertThat(lsRemote.who).isInstanceOf(AnonymousUser.class);
-    assertThat(uploadPack.what).endsWith("/git-upload-pack");
-    assertThat(uploadPack.params).isEmpty();
-    assertThat(uploadPack.httpStatus).isEqualTo(HttpServletResponse.SC_OK);
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 1a2ae7c..385780b 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -82,17 +82,14 @@
         .update();
   }
 
-  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void mixingMagicAndRegularPush() throws Exception {
     testRepo.branch("HEAD").commit().create();
     PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/for/master");
 
     String msg = "cannot combine normal pushes and magic pushes";
-    assertThat(r.getRemoteUpdate("refs/heads/master"))
-        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
-    assertThat(r.getRemoteUpdate("refs/for/master"))
-        .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
+    assertThat(r.getRemoteUpdate("refs/heads/master").getStatus()).isNotEqualTo(Status.OK);
+    assertThat(r.getRemoteUpdate("refs/for/master").getStatus()).isNotEqualTo(Status.OK);
     assertThat(r.getRemoteUpdate("refs/for/master").getMessage()).isEqualTo(msg);
   }
 
@@ -364,7 +361,7 @@
         .update();
 
     String project2 = name("project2");
-    gApi.projects().create(project2);
+    projectOperations.newProject().name(project2).create();
 
     ObjectId oldId = forceFetch("refs/meta/config");
 
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 0930815..b7acbe2 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHookChain;
 import com.google.gerrit.server.git.receive.testing.TestRefAdvertiser;
@@ -118,7 +119,7 @@
   @Before
   public void setUp() throws Exception {
     admins = adminGroupUuid();
-    nonInteractiveUsers = groupUuid("Service Users");
+    nonInteractiveUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
     setUpPermissions();
     setUpChanges();
   }
@@ -402,6 +403,39 @@
   }
 
   @Test
+  public void uploadPackSubsetOfBranchesVisibleAllPatchsets() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result pushResult =
+        pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master");
+    pushResult.assertOkStatus();
+    String firstPatchSetRef = RefNames.patchSetRef(pushResult.getPatchSetId());
+
+    pushResult = amendChange(pushResult.getChangeId());
+    pushResult.assertOkStatus();
+
+    String secondPatchSetRef = RefNames.patchSetRef(pushResult.getPatchSetId());
+
+    assertUploadPackRefs(
+        "HEAD",
+        psRef1,
+        metaRef1,
+        psRef3,
+        metaRef3,
+        RefNames.changeMetaRef(pushResult.getChange().getId()),
+        firstPatchSetRef,
+        // include all patchsets of the visible changes
+        secondPatchSetRef,
+        "refs/heads/master",
+        "refs/tags/master-tag");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
+  }
+
+  @Test
   public void uploadPackSubsetOfBranchesAndEditsVisibleWithViewPrivateChanges() throws Exception {
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index 64bd25c..5b18d02 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -27,7 +27,6 @@
     labels = [
         "docker",
         "elastic",
-        "exclusive",
         "pgm",
         "no_windows",
     ],
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
index 23d7658..093711f 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.schema.NoteDbSchemaVersion;
 import com.google.gerrit.server.schema.Schema_184;
 import com.google.gerrit.testing.TestUpdateUI;
@@ -26,7 +27,8 @@
 import org.junit.Test;
 
 public class Schema_184IT extends AbstractDaemonTest {
-  private static final AccountGroup.NameKey SERVICE_USERS = AccountGroup.nameKey("Service Users");
+  private static final AccountGroup.NameKey SERVICE_USERS =
+      AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS);
   private static final AccountGroup.NameKey NON_INTERACTIVE_USERS =
       AccountGroup.nameKey("Non-Interactive Users");
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index f5d9e3a..02916c7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -18,6 +18,13 @@
 import static org.apache.http.HttpStatus.SC_CREATED;
 import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
 import static org.apache.http.HttpStatus.SC_OK;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
@@ -36,7 +43,6 @@
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.validators.CommitValidationException;
@@ -52,11 +58,10 @@
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
-import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.apache.http.message.BasicHeader;
 import org.junit.Rule;
 import org.junit.Test;
@@ -337,7 +342,7 @@
     assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
     assertForceLogging(false);
     try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
-      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
@@ -348,7 +353,7 @@
               () -> {
                 // Verify that the tags and force logging flag have been propagated to the new
                 // thread.
-                SortedMap<String, SortedSet<Object>> threadTagMap =
+                Map<String, ? extends Set<Object>> threadTagMap =
                     LoggingContext.getInstance().getTags().asMap();
                 expect.that(threadTagMap.keySet()).containsExactly("foo");
                 expect.that(threadTagMap.get("foo")).containsExactly("bar");
@@ -370,37 +375,31 @@
 
   @Test
   public void performanceLoggingForRestCall() throws Exception {
-    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       RestResponse response = adminRestSession.put("/projects/new10");
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
-
-      // This assertion assumes that the server invokes the PerformanceLogger plugins before it
-      // sends
-      // the response to the client. If this assertion gets flaky it's likely that this got changed
-      // on
-      // server-side.
-      assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+      verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
     }
   }
 
   @Test
   public void performanceLoggingForPush() throws Exception {
-    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
-      assertThat(testPerformanceLogger.logEntries()).isNotEmpty();
+      verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
     }
   }
 
   @Test
   @GerritConfig(name = "tracing.performanceLogging", value = "false")
   public void noPerformanceLoggingIfDisabled() throws Exception {
-    TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
+    PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
       RestResponse response = adminRestSession.put("/projects/new11");
@@ -410,7 +409,7 @@
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
 
-      assertThat(testPerformanceLogger.logEntries()).isEmpty();
+      verifyZeroInteractions(testPerformanceLogger);
     }
   }
 
@@ -713,7 +712,7 @@
 
   @Test
   @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void autoRetryWithTrace() throws Exception {
+  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
     String changeId = createChange().getChangeId();
     approve(changeId);
 
@@ -723,49 +722,6 @@
       RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
       assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).startsWith("retry-on-failure-");
-      assertThat(traceSubmitRule.traceId).startsWith("retry-on-failure-");
-      assertThat(traceSubmitRule.isLoggingForced).isTrue();
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-
-    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
-    traceSubmitRule.failAlways = true;
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(traceSubmitRule)
-            .add(
-                new ExceptionHook() {
-                  @Override
-                  public boolean shouldRetry(String actionType, String actionName, Throwable t) {
-                    return true;
-                  }
-                })) {
-      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
-      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
-      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
-      assertThat(traceSubmitRule.traceId).isNull();
-      assertThat(traceSubmitRule.isLoggingForced).isFalse();
-    }
-  }
-
-  @Test
-  public void noAutoRetryWithTraceIfDisabled() throws Exception {
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-
-    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
-    traceSubmitRule.failOnce = true;
-    try (Registration registration = extensionRegistry.newRegistration().add(traceSubmitRule)) {
-      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
-      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
-      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
       assertThat(traceSubmitRule.traceId).isNull();
       assertThat(traceSubmitRule.isLoggingForced).isFalse();
     }
@@ -844,19 +800,6 @@
     }
   }
 
-  private static class TestPerformanceLogger implements PerformanceLogger {
-    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
-
-    @Override
-    public void log(String operation, long durationMs, Metadata metadata) {
-      logEntries.add(PerformanceLogEntry.create(operation, metadata));
-    }
-
-    ImmutableList<PerformanceLogEntry> logEntries() {
-      return ImmutableList.copyOf(logEntries);
-    }
-  }
-
   @AutoValue
   abstract static class PerformanceLogEntry {
     static PerformanceLogEntry create(String operation, Metadata metadata) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
index aaeed02..8feac20 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -44,7 +44,7 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.EnableReverseDnsLookup;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -56,7 +56,7 @@
 public class EmailIT extends AbstractDaemonTest {
   @Inject private @AnonymousCowardName String anonymousCowardName;
   @Inject private @CanonicalWebUrl Provider<String> canonicalUrl;
-  @Inject private @EnableReverseDnsLookup boolean enableReverseDnsLookup;
+  @Inject private @EnablePeerIPInReflogRecord boolean enablePeerIPInReflogRecord;
   @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
   @Inject private AuthConfig authConfig;
   @Inject private EmailExpander emailExpander;
@@ -280,7 +280,7 @@
             realm,
             anonymousCowardName,
             canonicalUrl,
-            enableReverseDnsLookup,
+            enablePeerIPInReflogRecord,
             accountCache,
             groupBackend);
     return atrScope.set(atrScope.newContext(null, userFactory.create(admin.id())));
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index a5cf3e1..b999abd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.inject.Inject;
 import org.junit.Test;
 
@@ -45,7 +46,11 @@
   public void getDetailForServiceUser() throws Exception {
     Account.Id serviceUser = accountOperations.newAccount().create();
     groupOperations
-        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
+        .group(
+            groupCache
+                .get(AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS))
+                .get()
+                .getGroupUUID())
         .forUpdate()
         .addMember(serviceUser)
         .update();
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
index 2e2f5d9..1e61d0a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.inject.Inject;
 import org.junit.Test;
 
@@ -70,7 +71,8 @@
   @Test
   public void getServiceUserAccount() throws Exception {
     TestAccount serviceUser =
-        accountCreator.create("robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot1", "robot1@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     assertThat(serviceUser.tags()).containsExactly("SERVICE_USER");
     testGetAccount(serviceUser.id().toString(), serviceUser);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 5c596dc..bf8de93 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -183,7 +184,7 @@
 
     ReviewInput in = new ReviewInput();
     in.onBehalfOf = user.id().toString();
-    in.label("Verified", 1);
+    in.label(LabelId.VERIFIED, 1);
 
     AuthException thrown = assertThrows(AuthException.class, () -> revision.review(in));
     assertThat(thrown)
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
index 2c9107c..b70cab8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
@@ -249,4 +249,30 @@
   public void postWithoutBody() throws Exception {
     adminRestSession.post("/accounts/" + admin.username() + "/watched.projects").assertOK();
   }
+
+  @Test
+  public void nullProjectThrowsBadRequestException() {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = null;
+    projectsToWatch.add(pwi);
+    Throwable t =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(t.getMessage()).isEqualTo("project name must be specified");
+  }
+
+  @Test
+  public void emptyProjectThrowsBadRequestException() {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = "  ";
+    projectsToWatch.add(pwi);
+    Throwable t =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(t.getMessage()).isEqualTo("project name must be specified");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
index 9298b43..00b1c55 100644
--- a/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
@@ -14,8 +14,6 @@
 
 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;
@@ -34,6 +32,5 @@
     RestSession anonymous = new RestSession(server, null);
     RestResponse r = anonymous.get("/auth-check");
     r.assertForbidden();
-    assertThat(r.getHeader("Content-Length")).isEqualTo("0");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
index b447534..16dc294 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
@@ -16,6 +16,7 @@
 
 import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -48,6 +49,7 @@
 import javax.servlet.http.HttpServletRequestWrapper;
 import javax.servlet.http.HttpServletResponse;
 import org.junit.Test;
+import org.kohsuke.args4j.Option;
 
 /**
  * Tests for checking plugin-provided REST API bindings directly under {@code /}.
@@ -192,8 +194,15 @@
 
   @Singleton
   static class TestGet implements RestReadView<TestPluginResource> {
+
+    @Option(name = "--crash")
+    String crash;
+
     @Override
     public Response<String> apply(TestPluginResource resource) throws Exception {
+      if (!Strings.nullToEmpty(crash).isEmpty()) {
+        throw new IllegalStateException();
+      }
       return Response.ok("test");
     }
   }
@@ -204,4 +213,13 @@
       RestApiCallHelper.execute(adminRestSession, TEST_CALLS.asList());
     }
   }
+
+  @Test
+  public void testOptionOnSingletonIsIgnored() throws Exception {
+    try (AutoCloseable ignored = installPlugin(PLUGIN_NAME, null, MyPluginHttpModule.class, null)) {
+      RestApiCallHelper.execute(
+          adminRestSession,
+          RestCall.get("/plugins/" + PLUGIN_NAME + "/test-collection/1/detail?crash=xyz"));
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index faef5aa..796ce38 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -59,6 +59,7 @@
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
@@ -228,7 +229,9 @@
                   "Cannot rebase "
                       + change2hash
                       + ": The change could "
-                      + "not be rebased due to a conflict during merge.");
+                      + "not be rebased due to a conflict during merge.\n\n"
+                      + "merge conflict(s):\n"
+                      + "a.txt");
           break;
         case MERGE_ALWAYS:
         case MERGE_IF_NECESSARY:
@@ -413,7 +416,7 @@
         .forUpdate()
         .add(block(Permission.SUBMIT).ref("refs/*").group(CHANGE_OWNER))
         .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
-        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
         .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -439,7 +442,7 @@
         .forUpdate()
         .add(block(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(CHANGE_OWNER))
-        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
         .update();
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
@@ -1375,7 +1378,6 @@
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
     Iterable<String> messages = Iterables.transform(info.messages, i -> i.message);
-    assertThat(messages).hasSize(3);
     String last = Iterables.getLast(messages);
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
       assertThat(last).startsWith("Change has been successfully cherry-picked as");
@@ -1386,6 +1388,17 @@
     }
   }
 
+  @Test
+  public void submitSetsMergedOn() throws Throwable {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().getMergedOn()).isEmpty();
+    submit(r.getChangeId());
+    assertThat(r.getChange().getMergedOn()).isPresent();
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.updated);
+    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.submitted);
+  }
+
   @Override
   protected void updateProjectInput(ProjectInput in) {
     in.submitType = getSubmitType();
@@ -1439,6 +1452,12 @@
     assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isTrue();
   }
 
+  protected void assertSubmitDisabled(String changeId) throws Throwable {
+    RevisionResource rsrc = parseCurrentRevisionResource(changeId);
+    UiAction.Description desc = submitHandler.getDescription(rsrc);
+    assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isFalse();
+  }
+
   protected void assertChangeMergedEvents(String... expected) throws Throwable {
     eventRecorder.assertChangeMergedEvents(project.get(), "refs/heads/master", expected);
   }
@@ -1470,7 +1489,7 @@
 
   protected void assertApproved(String changeId, TestAccount user) throws Throwable {
     ChangeInfo c = get(changeId, DETAILED_LABELS);
-    LabelInfo cr = c.labels.get("Code-Review");
+    LabelInfo cr = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(cr.all).hasSize(1);
     assertThat(cr.all.get(0).value).isEqualTo(2);
     assertThat(Account.id(cr.all.get(0)._accountId)).isEqualTo(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index f77552d..9c496fa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -117,4 +118,49 @@
     assertThat(head.getParent(0)).isEqualTo(change1.getCommit());
     assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    // submit button is disabled.
+    assertSubmitDisabled(change2Result.getChangeId());
+
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 2 changes due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + ", which is outdated patch set "
+            + patchSetId.get()
+            + " of change "
+            + changeResult.getChange().getId()
+            + ". The latest patch set is "
+            + changeResult.getPatchSetId().get()
+            + ".");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 955dd7a..81c098f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -235,7 +236,9 @@
         change2.getChangeId(),
         "Cannot rebase "
             + change2.getCommit().name()
-            + ": The change could not be rebased due to a conflict during merge.");
+            + ": The change could not be rebased due to a conflict during merge.\n\n"
+            + "merge conflict(s):\n"
+            + "a.txt");
     RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
@@ -362,7 +365,9 @@
         "Cannot rebase "
             + change2.getCommit().getName()
             + ": "
-            + "The change could not be rebased due to a conflict during merge.");
+            + "The change could not be rebased due to a conflict during merge.\n\n"
+            + "merge conflict(s):\n"
+            + "fileName 2");
     assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterChange1);
   }
 
@@ -384,4 +389,49 @@
     gApi.changes().id(change2.getChangeId()).current().rebase();
     submit(change2.getChangeId());
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetPreventsRebase() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    // submit button is disabled.
+    assertSubmitDisabled(change2Result.getChangeId());
+
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 2 changes due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + ", which is outdated patch set "
+            + patchSetId.get()
+            + " of change "
+            + changeResult.getChange().getId()
+            + ". The latest patch set is "
+            + changeResult.getPatchSetId().get()
+            + ".");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index c6a2819..e35f758 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -20,26 +20,20 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.TestProjectInput;
-import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.server.change.RevisionJson;
-import com.google.gerrit.server.change.testing.TestChangeETagComputation;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
@@ -56,7 +50,6 @@
     return submitWholeTopicEnabledConfig();
   }
 
-  @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private RevisionJson.Factory revisionJsonFactory;
   @Inject private ExtensionRegistry extensionRegistry;
 
@@ -68,10 +61,6 @@
     return gApi.changes().id(id).get().actions;
   }
 
-  protected String getETag(String id) throws Exception {
-    return gApi.changes().id(id).current().etag();
-  }
-
   @Test
   public void changeActionOneMergedChangeHasOnlyNormalRevert() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
@@ -138,124 +127,6 @@
   }
 
   @Test
-  public void revisionActionsETag() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChangeWithTopic().getChangeId();
-    approve(change);
-    String etag1 = getETag(change);
-
-    approve(parent);
-    String etag2 = getETag(change);
-
-    String changeWithSameTopic = createChangeWithTopic().getChangeId();
-    String etag3 = getETag(change);
-
-    approve(changeWithSameTopic);
-    String etag4 = getETag(change);
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
-    } else {
-      assertThat(etag2).isNotEqualTo(etag1);
-      assertThat(etag3).isEqualTo(etag2);
-      assertThat(etag4).isEqualTo(etag2);
-    }
-  }
-
-  @Test
-  public void revisionActionsAnonymousETag() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChangeWithTopic().getChangeId();
-    approve(change);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag1 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    approve(parent);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag2 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    String changeWithSameTopic = createChangeWithTopic().getChangeId();
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag3 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    approve(changeWithSameTopic);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag4 = getETag(change);
-
-    if (isSubmitWholeTopicEnabled()) {
-      assertThat(ImmutableList.of(etag1, etag2, etag3, etag4)).containsNoDuplicates();
-    } else {
-      assertThat(etag2).isNotEqualTo(etag1);
-      assertThat(etag3).isEqualTo(etag2);
-      assertThat(etag4).isEqualTo(etag2);
-    }
-  }
-
-  @Test
-  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
-  public void revisionActionsAnonymousETagCherryPickStrategy() throws Exception {
-    String parent = createChange().getChangeId();
-    String change = createChange().getChangeId();
-    approve(change);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag1 = getETag(change);
-
-    requestScopeOperations.setApiUser(admin.id());
-    approve(parent);
-
-    requestScopeOperations.setApiUserAnonymous();
-    String etag2 = getETag(change);
-    assertThat(etag2).isEqualTo(etag1);
-  }
-
-  @Test
-  public void pluginCanContributeToETagComputation() throws Exception {
-    String change = createChange().getChangeId();
-    String oldETag = getETag(change);
-
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag("foo"))) {
-      assertThat(getETag(change)).isNotEqualTo(oldETag);
-    }
-
-    assertThat(getETag(change)).isEqualTo(oldETag);
-  }
-
-  @Test
-  public void returningNullFromETagComputationDoesNotBreakGerrit() throws Exception {
-    String change = createChange().getChangeId();
-    String oldETag = getETag(change);
-
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag(null))) {
-      assertThat(getETag(change)).isEqualTo(oldETag);
-    }
-  }
-
-  @Test
-  public void throwingExceptionFromETagComputationDoesNotBreakGerrit() throws Exception {
-    String change = createChange().getChangeId();
-    String oldETag = getETag(change);
-
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                TestChangeETagComputation.withException(
-                    new StorageException("exception during test")))) {
-      assertThat(getETag(change)).isEqualTo(oldETag);
-    }
-  }
-
-  @Test
   public void revisionActionsTwoChangesInTopic_conflicting() throws Exception {
     String changeId = createChangeWithTopic().getChangeId();
     approve(changeId);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 5c50949..efd27d0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -35,6 +35,7 @@
 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;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -47,6 +48,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -61,6 +63,7 @@
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 import java.util.function.LongSupplier;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import org.junit.Before;
 import org.junit.Test;
@@ -113,48 +116,48 @@
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
     int accountId =
-        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "first"))._accountId;
-    assertThat(accountId).isEqualTo(user.id().get());
+        change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "first"))._accountId;
+    assertThat(accountId).isEqualTo(admin.id().get());
     AttentionSetUpdate expectedAttentionSetUpdate =
         AttentionSetUpdate.createFromRead(
-            fakeClock.now(), user.id(), AttentionSetUpdate.Operation.ADD, "first");
+            fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.ADD, "first");
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
 
     // Second add is ignored.
     accountId =
-        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "second"))._accountId;
-    assertThat(accountId).isEqualTo(user.id().get());
+        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.
     String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
     assertThat(emailBody)
         .contains(
-            user.fullName()
-                + " added themselves to the attention set of this change.\n The reason is: first.");
+            String.format(
+                "%s requires the attention of %s to this change.\n The reason is: first.",
+                user.fullName(), admin.fullName()));
   }
 
   @Test
   public void addMultipleUsers() throws Exception {
     PushOneCommit.Result r = createChange();
     Instant timestamp1 = fakeClock.now();
-    int accountId1 =
-        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"))._accountId;
-    assertThat(accountId1).isEqualTo(user.id().get());
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
     fakeClock.advance(Duration.ofSeconds(42));
     Instant timestamp2 = fakeClock.now();
     int accountId2 =
         change(r)
-            .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "admin"))
+            .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "manual update"))
             ._accountId;
     assertThat(accountId2).isEqualTo(admin.id().get());
 
     AttentionSetUpdate expectedAttentionSetUpdate1 =
         AttentionSetUpdate.createFromRead(
-            timestamp1, user.id(), AttentionSetUpdate.Operation.ADD, "user");
+            timestamp1, user.id(), AttentionSetUpdate.Operation.ADD, "Reviewer was added");
     AttentionSetUpdate expectedAttentionSetUpdate2 =
         AttentionSetUpdate.createFromRead(
-            timestamp2, admin.id(), AttentionSetUpdate.Operation.ADD, "admin");
+            timestamp2, admin.id(), AttentionSetUpdate.Operation.ADD, "manual update");
     assertThat(r.getChange().attentionSet())
         .containsExactly(expectedAttentionSetUpdate1, expectedAttentionSetUpdate2);
   }
@@ -162,7 +165,9 @@
   @Test
   public void removeUser() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "added"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+    sender.clear();
     requestScopeOperations.setApiUser(user.id());
 
     fakeClock.advance(Duration.ofSeconds(42));
@@ -189,6 +194,9 @@
   @Test
   public void removeUserWithInvalidUserInput() throws Exception {
     PushOneCommit.Result r = createChange();
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+
     BadRequestException exception =
         assertThrows(
             BadRequestException.class,
@@ -197,7 +205,9 @@
                     .attention(user.id().toString())
                     .remove(new AttentionSetInput("invalid user", "reason")));
     assertThat(exception.getMessage())
-        .isEqualTo("The user specified in the input body couldn't be found.");
+        .isEqualTo(
+            "invalid user doesn't exist or is not active on the change as an owner, "
+                + "uploader, reviewer, or cc so they can't be added to the attention set");
 
     exception =
         assertThrows(
@@ -212,16 +222,10 @@
   }
 
   @Test
-  public void removeUnrelatedUser() throws Exception {
-    PushOneCommit.Result r = createChange();
-    change(r).attention(user.id().toString()).remove(new AttentionSetInput("foo"));
-    assertThat(r.getChange().attentionSet()).isEmpty();
-  }
-
-  @Test
   public void abandonRemovesUsers() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
     change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "admin"));
 
     change(r).abandon();
@@ -242,7 +246,8 @@
   @Test
   public void workInProgressRemovesUsers() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     change(r).setWorkInProgress();
 
@@ -256,13 +261,10 @@
   public void submitRemovesUsersForAllSubmittedChanges() throws Exception {
     PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
 
-    change(r1)
-        .current()
-        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r1).current().review(ReviewInput.approve().reviewer(user.email()));
     PushOneCommit.Result r2 = createChange("refs/heads/master", "file2", "content");
-    change(r2)
-        .current()
-        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+    change(r2).current().review(ReviewInput.approve().reviewer(user.email()));
 
     change(r2).current().submit();
 
@@ -284,21 +286,26 @@
 
   @Test
   public void robotSubmitsRemovesUsers() throws Exception {
-    PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
+    PushOneCommit.Result r = createChange("refs/heads/master", "file1", "content");
 
-    change(r1)
-        .current()
-        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     TestAccount robot =
         accountCreator.create(
-            "robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users", "Administrators");
+            "robot2",
+            "robot2@example.com",
+            "Ro Bot",
+            "Ro",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
     requestScopeOperations.setApiUser(robot.id());
-    change(r1).current().submit();
+    change(r).current().review(ReviewInput.approve());
+    change(r).current().submit();
 
     // Attention set updates that relate to the admin (the person who replied) are filtered out.
     AttentionSetUpdate attentionSet =
-        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r1, user));
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
 
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
     assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
@@ -460,7 +467,12 @@
 
     TestAccount robot =
         accountCreator.create(
-            "robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users", "Administrators");
+            "robot1",
+            "robot1@example.com",
+            "Ro Bot",
+            "Ro",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
     requestScopeOperations.setApiUser(robot.id());
     change(r).setReadyForReview();
     AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
@@ -483,6 +495,62 @@
   }
 
   @Test
+  public void readyForReviewHasNoEffectOnReadyChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r)
+        .current()
+        .review(ReviewInput.create().reviewer(user.email()).blockAutomaticAttentionSetRules());
+
+    change(r).current().review(ReviewInput.create().setWorkInProgress(false));
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    change(r).current().review(ReviewInput.create().setReady(true));
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void workInProgressHasNoEffectOnWorkInProgressChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(user.email())
+                .setWorkInProgress(true)
+                .addUserToAttentionSet(user.email(), /* reason= */ "reason"));
+
+    change(r).current().review(ReviewInput.create().setWorkInProgress(true));
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+
+    change(r).current().review(ReviewInput.create().setReady(false));
+    attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+  }
+
+  @Test
+  public void rebaseDoesNotAddToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    // create an unrelated change so that we can rebase
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result unrelated = createChange();
+    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+    gApi.changes().id(r.getChangeId()).rebase();
+
+    // rebase has no impact on the attention set
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
   public void readyForReviewWhileRemovingReviewerRemovesThemToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
     change(r).setWorkInProgress();
@@ -521,7 +589,9 @@
   @Test
   public void reviewersAreNotAddedForNoReasonBecauseOfAnUpdate() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+
     change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
 
     HashtagsInput hashtagsInput = new HashtagsInput();
@@ -555,7 +625,8 @@
   @Test
   public void reviewRemovesManuallyRemovedUserFromAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
     requestScopeOperations.setApiUser(user.id());
 
     ReviewInput reviewInput =
@@ -575,7 +646,8 @@
   @Test
   public void reviewWithManualAdditionToAttentionSetFailsWithoutReason() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet(user.email(), "");
 
@@ -599,7 +671,8 @@
   @Test
   public void reviewAddReviewerWhileRemovingFromAttentionSetJustRemovesUser() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "addition"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     ReviewInput reviewInput =
         ReviewInput.create()
@@ -760,7 +833,15 @@
 
     requestScopeOperations.setApiUser(user.id());
 
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    // add the user to the attention set.
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(user.email(), ReviewerState.CC, true)
+                .addUserToAttentionSet(user.email(), "reason"));
+
+    // add the user as reviewer but still be removed on reply.
     ReviewInput reviewInput = ReviewInput.create().reviewer(user.email());
     change(r).current().review(reviewInput);
 
@@ -1132,6 +1213,9 @@
   @Test
   public void repliesWhileAddingAsReviewerStillRemovesUser() throws Exception {
     PushOneCommit.Result r = createChange();
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+
     change(r).addToAttentionSet(new AttentionSetInput(user.email(), "remove"));
 
     requestScopeOperations.setApiUser(user.id());
@@ -1222,8 +1306,11 @@
   @Test
   public void robotsNotAddedToAttentionSet() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot1", "robot1@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
+    // Make the robot active on the change.
+    change(r).addReviewer(robot.email());
 
     // Throw an error when adding a robot explicitly.
     BadRequestException exception =
@@ -1242,7 +1329,8 @@
   @Test
   public void robotAddingAReviewerChangeAttentionSet() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).addReviewer(user.id().toString());
@@ -1258,7 +1346,8 @@
   @Test
   public void robotReviewDoesNotChangeAttentionSet() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).current().review(ReviewInput.recommend());
@@ -1269,7 +1358,8 @@
   @Test
   public void robotReviewWithNegativeLabelAddsOwner() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).current().review(ReviewInput.dislike());
@@ -1284,7 +1374,8 @@
   @Test
   public void robotCommentDoesNotAddOwnerOnClosedChanges() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).abandon();
 
@@ -1301,7 +1392,8 @@
   @Test
   public void robotCanChangeAttentionSetExplicitly() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).current().review(new ReviewInput().addUserToAttentionSet(admin.email(), "reason"));
@@ -1317,13 +1409,15 @@
   public void addUsersToAttentionSetInPrivateChanges() throws Exception {
     PushOneCommit.Result r = createChange();
     change(r).setPrivate(true);
-    change(r).current().review(new ReviewInput().addUserToAttentionSet(user.email(), "reason"));
+
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     AttentionSetUpdate attentionSet =
         Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
     assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
-    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
   }
 
   @Test
@@ -1368,43 +1462,30 @@
   public void attentionSetEmailHeader() throws Exception {
     PushOneCommit.Result r = createChange();
     TestAccount user2 = accountCreator.user2();
+
+    // The pattern ensures the header mentions the attention set requirements in any order.
+    Pattern attentionSetHeaderPattern =
+        Pattern.compile(
+            String.format(
+                "Attention is currently required from: (%s|%s), (%s|%s).",
+                user2.fullName(), user.fullName(), user.fullName(), user2.fullName()));
     // Add user and user2 to the attention set.
     change(r)
         .current()
         .review(
             ReviewInput.create().reviewer(user.email()).reviewer(accountCreator.user2().email()));
     assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
-        .contains(
-            "Attention is currently required from: "
-                + user2.fullName()
-                + ", "
-                + user.fullName()
-                + ".");
+        .containsMatch(attentionSetHeaderPattern);
     assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
-        .contains(
-            "Attention is currently required from: "
-                + user2.fullName()
-                + ", "
-                + user.fullName()
-                + ".");
+        .containsMatch(attentionSetHeaderPattern);
     sender.clear();
 
     // Irrelevant reply, User and User2 are still in the attention set.
     change(r).current().review(ReviewInput.approve());
     assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
-        .contains(
-            "Attention is currently required from: "
-                + user2.fullName()
-                + ", "
-                + user.fullName()
-                + ".");
+        .containsMatch(attentionSetHeaderPattern);
     assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
-        .contains(
-            "Attention is currently required from: "
-                + user2.fullName()
-                + ", "
-                + user.fullName()
-                + ".");
+        .containsMatch(attentionSetHeaderPattern);
     sender.clear();
 
     // Abandon the change which removes user from attention set; there is an email but without the
@@ -1457,6 +1538,58 @@
   }
 
   @Test
+  public void attentionSetWithEmailFilterFiltersNewPatchsets() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Add user to reviewers but not to the attention set
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(user.email())
+                .removeUserFromAttentionSet(user.email(), "reason"));
+    sender.clear();
+
+    // amending a change doesn't send an email when user is not in the attention set.
+    amendChange(r.getChangeId());
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void attentionSetWithEmailFilterStillReceivesSubmitEmail() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Add user to reviewers but not to the attention set
+    change(r)
+        .current()
+        .review(
+            ReviewInput.approve()
+                .reviewer(user.email())
+                .removeUserFromAttentionSet(user.email(), "reason"));
+    sender.clear();
+
+    // submitting the change sends an email even when user is not in the attention set.
+    change(r).current().submit();
+    assertThat(sender.getMessages()).isNotEmpty();
+  }
+
+  @Test
   public void attentionSetWithEmailFilterImpactingOnlyChangeEmails() throws Exception {
     // Add preference for the user such that they only receive an email on changes that require
     // their attention.
@@ -1468,6 +1601,156 @@
   }
 
   @Test
+  public void cannotAddIrrelevantUserToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+                    + "or cc so they can't be added to the attention set",
+                user.email()));
+  }
+
+  @Test
+  public void cannotAddNonExistingUserToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).addToAttentionSet(new AttentionSetInput("INVALID USER", "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "INVALID USER doesn't exist or is not active on the change as an owner,"
+                + " uploader, reviewer, or cc so they can't be added to the attention set");
+  }
+
+  @Test
+  public void cannotRemoveIrrelevantUserToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).attention(user.email()).remove(new AttentionSetInput("reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+                    + "or cc so they can't be added to the attention set",
+                user.email()));
+  }
+
+  @Test
+  public void cannotRemoveIrrelevantUserToAttentionSetWithUserInInput() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                change(r)
+                    .attention(user.email())
+                    .remove(new AttentionSetInput(user.email(), "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+                    + "or cc so they can't be added to the attention set",
+                user.email()));
+  }
+
+  @Test
+  public void cannotRemoveNonExistingUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).attention("INVALID USER").remove(new AttentionSetInput("reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "INVALID USER doesn't exist or is not active on the change as an owner,"
+                + " uploader, reviewer, or cc so they can't be added to the attention set");
+  }
+
+  @Test
+  public void irrelevantUsersAddedToAttentionSetAreIgnoredOnReply() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).current().review(ReviewInput.create().addUserToAttentionSet(user.email(), "reason"));
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void newReviewerCanBeAddedToTheAttentionSetManually() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(user.email())
+                .addUserToAttentionSet(user.email(), "reason")
+                .blockAutomaticAttentionSetRules());
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+        .isEqualTo(Operation.ADD);
+  }
+
+  @Test
+  public void newReviewerCanBeAddedToTheAttentionSetAutomatically() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).current().review(ReviewInput.create().reviewer(user.email()));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+        .isEqualTo(Operation.ADD);
+  }
+
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void onReplyCanAddInvisibleUsersToAttentionSetOnVisibleChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+
+    // admin is invisible to the user, but they can still add them to the attention set since they
+    // see the change.
+    change(r).current().review(ReviewInput.create().addUserToAttentionSet(admin.email(), "reason"));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+        .isEqualTo(Operation.ADD);
+  }
+
+  @Test
+  public void onReplyNonExistingUsersAreSilentlyIgnored() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r)
+        .current()
+        .review(ReviewInput.create().addUserToAttentionSet("INVALID USER", "reason"));
+    assertThat(getAttentionSetUpdates(r.getChange().getId())).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void canModifyAttentionSetForInvisibleUsersOnVisibleChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+
+    // admin is invisible to the user, but they can still add them to the attention set since they
+    // see the change.
+    change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+        .isEqualTo(Operation.ADD);
+
+    // admin is invisible to the user, but they can still remove them to the attention set since
+    // they see the change.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+        .isEqualTo(Operation.REMOVE);
+  }
+
+  @Test
   public void outsideAttentionSet_watchProjectEmailReceived() throws Exception {
     setEmailStrategyForUser(EmailStrategy.ATTENTION_SET_ONLY);
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index 264ced6..ad3a3c1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -253,7 +254,7 @@
     c.message = "comment 1";
     c.path = FILE_NAME;
 
-    ReviewInput reviewInput = new ReviewInput().label("Code-Review", 1);
+    ReviewInput reviewInput = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
     reviewInput.comments = ImmutableMap.of(c.path, Lists.newArrayList(c));
     reviewInput.message = changeMessage;
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMetaIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMetaIT.java
new file mode 100644
index 0000000..2cd04ed
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMetaIT.java
@@ -0,0 +1,150 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.inject.AbstractModule;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+/** Test handling of the NoteDb commit hash in the GetChange endpoint */
+public class ChangeMetaIT extends AbstractDaemonTest {
+  @Test
+  public void metaSha1_fromIndex() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+
+    try (AutoCloseable ignored = disableNoteDb()) {
+      ChangeInfo change =
+          Iterables.getOnlyElement(gApi.changes().query().withQuery("change:" + changeId).get());
+
+      try (Repository repo = repoManager.openRepository(project)) {
+        assertThat(change.metaRevId)
+            .isEqualTo(
+                repo.exactRef(changeMetaRef(Change.id(change._number))).getObjectId().getName());
+      }
+    }
+  }
+
+  @Test
+  public void metaSha1_fromNoteDb() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    ChangeInfo before = gApi.changes().id(changeId).get();
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(before.metaRevId)
+          .isEqualTo(
+              repo.exactRef(changeMetaRef(Change.id(before._number))).getObjectId().getName());
+    }
+  }
+
+  @Test
+  public void ChangeInfo_metaSha1_parameter() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).setMessage("before\n\n" + "Change-Id: " + result.getChangeId());
+    ChangeInfo before = gApi.changes().id(changeId).get();
+    gApi.changes().id(changeId).setMessage("after\n\n" + "Change-Id: " + result.getChangeId());
+    ChangeInfo after = gApi.changes().id(changeId).get();
+    assertThat(after.metaRevId).isNotEqualTo(before.metaRevId);
+
+    RestResponse resp = adminRestSession.get("/changes/" + changeId + "/?meta=" + before.metaRevId);
+    resp.assertOK();
+
+    ChangeInfo got;
+    try (JsonReader jsonReader = new JsonReader(resp.getReader())) {
+      jsonReader.setLenient(true);
+      got = newGson().fromJson(jsonReader, ChangeInfo.class);
+    }
+    assertThat(got.subject).isEqualTo(before.subject);
+  }
+
+  @Test
+  public void metaUnreachableSha1() throws Exception {
+    PushOneCommit.Result ch1 = createChange();
+    PushOneCommit.Result ch2 = createChange();
+
+    ChangeInfo info2 = gApi.changes().id(ch2.getChangeId()).get();
+
+    RestResponse resp =
+        adminRestSession.get("/changes/" + ch1.getChangeId() + "/?meta=" + info2.metaRevId);
+
+    resp.assertStatus(412);
+  }
+
+  protected static class PluginDefinedSimpleAttributeModule extends AbstractModule {
+    static class MyMetaHash extends PluginDefinedInfo {
+      String myMetaRef;
+    }
+
+    static PluginDefinedInfo newMyMetaHash(ChangeData cd) {
+      MyMetaHash mmh = new MyMetaHash();
+      mmh.myMetaRef = cd.notes().getMetaId().name();
+      return mmh;
+    }
+
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .toInstance(
+              (cds, bp, p) -> {
+                Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+                cds.forEach(cd -> out.put(cd.getId(), newMyMetaHash(cd)));
+                return out;
+              });
+    }
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void pluginDefinedAttribute() throws Exception {
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedSimpleAttributeModule.class)) {
+      PushOneCommit.Result result = createChange();
+      String changeId = result.getChangeId();
+      gApi.changes().id(changeId).setMessage("before\n\n" + "Change-Id: " + result.getChangeId());
+      ChangeInfo before = gApi.changes().id(changeId).get();
+      gApi.changes().id(changeId).setMessage("after\n\n" + "Change-Id: " + result.getChangeId());
+
+      RestResponse resp =
+          adminRestSession.get("/changes/" + changeId + "/?meta=" + before.metaRevId);
+      resp.assertOK();
+
+      Map<String, Object> changeInfo =
+          newGson().fromJson(resp.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
+      List<Object> plugins = (List<Object>) changeInfo.get("plugins");
+      Map<String, Object> myplugin = (Map<String, Object>) plugins.get(0);
+
+      assertThat(myplugin.get("my_meta_ref")).isEqualTo(before.metaRevId);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index 10194eb..6bffdf7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -146,8 +147,8 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(groupUUID).range(-2, 2))
-        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/heads/*"), exclusive)
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(groupUUID).range(-2, 2))
+        .setExclusiveGroup(labelPermissionKey(LabelId.CODE_REVIEW).ref("refs/heads/*"), exclusive)
         .update();
   }
 
@@ -156,7 +157,7 @@
         .project(project)
         .forUpdate()
         .add(
-            blockLabel("Code-Review")
+            blockLabel(LabelId.CODE_REVIEW)
                 .ref("refs/heads/*")
                 .group(SystemGroupBackend.CHANGE_OWNER)
                 .range(-2, 2))
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index a6bd5eb..f9493c2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -28,6 +28,7 @@
 
 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.RestResponse;
@@ -37,6 +38,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -283,7 +285,7 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER, user);
     assertReviewers(c, CC);
-    LabelInfo label = c.labels.get("Code-Review");
+    LabelInfo label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNotNull();
     assertThat(label.all).hasSize(1);
@@ -313,7 +315,7 @@
     assertReviewers(c, CC, user);
     // Verify no approvals were added.
     assertThat(c.labels).isNotNull();
-    LabelInfo label = c.labels.get("Code-Review");
+    LabelInfo label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNull();
   }
@@ -327,7 +329,7 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER);
     assertReviewers(c, CC);
-    LabelInfo label = c.labels.get("Code-Review");
+    LabelInfo label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNull();
 
@@ -342,7 +344,7 @@
     c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER, user);
     assertReviewers(c, CC);
-    label = c.labels.get("Code-Review");
+    label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNotNull();
     assertThat(label.all).hasSize(1);
@@ -366,7 +368,7 @@
     c = gApi.changes().id(r.getChangeId()).get();
     assertReviewers(c, REVIEWER, user);
     assertReviewers(c, CC);
-    label = c.labels.get("Code-Review");
+    label = c.labels.get(LabelId.CODE_REVIEW);
     assertThat(label).isNotNull();
     assertThat(label.all).isNotNull();
     assertThat(label.all).hasSize(1);
@@ -600,7 +602,7 @@
 
   @Test
   public void removingReviewerRemovesTheirVote() throws Exception {
-    String crLabel = "Code-Review";
+    String crLabel = LabelId.CODE_REVIEW;
     PushOneCommit.Result r = createChange();
     ReviewInput input = ReviewInput.approve().reviewer(admin.email());
     ReviewResult addResult = review(r.getChangeId(), r.getCommit().name(), input);
@@ -657,13 +659,124 @@
   }
 
   @Test
+  public void removeReviewerWithVoteOnMergedChangeForChangeOwnerFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 1));
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+  }
+
+  @Test
+  public void removeReviewerWithVoteOnMergedChangeForUserFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 1));
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+    assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+  }
+
+  @Test
+  public void removeReviewerWithoutVoteOnMergedChangeForChangeOwnerSucceeds() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(
+            Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).get().removableReviewers)
+                .email)
+        .isEqualTo(user.email());
+
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+
+    // Admin is a "reviewer" since the admin submitted the change, this ensures user is not a
+    // reviewer.
+    assertThat(
+            Iterables.getOnlyElement(
+                    gApi.changes().id(r.getChangeId()).get().reviewers.get(REVIEWER))
+                .email)
+        .doesNotMatch(user.email());
+  }
+
+  @Test
+  public void removeReviewerWithoutVoteOnMergedChangeForUserSucceeds() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThat(
+            Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).get().removableReviewers)
+                .email)
+        .isEqualTo(user.email());
+
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+
+    // Admin is a "reviewer" since the admin submitted the change, this ensures user is not a
+    // reviewer.
+    assertThat(
+            Iterables.getOnlyElement(
+                    gApi.changes().id(r.getChangeId()).get().reviewers.get(REVIEWER))
+                .email)
+        .doesNotMatch(user.email());
+  }
+
+  @Test
   public void removeReviewerWithVoteWithoutPermissionFails() throws Exception {
     PushOneCommit.Result r = createChange();
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Code-Review", 1));
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 1));
     requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
     AuthException thrown =
         assertThrows(
             AuthException.class,
@@ -687,17 +800,53 @@
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     assertThatUserIsOnlyReviewer(r.getChangeId());
     requestScopeOperations.setApiUser(newUser.id());
+    assertThat(
+            Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).get().removableReviewers)
+                .email)
+        .isEqualTo(user.email());
+
     gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
     assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
   }
 
   @Test
+  @Sandboxed
+  public void removeReviewerWithoutVoteOnAMergedChangeWithPermissionSucceeds() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // This test creates a new user so that it can explicitly check the REMOVE_REVIEWER permission
+    // rather than bypassing the check because of project or ref ownership.
+    TestAccount newUser = createAccounts(1, name("foo")).get(0);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+        .update();
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    requestScopeOperations.setApiUser(newUser.id());
+
+    // Ensures user is removable.
+    assertThat(
+            gApi.changes().id(r.getChangeId()).get().removableReviewers.stream()
+                .filter(a -> user.email().equals(a.email))
+                .findAny()
+                .isPresent())
+        .isTrue();
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+  }
+
+  @Test
   public void removeReviewerWithoutVoteWithoutPermissionFails() throws Exception {
     PushOneCommit.Result r = createChange();
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
 
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
     AuthException thrown =
         assertThrows(
             AuthException.class,
@@ -715,6 +864,8 @@
     input.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(input);
     requestScopeOperations.setApiUser(newUser.id());
+    assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
     AuthException thrown =
         assertThrows(
             AuthException.class,
@@ -789,7 +940,11 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .update();
 
     // Create a change and add 'user' as reviewer.
@@ -808,7 +963,7 @@
     requestScopeOperations.setApiUser(user.id());
     approve(changeId);
     c = gApi.changes().id(changeId).get();
-    assertThat(c.labels.get("Code-Review").approved._accountId).isEqualTo(user.id().get());
+    assertThat(c.labels.get(LabelId.CODE_REVIEW).approved._accountId).isEqualTo(user.id().get());
 
     // Move 'user' from reviewer to CC.
     requestScopeOperations.setApiUser(admin.id());
@@ -826,7 +981,7 @@
     assertThat(c.reviewers.get(REVIEWER)).isNull();
 
     // Verify that the approval of 'user' is still there.
-    assertThat(c.labels.get("Code-Review").approved._accountId).isEqualTo(user.id().get());
+    assertThat(c.labels.get(LabelId.CODE_REVIEW).approved._accountId).isEqualTo(user.id().get());
   }
 
   private void assertThatUserIsOnlyReviewer(String changeId) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
index 3b26459..d3dd801 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -204,7 +204,7 @@
   public void crossDomainPutTopic() throws Exception {
     Result change = createChange();
     BasicCookieStore cookies = new BasicCookieStore();
-    Executor http = Executor.newInstance().cookieStore(cookies);
+    Executor http = Executor.newInstance().use(cookies);
 
     Request req = Request.Get(canonicalWebUrl.get() + "/login/?account_id=" + admin.id().get());
     http.execute(req);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 7fe2a50..129b546 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.entities.Permission.CREATE;
 import static com.google.gerrit.entities.Permission.READ;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static com.google.gerrit.extensions.common.testing.GitPersonSubject.assertThat;
@@ -180,6 +182,77 @@
   }
 
   @Test
+  public void cannotCreateChangeOnGerritInternalRefs() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject";
+    ci.branch = "refs/changes/00/1000"; // disallowedRef
+
+    Throwable thrown = assertThrows(RestApiException.class, () -> gApi.changes().create(ci));
+    assertThat(thrown).hasMessageThat().contains("Cannot create a change on ref " + ci.branch);
+  }
+
+  @Test
+  public void cannotCreateChangeOnTagRefs() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject";
+    ci.branch = "refs/tags/v1.0"; // disallowed ref
+
+    Throwable thrown = assertThrows(RestApiException.class, () -> gApi.changes().create(ci));
+    assertThat(thrown).hasMessageThat().contains("Cannot create a change on ref " + ci.branch);
+  }
+
+  @Test
+  public void canCreateChangeOnRefsMetaConfig() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject";
+    ci.branch = RefNames.REFS_CONFIG;
+    assertThat(gApi.changes().create(ci).info().branch).isEqualTo(RefNames.REFS_CONFIG);
+  }
+
+  @Test
+  public void canCreateChangeOnRefsMetaDashboards() throws Exception {
+    String branchName = "refs/meta/dashboards/project_1";
+    requestScopeOperations.setApiUser(admin.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(CREATE).ref(branchName).group(REGISTERED_USERS))
+        .add(allow(READ).ref(branchName).group(REGISTERED_USERS))
+        .update();
+    BranchNameKey branchNameKey = BranchNameKey.create(project, branchName);
+    createBranch(branchNameKey);
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Subject";
+    ci.branch = branchName;
+    assertThat(gApi.changes().create(ci).info().branch).isEqualTo(branchName);
+  }
+
+  @Test
   public void cannotCreateChangeWithChangeIfOfExistingChangeOnSameBranch() throws Exception {
     String changeId = createChange().getChangeId();
 
@@ -708,6 +781,9 @@
     projectOperations
         .project(project)
         .forUpdate()
+        // Allow reading for refs/meta/config so that the project is visible to the user. Otherwise
+        // the request will fail with an UnprocessableEntityException "Project not found:".
+        .add(allow(READ).ref("refs/meta/config").group(REGISTERED_USERS))
         .add(block(READ).ref("refs/heads/*").group(REGISTERED_USERS))
         .update();
     requestScopeOperations.setApiUser(user.id());
@@ -731,6 +807,9 @@
     projectOperations
         .project(project)
         .forUpdate()
+        // Allow reading for refs/meta/config so that the project is visible to the user. Otherwise
+        // the request will fail with an UnprocessableEntityException "Project not found:".
+        .add(allow(READ).ref("refs/meta/config").group(REGISTERED_USERS))
         .add(block(READ).ref("refs/heads/*").group(REGISTERED_USERS))
         .update();
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index 25e5647..0c2c3a1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -92,7 +93,7 @@
     Map<String, Short> m =
         newGson().fromJson(response.getReader(), new TypeToken<Map<String, Short>>() {}.getType());
 
-    assertThat(m).containsExactly("Code-Review", Short.valueOf((short) 0));
+    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 0));
 
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
new file mode 100644
index 0000000..29dd227
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
@@ -0,0 +1,225 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+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.ChangeInfoDifference;
+import com.google.inject.Inject;
+import java.util.Collection;
+import org.junit.Test;
+
+public class GetMetaDiffIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  private static final String UNSAVED_REV_ID = "0000000000000000000000000000000000000001";
+  private static final String TOPIC = "topic";
+  private static final String HASHTAG = "hashtag";
+
+  @Test
+  public void metaDiff() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    chApi.topic(TOPIC);
+    ChangeInfo oldInfo = chApi.get();
+    chApi.topic(TOPIC + "-2");
+    chApi.setHashtags(new HashtagsInput(ImmutableSet.of(HASHTAG)));
+    ChangeInfo newInfo = chApi.get();
+
+    ChangeInfoDifference difference = chApi.metaDiff(oldInfo.metaRevId, newInfo.metaRevId);
+
+    assertThat(difference.added().topic).isEqualTo(newInfo.topic);
+    assertThat(difference.added().hashtags).isNotNull();
+    assertThat(difference.added().hashtags).containsExactly(HASHTAG);
+    assertThat(difference.removed().topic).isEqualTo(oldInfo.topic);
+    assertThat(difference.removed().hashtags).isNull();
+  }
+
+  @Test
+  public void metaDiffReturnsSuccessful() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeInfo info = gApi.changes().id(ch.getChangeId()).get();
+
+    RestResponse resp =
+        adminRestSession.get("/changes/" + ch.getChangeId() + "/meta_diff/?meta=" + info.metaRevId);
+
+    resp.assertOK();
+  }
+
+  @Test
+  public void metaDiffUnreachableNewSha1() throws Exception {
+    PushOneCommit.Result ch1 = createChange();
+    PushOneCommit.Result ch2 = createChange();
+
+    ChangeInfo info2 = gApi.changes().id(ch2.getChangeId()).get();
+
+    RestResponse resp =
+        adminRestSession.get(
+            "/changes/" + ch1.getChangeId() + "/meta_diff/?meta=" + info2.metaRevId);
+
+    resp.assertStatus(412);
+  }
+
+  @Test
+  public void metaDiffInvalidNewSha1() throws Exception {
+    PushOneCommit.Result ch = createChange();
+
+    RestResponse resp =
+        adminRestSession.get("/changes/" + ch.getChangeId() + "/meta_diff/?meta=invalid");
+
+    resp.assertBadRequest();
+  }
+
+  @Test
+  public void metaDiffInvalidOldSha1() throws Exception {
+    PushOneCommit.Result ch = createChange();
+
+    RestResponse resp =
+        adminRestSession.get("/changes/" + ch.getChangeId() + "/meta_diff/?old=invalid");
+
+    resp.assertBadRequest();
+  }
+
+  @Test
+  public void metaDiffWithNewSha1NotInRepo() throws Exception {
+    PushOneCommit.Result ch = createChange();
+
+    RestResponse resp =
+        adminRestSession.get("/changes/" + ch.getChangeId() + "/meta_diff/?meta=" + UNSAVED_REV_ID);
+
+    resp.assertStatus(412);
+  }
+
+  @Test
+  public void metaDiffUnreachableOldSha1UsesDefault() throws Exception {
+    PushOneCommit.Result ch1 = createChange();
+    PushOneCommit.Result ch2 = createChange();
+    gApi.changes().id(ch1.getChangeId()).topic("intermediate-topic");
+    gApi.changes().id(ch1.getChangeId()).topic(TOPIC);
+    ChangeInfo info1 = gApi.changes().id(ch1.getChangeId()).get();
+    ChangeInfo info2 = gApi.changes().id(ch2.getChangeId()).get();
+
+    ChangeInfoDifference difference =
+        gApi.changes().id(ch1.getChangeId()).metaDiff(info2.metaRevId, info1.metaRevId);
+
+    assertThat(difference.added().topic).isEqualTo(TOPIC);
+    assertThat(difference.removed().topic).isNull();
+  }
+
+  @Test
+  public void metaDiffWithOldSha1NotInRepoUsesDefault() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    gApi.changes().id(ch.getChangeId()).topic("intermediate-topic");
+    gApi.changes().id(ch.getChangeId()).topic(TOPIC);
+    ChangeInfo info = gApi.changes().id(ch.getChangeId()).get();
+
+    ChangeInfoDifference difference =
+        gApi.changes().id(ch.getChangeId()).metaDiff(UNSAVED_REV_ID, info.metaRevId);
+
+    assertThat(difference.added().topic).isEqualTo(TOPIC);
+    assertThat(difference.removed().topic).isNull();
+  }
+
+  @Test
+  public void metaDiffNoOldMetaGivenUsesPatchSetBeforeNew() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    chApi.topic(TOPIC);
+    ChangeInfo newInfo = chApi.get();
+    chApi.topic(TOPIC + "2");
+
+    ChangeInfoDifference difference = chApi.metaDiff(null, newInfo.metaRevId);
+
+    assertThat(difference.added().topic).isEqualTo(TOPIC);
+    assertThat(difference.removed().topic).isNull();
+  }
+
+  @Test
+  public void metaDiffNoNewMetaGivenUsesCurrentPatchSet() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    ChangeInfo oldInfo = chApi.get();
+    chApi.topic(TOPIC);
+
+    ChangeInfoDifference difference = chApi.metaDiff(oldInfo.metaRevId, null);
+
+    assertThat(difference.added().topic).isEqualTo(TOPIC);
+    assertThat(difference.removed().topic).isNull();
+  }
+
+  @Test
+  public void metaDiffWithoutOptionDoesNotIncludeExtraInformation() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    ChangeInfo oldInfo = chApi.get();
+    amendChange(ch.getChangeId());
+    ChangeInfo newInfo = chApi.get();
+
+    ChangeInfoDifference difference = chApi.metaDiff(oldInfo.metaRevId, newInfo.metaRevId);
+
+    assertThat(difference.added().currentRevision).isNull();
+    assertThat(difference.removed().currentRevision).isNull();
+  }
+
+  @Test
+  public void metaDiffWithOptionIncludesExtraInformation() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    ChangeInfo oldInfo = chApi.get(ListChangesOption.CURRENT_REVISION);
+    amendChange(ch.getChangeId());
+    ChangeInfo newInfo = chApi.get(ListChangesOption.CURRENT_REVISION);
+
+    ChangeInfoDifference difference =
+        chApi.metaDiff(
+            oldInfo.metaRevId,
+            newInfo.metaRevId,
+            ImmutableSet.of(ListChangesOption.CURRENT_REVISION));
+
+    assertThat(newInfo.currentRevision).isNotNull();
+    assertThat(oldInfo.currentRevision).isNotNull();
+    assertThat(difference.added().currentRevision).isEqualTo(newInfo.currentRevision);
+    assertThat(difference.removed().currentRevision).isEqualTo(oldInfo.currentRevision);
+  }
+
+  @Test
+  public void staticField() throws Exception {
+    PushOneCommit.Result result = createChange();
+    ReviewInput in = new ReviewInput();
+    in.message("hello");
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(result.getChangeId()).revision("current").review(in);
+    ChangeApi chApi = gApi.changes().id(result.getChangeId());
+    ChangeInfoDifference difference = chApi.metaDiff(null, null, ListChangesOption.LABELS);
+    assertThat(difference.added().reviewers).containsKey(ReviewerState.CC);
+    assertThat(difference.added().reviewers).hasSize(1);
+    Collection<AccountInfo> reviewers = difference.added().reviewers.get(ReviewerState.CC);
+    assertThat(reviewers).hasSize(1);
+    AccountInfo info = reviewers.iterator().next();
+    assertThat(info._accountId).isEqualTo(user.id().get());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/LifecycleListenersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/LifecycleListenersIT.java
new file mode 100644
index 0000000..59914bc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/LifecycleListenersIT.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractLifecycleListenersTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+public class LifecycleListenersIT extends AbstractLifecycleListenersTest {
+  @Inject private InvocationCheck invocationCheck;
+
+  @Before
+  public void before() {
+    invocationCheck.setStartInvoked(false);
+    invocationCheck.setStopInvoked(false);
+  }
+
+  @Test
+  public void lifecycleListenerSuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      RestResponse response = adminRestSession.get("/changes/?--my-plugin--opt&q=status:open");
+      response.assertOK();
+      assertTrue(invocationCheck.isStartInvoked());
+      assertTrue(invocationCheck.isStopInvoked());
+    }
+  }
+
+  @Test
+  public void lifecycleListenerUnsuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      RestResponse response = adminRestSession.get("/projects/");
+      response.assertOK();
+      assertFalse(invocationCheck.isStartInvoked());
+      assertFalse(invocationCheck.isStopInvoked());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 987d646..69f1d8e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -16,20 +16,25 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.MoveInput;
@@ -188,7 +193,7 @@
         .update();
     AuthException thrown =
         assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
-    assertThat(thrown).hasMessageThat().contains("move not permitted");
+    assertThat(thrown).hasMessageThat().isEqualTo("move not permitted");
   }
 
   @Test
@@ -208,7 +213,7 @@
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
         assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
-    assertThat(thrown).hasMessageThat().contains("move not permitted");
+    assertThat(thrown).hasMessageThat().isEqualTo("move not permitted");
   }
 
   @Test
@@ -267,12 +272,230 @@
   }
 
   @Test
+  public void moveChangeKeepAllVotesOnlyAllowedForAdmins() throws Exception {
+    // Keep all votes options is only permitted for admins.
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+
+    // Grant change permissions to the registered users.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(destinationBranch.branch()).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref(sourceBranch.branch()).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> move(changeId, destinationBranch.shortName(), true));
+    assertThat(thrown).hasMessageThat().isEqualTo("move is not permitted with keepAllVotes option");
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    move(changeId, destinationBranch.branch(), true);
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+  }
+
+  @Test
+  public void moveChangeKeepAllVotesNoLabelInDestination() throws Exception {
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+
+    String testLabelA = "Label-A";
+    // The label has the range [-1; 1]
+    configLabel(testLabelA, LabelFunction.NO_BLOCK, ImmutableList.of(sourceBranch.branch()));
+    // Registered users have permissions for the entire range [-1; 1] on all branches.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .update();
+
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput userReviewInput = new ReviewInput();
+    userReviewInput.label(testLabelA, 1);
+    gApi.changes().id(changeId).current().review(userReviewInput);
+
+    assertLabelVote(user, changeId, testLabelA, (short) 1);
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(atrScope.get().getUser().getAccountId()).isEqualTo(admin.id());
+
+    // Move the change to the destination branch.
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    move(changeId, destinationBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+
+    // Label is missing in the destination branch.
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes()).isEmpty();
+
+    // Move the change back to the source, the label is kept.
+    move(changeId, sourceBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    assertLabelVote(user, changeId, testLabelA, (short) 1);
+  }
+
+  @Test
+  public void moveChangeKeepAllVotesOutOfUserPermissionRange() throws Exception {
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+
+    String testLabelA = "Label-A";
+    // The label has the range [-2; 2]
+    configLabel(
+        project,
+        testLabelA,
+        LabelFunction.NO_BLOCK,
+        value(2, "Passes"),
+        value(1, "Mostly ok"),
+        value(0, "No score"),
+        value(-1, "Needs work"),
+        value(-2, "Failed"));
+    // Registered users have [-2; 2] permissions on the source.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(testLabelA).ref(sourceBranch.branch()).group(REGISTERED_USERS).range(-2, +2))
+        .update();
+
+    // Registered users have [-1; 1] permissions on the destination.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(testLabelA)
+                .ref(destinationBranch.branch())
+                .group(REGISTERED_USERS)
+                .range(-1, +1))
+        .update();
+
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+    requestScopeOperations.setApiUser(user.id());
+    // Vote within the range of the source branch.
+    ReviewInput userReviewInput = new ReviewInput();
+    userReviewInput.label(testLabelA, 2);
+    gApi.changes().id(changeId).current().review(userReviewInput);
+
+    assertLabelVote(user, changeId, testLabelA, (short) 2);
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(atrScope.get().getUser().getAccountId()).isEqualTo(admin.id());
+
+    // Move the change to the destination branch.
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    move(changeId, destinationBranch.branch(), true);
+    // User does not have label permissions for the same vote on the destination branch.
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(changeId).current().review(userReviewInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(String.format("Applying label \"%s\": 2 is restricted", testLabelA));
+
+    // Label is kept even though the user's permission range is different from the source.
+    // Since we do not squash users votes based on the destination branch access label
+    // configuration, this is working as intended.
+    // It's the same behavior as when a project owner reduces user's permission range on label.
+    // Administrators should take this into account.
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+    assertLabelVote(user, changeId, testLabelA, (short) 2);
+
+    requestScopeOperations.setApiUser(admin.id());
+    // Move the change back to the source, the label is kept.
+    move(changeId, sourceBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    assertLabelVote(user, changeId, testLabelA, (short) 2);
+  }
+
+  @Test
+  public void moveKeepAllVotesCanMoveAllInRange() throws Exception {
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+
+    // The non-block label has the range [-2; 2]
+    String testLabelA = "Label-A";
+    configLabel(
+        project,
+        testLabelA,
+        LabelFunction.NO_BLOCK,
+        value(2, "Passes"),
+        value(1, "Mostly ok"),
+        value(0, "No score"),
+        value(-1, "Needs work"),
+        value(-2, "Failed"));
+
+    // Registered users have [-2; 2] permissions on all branches.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
+
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+
+    for (int vote = -2; vote <= 2; vote++) {
+      TestAccount testUser = accountCreator.create("TestUser" + vote);
+      requestScopeOperations.setApiUser(testUser.id());
+      ReviewInput userReviewInput = new ReviewInput();
+      userReviewInput.label(testLabelA, vote);
+      gApi.changes().id(changeId).current().review(userReviewInput);
+    }
+
+    assertThat(
+            gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
+                .map(approvalInfo -> approvalInfo.value)
+                .collect(ImmutableList.toImmutableList()))
+        .containsExactly(-2, -1, 1, 2);
+
+    requestScopeOperations.setApiUser(admin.id());
+    // Move the change to the destination branch.
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    move(changeId, destinationBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+
+    // All votes are kept
+    assertThat(
+            gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
+                .map(approvalInfo -> approvalInfo.value)
+                .collect(ImmutableList.toImmutableList()))
+        .containsExactly(-2, -1, 1, 2);
+
+    // Move the change back to the source, the label is kept.
+    move(changeId, sourceBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    assertThat(
+            gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
+                .map(approvalInfo -> approvalInfo.value)
+                .collect(ImmutableList.toImmutableList()))
+        .containsExactly(-2, -1, 1, 2);
+  }
+
+  @Test
   public void moveChangeOnlyKeepVetoVotes() throws Exception {
     // A vote for a label will be kept after moving if the label's function is *WithBlock and the
     // vote holds the minimum value.
     createBranch(BranchNameKey.create(project, "foo"));
 
-    String codeReviewLabel = "Code-Review"; // 'Code-Review' uses 'MaxWithBlock' function.
+    String codeReviewLabel = LabelId.CODE_REVIEW; // 'Code-Review' uses 'MaxWithBlock' function.
     String testLabelA = "Label-A";
     String testLabelB = "Label-B";
     String testLabelC = "Label-C";
@@ -392,10 +615,28 @@
     gApi.changes().id(changeId).move(in);
   }
 
+  private void move(String changeId, String destination, boolean keepAllVotes)
+      throws RestApiException {
+    MoveInput in = new MoveInput();
+    in.destinationBranch = destination;
+    in.keepAllVotes = keepAllVotes;
+    gApi.changes().id(changeId).move(in);
+  }
+
   private PushOneCommit.Result createChange(String branch, String changeId) throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, changeId);
     PushOneCommit.Result result = push.to("refs/for/" + branch);
     result.assertOkStatus();
     return result;
   }
+
+  private PushOneCommit.Result createChangeInBranch(String branch) throws Exception {
+    return createChange("refs/for/" + branch);
+  }
+
+  private void assertLabelVote(TestAccount user, String changeId, String label, short vote)
+      throws Exception {
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes())
+        .containsEntry(label, vote);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
index 17bf37e..a4ec40e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
@@ -35,41 +33,6 @@
   private static final Gson GSON = OutputFormat.JSON.newGson();
 
   @Test
-  public void queryChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))));
-  }
-
-  @Test
-  public void getChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))));
-  }
-
-  @Test
-  public void getChangeDetailWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
-  }
-
-  @Test
-  public void queryChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))));
-  }
-
-  @Test
-  public void getChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))));
-  }
-
-  @Test
-  public void getChangeDetailWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
-  }
-
-  @Test
   public void querySingleChangeWithBulkAttribute() throws Exception {
     getSingleChangeWithPluginDefinedBulkAttribute(
         id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))));
@@ -88,27 +51,6 @@
   }
 
   @Test
-  public void queryChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))),
-        (id, opts) -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id, opts))));
-  }
-
-  @Test
-  public void getChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))),
-        (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id, opts))));
-  }
-
-  @Test
-  public void getChangeDetailWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))),
-        (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
-  }
-
-  @Test
   public void pluginDefinedQueryChangeWithOption() throws Exception {
     getChangeWithPluginDefinedBulkAttributeOption(
         id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))),
@@ -142,12 +84,6 @@
   }
 
   @Test
-  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
-    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
-  }
-
-  @Test
   public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
     getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
         () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
@@ -204,24 +140,6 @@
   }
 
   @Nullable
-  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(RestResponse res)
-      throws Exception {
-    res.assertOK();
-    List<Map<String, Object>> changeInfos =
-        GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
-    assertThat(changeInfos).hasSize(1);
-    return decodeRawPluginsList(GSON, changeInfos.get(0).get("plugins"));
-  }
-
-  @Nullable
-  private List<PluginDefinedInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
-    res.assertOK();
-    Map<String, Object> changeInfo =
-        GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
-    return decodeRawPluginsList(GSON, changeInfo.get("plugins"));
-  }
-
-  @Nullable
   private Map<Change.Id, List<PluginDefinedInfo>> pluginInfoMapFromChangeInfo(RestResponse res)
       throws Exception {
     res.assertOK();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 6f519f1..fff3cb6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -25,12 +25,14 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.inject.Inject;
 import java.util.List;
@@ -88,12 +90,18 @@
   @Test
   public void changeMessageOnSubmit() throws Throwable {
     PushOneCommit.Result change = createChange();
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                (newCommitMessage, original, mergeTip, destination) ->
-                    newCommitMessage + "Custom: " + destination.branch())) {
+    ChangeMessageModifier link =
+        new ChangeMessageModifier() {
+          @Override
+          public String onSubmit(
+              String newCommitMessage,
+              RevCommit original,
+              RevCommit mergeTip,
+              BranchNameKey destination) {
+            return newCommitMessage + "Custom: " + destination.branch();
+          }
+        };
+    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
       submit(change.getChangeId());
     }
     testRepo.git().fetch().setRemote("origin").call();
@@ -373,4 +381,26 @@
         change2.getChangeId(),
         headAfterFirstSubmit.name());
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetNotPreventingCherryPick() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    assertSubmittable(change2Result.getChangeId());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 1912697..66eb48c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
@@ -170,4 +171,49 @@
     assertRefUpdatedEvents(initialHead, headAfterSubmit);
     assertChangeMergedEvents(id1, headAfterSubmit.name());
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetPreventsFastForward() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    // submit button is disabled.
+    assertSubmitDisabled(change2Result.getChangeId());
+
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 2 changes due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + ", which is outdated patch set "
+            + patchSetId.get()
+            + " of change "
+            + changeResult.getChange().getId()
+            + ". The latest patch set is "
+            + changeResult.getPatchSetId().get()
+            + ".");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 5fe741d..157c93c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -534,48 +534,6 @@
   }
 
   @Test
-  public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
-    // Create a change
-    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
-    PushOneCommit.Result changeResult = change.to("refs/for/master");
-    PatchSet.Id patchSetId = changeResult.getPatchSetId();
-
-    // Create a successor change.
-    PushOneCommit change2 =
-        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
-    PushOneCommit.Result change2Result = change2.to("refs/for/master");
-
-    // Create new patch set for first change.
-    testRepo.reset(changeResult.getCommit().name());
-    amendChange(changeResult.getChangeId());
-
-    // Approve both changes
-    approve(changeResult.getChangeId());
-    approve(change2Result.getChangeId());
-
-    submitWithConflict(
-        change2Result.getChangeId(),
-        "Failed to submit 2 changes due to the following problems:\n"
-            + "Change "
-            + change2Result.getChange().getId()
-            + ": Depends on change that was not submitted."
-            + " Commit "
-            + change2Result.getCommit().name()
-            + " depends on commit "
-            + changeResult.getCommit().name()
-            + ", which is outdated patch set "
-            + patchSetId.get()
-            + " of change "
-            + changeResult.getChange().getId()
-            + ". The latest patch set is "
-            + changeResult.getPatchSetId().get()
-            + ".");
-
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents();
-  }
-
-  @Test
   public void dependencyOnDeletedChangePreventsMerge() throws Throwable {
     // Create a change
     PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
@@ -614,7 +572,11 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
@@ -676,7 +638,11 @@
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
@@ -733,13 +699,21 @@
     projectOperations
         .project(p1)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .update();
     projectOperations
         .project(p2)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, 2))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index d742fad..eeeac2a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -129,7 +129,8 @@
     ChangeMessageModifier modifier2 = (msg, orig, tip, dest) -> msg + "A-footer: value\n";
     try (Registration registration =
         extensionRegistry.newRegistration().add(modifier1).add(modifier2)) {
-      StorageException thrown = assertThrows(StorageException.class, () -> submitWithRebase());
+      InternalServerWithUserMessageException thrown =
+          assertThrows(InternalServerWithUserMessageException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause).hasMessageThat().isEqualTo("boom");
@@ -145,7 +146,8 @@
             .newRegistration()
             .add(modifier1, "modifier-1")
             .add(modifier2, "modifier-2")) {
-      StorageException thrown = assertThrows(StorageException.class, () -> submitWithRebase());
+      InternalServerWithUserMessageException thrown =
+          assertThrows(InternalServerWithUserMessageException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause)
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index cef66654..c893214 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -58,7 +58,6 @@
   @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password")
 
   // change
-  @GerritConfig(name = "change.largeChange", value = "300")
   @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments")
   @GerritConfig(name = "change.replyLabel", value = "Vote")
   @GerritConfig(name = "change.updateDelay", value = "50s")
@@ -75,6 +74,7 @@
   @GerritConfig(name = "gerrit.allProjects", value = "Root")
   @GerritConfig(name = "gerrit.allUsers", value = "Users")
   @GerritConfig(name = "gerrit.reportBugUrl", value = "https://example.com/report")
+  @GerritConfig(name = "gerrit.instanceId", value = "devops-instance")
 
   // suggest
   @GerritConfig(name = "suggest.from", value = "3")
@@ -102,7 +102,6 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.largeChange).isEqualTo(300);
     assertThat(i.change.replyTooltip).startsWith("Publish votes and draft comments");
     assertThat(i.change.replyLabel).isEqualTo("Vote\u2026");
     assertThat(i.change.updateDelay).isEqualTo(50);
@@ -118,6 +117,7 @@
     assertThat(i.gerrit.allProjects).isEqualTo("Root");
     assertThat(i.gerrit.allUsers).isEqualTo("Users");
     assertThat(i.gerrit.reportBugUrl).isEqualTo("https://example.com/report");
+    assertThat(i.gerrit.instanceId).isEqualTo("devops-instance");
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
@@ -170,14 +170,12 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.largeChange).isEqualTo(500);
     assertThat(i.change.replyTooltip).startsWith("Reply and score");
     assertThat(i.change.replyLabel).isEqualTo("Reply\u2026");
     assertThat(i.change.updateDelay).isEqualTo(300);
     assertThat(i.change.disablePrivateChanges).isNull();
     assertThat(i.change.submitWholeTopic).isNull();
-    assertThat(i.change.mergeabilityComputationBehavior)
-        .isEqualTo("API_REF_UPDATED_AND_CHANGE_REINDEX");
+    assertThat(i.change.mergeabilityComputationBehavior).isEqualTo("NEVER");
 
     // download
     assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
@@ -187,6 +185,7 @@
     assertThat(i.gerrit.allProjects).isEqualTo(AllProjectsNameProvider.DEFAULT);
     assertThat(i.gerrit.allUsers).isEqualTo(AllUsersNameProvider.DEFAULT);
     assertThat(i.gerrit.reportBugUrl).isNull();
+    assertThat(i.gerrit.instanceId).isNull();
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
index 9c17a5a..4453345 100644
--- a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gson.reflect.TypeToken;
 import java.util.Map;
 import org.junit.Test;
@@ -32,6 +33,7 @@
     Map<String, GroupInfo> groupMap =
         newGson()
             .fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
-    assertThat(groupMap.keySet()).containsExactly("Administrators", "Service Users");
+    assertThat(groupMap.keySet())
+        .containsExactly("Administrators", ServiceUserClassifier.SERVICE_USERS);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 908f17a..7442425 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -81,8 +82,9 @@
 
   private static final String REFS_ALL = Constants.R_REFS + "*";
   private static final String REFS_HEADS = Constants.R_HEADS + "*";
-
-  private static final String LABEL_CODE_REVIEW = "Code-Review";
+  private static final String REFS_META_VERSION = "refs/meta/version";
+  private static final String REFS_DRAFTS = "refs/draft-comments/*";
+  private static final String REFS_STARRED_CHANGES = "refs/starred-changes/*";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -418,13 +420,13 @@
     // Remove specific permission
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
     accessSectionToRemove.permissions.put(
-        Permission.LABEL + LABEL_CODE_REVIEW, newPermissionInfo());
+        Permission.LABEL + LabelId.CODE_REVIEW, newPermissionInfo());
     ProjectAccessInput removal = newProjectAccessInput();
     removal.remove.put(REFS_HEADS, accessSectionToRemove);
     pApi().access(removal);
 
     // Remove locally
-    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
 
     // Check
     assertThat(pApi().access().local).isEqualTo(accessInput.add);
@@ -442,10 +444,10 @@
     // Remove specific permission rule
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
     PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LABEL_CODE_REVIEW;
+    codeReview.label = LabelId.CODE_REVIEW;
     PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
     codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionToRemove.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
+    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
     ProjectAccessInput removal = newProjectAccessInput();
     removal.remove.put(REFS_HEADS, accessSectionToRemove);
     pApi().access(removal);
@@ -455,7 +457,7 @@
         .add
         .get(REFS_HEADS)
         .permissions
-        .get(Permission.LABEL + LABEL_CODE_REVIEW)
+        .get(Permission.LABEL + LabelId.CODE_REVIEW)
         .rules
         .remove(SystemGroupBackend.REGISTERED_USERS.get());
 
@@ -475,18 +477,18 @@
     // Remove specific permission rules
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
     PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LABEL_CODE_REVIEW;
+    codeReview.label = LabelId.CODE_REVIEW;
     PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
     codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
     pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
     codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSectionToRemove.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
+    accessSectionToRemove.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
     ProjectAccessInput removal = newProjectAccessInput();
     removal.remove.put(REFS_HEADS, accessSectionToRemove);
     pApi().access(removal);
 
     // Remove locally
-    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LABEL_CODE_REVIEW);
+    accessInput.add.get(REFS_HEADS).permissions.remove(Permission.LABEL + LabelId.CODE_REVIEW);
 
     // Check
     assertThat(pApi().access().local).isEqualTo(accessInput.add);
@@ -499,7 +501,10 @@
     AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
 
     // Disallow READ
-    accessInput.add.put(REFS_ALL, accessSectionInfo);
+    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
+    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
+    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
     pApi().access(accessInput);
 
     requestScopeOperations.setApiUser(user.id());
@@ -513,7 +518,10 @@
     AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
 
     // Disallow READ
-    accessInput.add.put(REFS_ALL, accessSectionInfo);
+    accessInput.add.put(REFS_META_VERSION, accessSectionInfo);
+    accessInput.add.put(REFS_DRAFTS, accessSectionInfo);
+    accessInput.add.put(REFS_STARRED_CHANGES, accessSectionInfo);
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
     pApi().access(accessInput);
 
     // Create a change to apply
@@ -1056,7 +1064,7 @@
     accessSection.permissions.put(Permission.PUSH, push);
 
     PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LABEL_CODE_REVIEW;
+    codeReview.label = LabelId.CODE_REVIEW;
     pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
     codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
 
@@ -1064,7 +1072,7 @@
     pri.max = 1;
     pri.min = -1;
     codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSection.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
+    accessSection.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
 
     return accessSection;
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index 5e1fc83..edcb1f9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -20,7 +20,6 @@
         "LabelAssert.java",
     ],
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
index a2f976a..b0320f6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
@@ -233,7 +233,11 @@
         .setRefSpecs(new RefSpec("HEAD:refs/heads/master"))
         .call();
 
-    assertBadRequest("master", "fdsafsdf", "recursive", "Cannot resolve 'fdsafsdf' to a commit");
+    assertBadRequest(
+        "master",
+        "fdsafsdf",
+        "recursive",
+        "Error resolving: 'fdsafsdf'. Do not have read permission, or failed to resolve to a commit.");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 096c72b..93ce255 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -207,7 +207,7 @@
   }
 
   @Test
-  public void createUserBranch_Conflict() throws Exception {
+  public void createUserBranch_NotAllowed() throws Exception {
     projectOperations
         .project(allUsers)
         .forUpdate()
@@ -217,12 +217,12 @@
     assertCreateFails(
         BranchNameKey.create(allUsers, RefNames.refsUsers(Account.id(1))),
         RefNames.refsUsers(admin.id()),
-        ResourceConflictException.class,
-        "Not allowed to create user branch.");
+        BadRequestException.class,
+        "Not allowed to create branches under Gerrit internal or tags refs.");
   }
 
   @Test
-  public void createGroupBranch_Conflict() throws Exception {
+  public void createGroupBranch_NotAllowed() throws Exception {
     projectOperations
         .project(allUsers)
         .forUpdate()
@@ -232,8 +232,8 @@
     assertCreateFails(
         BranchNameKey.create(allUsers, RefNames.refsGroups(AccountGroup.uuid("foo"))),
         RefNames.refsGroups(adminGroupUuid()),
-        ResourceConflictException.class,
-        "Not allowed to create group branch.");
+        BadRequestException.class,
+        "Not allowed to create branches under Gerrit internal or tags refs.");
   }
 
   @Test
@@ -355,6 +355,22 @@
   }
 
   @Test
+  public void cannotCreateBranchInGerritInternalRefsNamespace() throws Exception {
+    assertCreateFails(
+        BranchNameKey.create(project, RefNames.REFS_CHANGES + "00/1000"),
+        BadRequestException.class,
+        "Not allowed to create branches under Gerrit internal or tags refs.");
+  }
+
+  @Test
+  public void cannotCreateBranchInTagsNamespace() throws Exception {
+    assertCreateFails(
+        BranchNameKey.create(project, RefNames.REFS_TAGS + "v1.0"),
+        BadRequestException.class,
+        "Not allowed to create branches under Gerrit internal or tags refs.");
+  }
+
+  @Test
   public void cannotCreateBranchWithInvalidName() throws Exception {
     assertCreateFails(
         BranchNameKey.create(project, RefNames.REFS_HEADS),
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index 94511f8..6a98b8b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -95,7 +96,7 @@
             () ->
                 gApi.projects()
                     .name(allProjects.get())
-                    .label("Code-Review")
+                    .label(LabelId.CODE_REVIEW)
                     .create(new LabelDefinitionInput()));
     assertThat(thrown).hasMessageThat().contains("label Code-Review already exists");
   }
@@ -240,6 +241,7 @@
     assertThat(createdLabel.copyAnyScore).isNull();
     assertThat(createdLabel.copyMinScore).isNull();
     assertThat(createdLabel.copyMaxScore).isNull();
+    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
     assertThat(createdLabel.copyAllScoresIfNoChange).isTrue();
     assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
@@ -450,6 +452,28 @@
   }
 
   @Test
+  public void createWithCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfListOfFilesDidNotChange = true;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
+  }
+
+  @Test
+  public void createWithoutCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyAllScoresIfListOfFilesDidNotChange = false;
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
+  }
+
+  @Test
   public void createWithCopyAllScoresIfNoChange() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 10fd65f..7090074 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -26,6 +26,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.net.HttpHeaders;
@@ -40,6 +41,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -47,12 +49,14 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import java.util.Collections;
 import java.util.Optional;
@@ -303,13 +307,15 @@
   }
 
   @Test
-  public void createProjectWithEmptyCommit() throws Exception {
+  @GerritConfig(name = "gerrit.defaultBranch", value = "main")
+  public void createPermissionOnlyProject_WhenDefaultBranchIsSet() throws Exception {
     String newProjectName = name("newProject");
     ProjectInput in = new ProjectInput();
     in.name = newProjectName;
-    in.createEmptyCommit = true;
+    in.permissionsOnly = true;
     gApi.projects().create(in);
-    assertEmptyCommit(newProjectName, "refs/heads/master");
+    // For permissionOnly, don't use host-level default branch.
+    assertHead(newProjectName, RefNames.REFS_CONFIG);
   }
 
   @Test
@@ -328,6 +334,87 @@
   }
 
   @Test
+  public void createProjectWithInvalidBranch() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInput in = new ProjectInput();
+    in.name = newProjectName;
+    in.createEmptyCommit = true;
+    in.branches = ImmutableList.of("refs/heads/test", "refs/changes/34/1234");
+    Throwable thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.projects().create(in));
+    assertThat(thrown).hasCauseThat().isInstanceOf(ValidationException.class);
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot create a project with branch refs/changes/34/1234");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "main")
+  public void createProject_WhenDefaultBranchIsSet() throws Exception {
+    String newProjectName = name("newProject");
+    gApi.projects().create(newProjectName).get();
+    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");
+    assertHead(newProjectName, "refs/heads/main");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "main")
+  public void createProjectWithEmptyCommit_WhenDefaultBranchIsSet() throws Exception {
+    String newProjectName = name("newProject");
+    ProjectInput in = new ProjectInput();
+    in.name = newProjectName;
+    in.createEmptyCommit = true;
+    gApi.projects().create(in);
+    ImmutableMap<String, BranchInfo> branches = getProjectBranches(newProjectName);
+    // HEAD symbolic ref is set to the default, and the actual ref is created.
+    assertThat(branches.keySet()).containsExactly("HEAD", "refs/meta/config", "refs/heads/main");
+    assertHead(newProjectName, "refs/heads/main");
+    assertEmptyCommit(newProjectName, "HEAD", "refs/heads/main");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "refs/heads/main")
+  public void createProject_WhenDefaultBranchIsSet_WithBranches() throws Exception {
+    // Host-level default only applies if no branches were passed in the input
+    String newProjectName = name("newProject");
+    ProjectInput in = new ProjectInput();
+    in.name = newProjectName;
+    in.createEmptyCommit = true;
+    in.branches = ImmutableList.of("refs/heads/test", "release");
+    gApi.projects().create(in);
+    ImmutableMap<String, BranchInfo> branches = getProjectBranches(newProjectName);
+    assertThat(branches.keySet())
+        .containsExactly("HEAD", "refs/meta/config", "refs/heads/test", "refs/heads/release");
+    assertHead(newProjectName, "refs/heads/test");
+    assertEmptyCommit(newProjectName, "refs/heads/test", "refs/heads/release");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "refs/users/self")
+  public void createProject_WhenDefaultBranchIsSet_ToGerritRef() throws Exception {
+    String newProjectName = name("newProject");
+    Throwable thrown =
+        assertThrows(ResourceConflictException.class, () -> gApi.projects().create(newProjectName));
+    assertThat(thrown).hasCauseThat().isInstanceOf(ValidationException.class);
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Cannot create a project with branch refs/users/self");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "refs~main")
+  public void createProject_WhenDefaultBranchIsSet_ToInvalidBranch() throws Exception {
+    String newProjectName = name("newProject");
+    Throwable thrown =
+        assertThrows(BadRequestException.class, () -> gApi.projects().create(newProjectName));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Branch \"refs/heads/refs~main\" is not a valid name.");
+  }
+
+  @Test
   public void createProjectWithCapability() throws Exception {
     projectOperations
         .allProjectsForUpdate()
@@ -478,12 +565,6 @@
     assertThat(cfg.defaultSubmitType.inheritedValue).isEqualTo(SubmitType.MERGE_ALWAYS);
   }
 
-  private void assertHead(String projectName, String expectedRef) throws Exception {
-    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
-      assertThat(repo.exactRef(Constants.HEAD).getTarget().getName()).isEqualTo(expectedRef);
-    }
-  }
-
   private void assertEmptyCommit(String projectName, String... refs) throws Exception {
     Project.NameKey projectKey = Project.nameKey(projectName);
     try (Repository repo = repoManager.openRepository(projectKey);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index c98a58e..ce92536 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -128,6 +128,7 @@
     projectOperations
         .project(project)
         .forUpdate()
+        .add(allow(Permission.READ).ref(metaRef).group(REGISTERED_USERS))
         .add(allow(Permission.CREATE).ref(metaRef).group(REGISTERED_USERS))
         .add(allow(Permission.PUSH).ref(metaRef).group(REGISTERED_USERS))
         .update();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
index 57c7b17..c2db9f1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -39,7 +40,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").delete());
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete());
     assertThat(thrown).hasMessageThat().contains("Authentication required");
   }
 
@@ -55,7 +56,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").delete());
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete());
     assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
   }
 
@@ -70,18 +71,18 @@
 
   @Test
   public void delete() throws Exception {
-    gApi.projects().name(allProjects.get()).label("Code-Review").delete();
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete();
 
     ResourceNotFoundException thrown =
         assertThrows(
             ResourceNotFoundException.class,
-            () -> gApi.projects().name(project.get()).label("Code-Review").get());
+            () -> gApi.projects().name(project.get()).label(LabelId.CODE_REVIEW).get());
     assertThat(thrown).hasMessageThat().contains("Not found: Code-Review");
   }
 
   @Test
   public void defaultCommitMessage() throws Exception {
-    gApi.projects().name(allProjects.get()).label("Code-Review").delete();
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete();
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Delete label");
@@ -89,7 +90,10 @@
 
   @Test
   public void withCommitMessage() throws Exception {
-    gApi.projects().name(allProjects.get()).label("Code-Review").delete("Delete Code-Review label");
+    gApi.projects()
+        .name(allProjects.get())
+        .label(LabelId.CODE_REVIEW)
+        .delete("Delete Code-Review label");
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Delete Code-Review label");
@@ -99,7 +103,7 @@
   public void commitMessageIsTrimmed() throws Exception {
     gApi.projects()
         .name(allProjects.get())
-        .label("Code-Review")
+        .label(LabelId.CODE_REVIEW)
         .delete(" Delete Code-Review label ");
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
new file mode 100644
index 0000000..74ba48e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.server.restapi.project.FilesInCommitCollection;
+import java.util.Map;
+import java.util.function.Function;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test class for {@link FilesInCommitCollection}. */
+public class FilesInCommitIT extends AbstractDaemonTest {
+  private String changeId;
+
+  @Before
+  public void setUp() throws Exception {
+    baseConfig.setString("cache", "diff", "timeout", "1 minute");
+
+    ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    addCommit(
+        headCommit,
+        ImmutableMap.of("file_1.txt", "file 1 content", "file_2.txt", "file 2 content"));
+
+    Result result = createEmptyChange();
+    changeId = result.getChangeId();
+  }
+
+  @Test
+  public void listFilesForSingleParentCommit() throws Exception {
+    gApi.changes()
+        .id(changeId)
+        .edit()
+        .modifyFile("a_new_file.txt", RawInputUtil.create("Line 1\nLine 2\nLine 3"));
+    gApi.changes().id(changeId).edit().deleteFile("file_1.txt");
+    gApi.changes().id(changeId).edit().publish();
+
+    String lastCommitId = gApi.changes().id(changeId).get().currentRevision;
+
+    // When parentNum is 0, the diff is performed against the default base, i.e. the single parent
+    // in this case.
+    Map<String, FileInfo> changedFiles =
+        gApi.projects().name(project.get()).commit(lastCommitId).files(0);
+
+    assertThat(changedFiles.keySet())
+        .containsExactly("/COMMIT_MSG", "a_new_file.txt", "file_1.txt");
+  }
+
+  @Test
+  public void listFilesForMergeCommitAgainstParent1() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
+
+    String changeId = result.getChangeId();
+    addModifiedPatchSet(changeId, "my_file.txt", content -> content.concat("Line I\nLine II\n"));
+
+    String lastCommitId = gApi.changes().id(changeId).get().currentRevision;
+
+    // Diffing against the first parent.
+    Map<String, FileInfo> changedFiles =
+        gApi.projects().name(project.get()).commit(lastCommitId).files(1);
+
+    assertThat(changedFiles.keySet())
+        .containsExactly(
+            "/COMMIT_MSG",
+            "/MERGE_LIST",
+            "bar", // file bar is coming from parent two
+            "my_file.txt");
+  }
+
+  @Test
+  public void listFilesForMergeCommitAgainstDefaultParent() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
+
+    String changeId = result.getChangeId();
+    addModifiedPatchSet(changeId, "my_file.txt", content -> content.concat("Line I\nLine II\n"));
+
+    String lastCommitId = gApi.changes().id(changeId).get().currentRevision;
+
+    // When parentNum is 0, the diff is performed against the default base. In this case, the
+    // auto-merge commit.
+    Map<String, FileInfo> changedFiles =
+        gApi.projects().name(project.get()).commit(lastCommitId).files(0);
+
+    assertThat(changedFiles.keySet())
+        .containsExactly(
+            "/COMMIT_MSG",
+            "/MERGE_LIST",
+            "bar", // file bar is coming from parent two
+            "my_file.txt");
+  }
+
+  private void addModifiedPatchSet(
+      String changeId, String filePath, Function<String, String> contentModification)
+      throws Exception {
+    try (BinaryResult content = gApi.changes().id(changeId).current().file(filePath).content()) {
+      String newContent = contentModification.apply(content.asString());
+      gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(newContent));
+    }
+    gApi.changes().id(changeId).edit().publish();
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, ImmutableMap<String, String> files)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Adjust files of repo", files);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    return result.getCommit();
+  }
+
+  private Result createEmptyChange() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Test change", ImmutableMap.of());
+    return push.to("refs/for/master");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
index b4b1be0..13c20dd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
@@ -262,9 +262,12 @@
     requestScopeOperations.setApiUser(user.id());
     assertBranchFound(allUsers, RefNames.refsUsers(user.id()));
 
-    // TODO: every user can see the own user ref via the magic ref/users/self ref
-    // requestScopeOperations.setApiUser(user.id());
-    // assertBranchFound(allUsers, RefNames.REFS_USERS_SELF);
+    // every user can see the own user ref via the magic ref/users/self ref. For this special case,
+    // the branch in the request is refs/users/self, but the response contains the actual
+    // refs/users/$sharded_id/$id
+    BranchInfo branchInfo =
+        gApi.projects().name(allUsers.get()).branch(RefNames.REFS_USERS_SELF).get();
+    assertThat(branchInfo.ref).isEqualTo(RefNames.refsUsers(user.id()));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
index a2c5c64..302d827 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -38,7 +39,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
     assertThat(thrown).hasMessageThat().contains("Authentication required");
   }
 
@@ -48,7 +49,7 @@
     AuthException thrown =
         assertThrows(
             AuthException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
     assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
   }
 
@@ -64,7 +65,7 @@
   @Test
   public void allProjectsCodeReviewLabel() throws Exception {
     LabelDefinitionInfo codeReviewLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").get();
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get();
     LabelAssert.assertCodeReviewLabel(codeReviewLabel);
   }
 
@@ -113,6 +114,7 @@
     assertThat(fooLabel.copyAnyScore).isNull();
     assertThat(fooLabel.copyMinScore).isNull();
     assertThat(fooLabel.copyMaxScore).isNull();
+    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
     assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
@@ -135,6 +137,7 @@
                 labelType.setCopyAnyScore(true);
                 labelType.setCopyMinScore(true);
                 labelType.setCopyMaxScore(true);
+                labelType.setCopyAllScoresIfListOfFilesDidNotChange(true);
                 labelType.setCopyAllScoresIfNoCodeChange(true);
                 labelType.setCopyAllScoresOnTrivialRebase(true);
                 labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
@@ -149,6 +152,7 @@
     assertThat(fooLabel.copyAnyScore).isTrue();
     assertThat(fooLabel.copyMinScore).isTrue();
     assertThat(fooLabel.copyMaxScore).isTrue();
+    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
     assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 201bb53..9e31026 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -17,12 +17,13 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 
 public class LabelAssert {
   public static void assertCodeReviewLabel(LabelDefinitionInfo codeReviewLabel) {
-    assertThat(codeReviewLabel.name).isEqualTo("Code-Review");
+    assertThat(codeReviewLabel.name).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(codeReviewLabel.projectName).isEqualTo(AllProjectsNameProvider.DEFAULT);
     assertThat(codeReviewLabel.function).isEqualTo(LabelFunction.MAX_WITH_BLOCK.getFunctionName());
     assertThat(codeReviewLabel.values)
@@ -43,6 +44,7 @@
     assertThat(codeReviewLabel.copyAnyScore).isNull();
     assertThat(codeReviewLabel.copyMinScore).isTrue();
     assertThat(codeReviewLabel.copyMaxScore).isNull();
+    assertThat(codeReviewLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
     assertThat(codeReviewLabel.copyAllScoresIfNoChange).isTrue();
     assertThat(codeReviewLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(codeReviewLabel.copyAllScoresOnTrivialRebase).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
index 7535dea..3c8357b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
@@ -16,13 +16,21 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import org.apache.commons.lang.RandomStringUtils;
 import org.junit.Test;
@@ -30,6 +38,8 @@
 @NoHttpd
 public class ListChildProjectsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void listChildrenOfNonExistingProject_NotFound() throws Exception {
@@ -84,4 +94,29 @@
         .containsExactly(child1_1, child1_1_1, child1_1_1_1, child1_2)
         .inOrder();
   }
+
+  @Test
+  public void listChildrenVisibility() throws Exception {
+    Project.NameKey parent = projectOperations.newProject().createEmptyCommit(true).create();
+    Project.NameKey project =
+        projectOperations.newProject().createEmptyCommit(true).parent(parent).create();
+
+    AccountGroup.UUID privilegedGroupUuid =
+        groupOperations.newGroup().name(name("privilegedGroup")).create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(privilegedGroupUuid))
+        .add(block(Permission.READ).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
+
+    TestAccount privilegedUser =
+        accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden", null);
+    groupOperations.group(privilegedGroupUuid).forUpdate().addMember(privilegedUser.id()).update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThat(gApi.projects().name(parent.get()).children(false)).isEmpty();
+    requestScopeOperations.setApiUser(privilegedUser.id());
+    assertThat(gApi.projects().name(parent.get()).children(false)).isNotEmpty();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index d39c96e..a397693 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -65,7 +66,7 @@
   @Test
   public void allProjectsLabels() throws Exception {
     List<LabelDefinitionInfo> labels = gApi.projects().name(allProjects.get()).labels().get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review");
+    assertThat(labelNames(labels)).containsExactly(LabelId.CODE_REVIEW);
 
     LabelDefinitionInfo codeReviewLabel = Iterables.getOnlyElement(labels);
     LabelAssert.assertCodeReviewLabel(codeReviewLabel);
@@ -135,6 +136,7 @@
     assertThat(fooLabel.copyAnyScore).isNull();
     assertThat(fooLabel.copyMinScore).isNull();
     assertThat(fooLabel.copyMaxScore).isNull();
+    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
     assertThat(fooLabel.copyAllScoresIfNoChange).isNull();
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isNull();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isNull();
@@ -157,6 +159,7 @@
                 labelType.setCopyAnyScore(true);
                 labelType.setCopyMinScore(true);
                 labelType.setCopyMaxScore(true);
+                labelType.setCopyAllScoresIfListOfFilesDidNotChange(true);
                 labelType.setCopyAllScoresIfNoCodeChange(true);
                 labelType.setCopyAllScoresOnTrivialRebase(true);
                 labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
@@ -174,6 +177,7 @@
     assertThat(fooLabel.copyAnyScore).isTrue();
     assertThat(fooLabel.copyMinScore).isTrue();
     assertThat(fooLabel.copyMaxScore).isTrue();
+    assertThat(fooLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
     assertThat(fooLabel.copyAllScoresIfNoChange).isTrue();
     assertThat(fooLabel.copyAllScoresIfNoCodeChange).isTrue();
     assertThat(fooLabel.copyAllScoresOnTrivialRebase).isTrue();
@@ -210,7 +214,7 @@
   public void inheritedLabelsOnly() throws Exception {
     List<LabelDefinitionInfo> labels =
         gApi.projects().name(project.get()).labels().withInherited(true).get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review");
+    assertThat(labelNames(labels)).containsExactly(LabelId.CODE_REVIEW);
 
     LabelDefinitionInfo codeReviewLabel = Iterables.getOnlyElement(labels);
     LabelAssert.assertCodeReviewLabel(codeReviewLabel);
@@ -224,7 +228,9 @@
 
     List<LabelDefinitionInfo> labels =
         gApi.projects().name(project.get()).labels().withInherited(true).get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review", "bar", "baz", "foo").inOrder();
+    assertThat(labelNames(labels))
+        .containsExactly(LabelId.CODE_REVIEW, "bar", "baz", "foo")
+        .inOrder();
 
     LabelAssert.assertCodeReviewLabel(labels.get(0));
     assertThat(labels.get(1).name).isEqualTo("bar");
@@ -237,14 +243,14 @@
 
   @Test
   public void withInheritedLabelsAndOverriddenLabel() throws Exception {
-    configLabel("Code-Review", LabelFunction.NO_OP);
+    configLabel(LabelId.CODE_REVIEW, LabelFunction.NO_OP);
 
     List<LabelDefinitionInfo> labels =
         gApi.projects().name(project.get()).labels().withInherited(true).get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review", "Code-Review");
+    assertThat(labelNames(labels)).containsExactly(LabelId.CODE_REVIEW, LabelId.CODE_REVIEW);
 
     LabelAssert.assertCodeReviewLabel(labels.get(0));
-    assertThat(labels.get(1).name).isEqualTo("Code-Review");
+    assertThat(labels.get(1).name).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(labels.get(1).projectName).isEqualTo(project.get());
     assertThat(labels.get(1).function).isEqualTo(LabelFunction.NO_OP.getFunctionName());
   }
@@ -259,7 +265,7 @@
 
     List<LabelDefinitionInfo> labels =
         gApi.projects().name(childProject.get()).labels().withInherited(true).get();
-    assertThat(labelNames(labels)).containsExactly("Code-Review", "foo", "bar").inOrder();
+    assertThat(labelNames(labels)).containsExactly(LabelId.CODE_REVIEW, "foo", "bar").inOrder();
 
     LabelAssert.assertCodeReviewLabel(labels.get(0));
     assertThat(labels.get(1).name).isEqualTo("foo");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
index ba52024..cbaba2e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.BatchLabelInput;
@@ -124,7 +125,7 @@
   @Test
   public void cannotCreateLabelWithNameThatIsAlreadyInUse() throws Exception {
     LabelDefinitionInput labelInput = new LabelDefinitionInput();
-    labelInput.name = "Code-Review";
+    labelInput.name = LabelId.CODE_REVIEW;
     BatchLabelInput input = new BatchLabelInput();
     input.create = ImmutableList.of(labelInput);
 
@@ -319,7 +320,7 @@
     labelInput.commitMessage = "Update label";
 
     BatchLabelInput input = new BatchLabelInput();
-    input.update = ImmutableMap.of("Code-Review", labelInput);
+    input.update = ImmutableMap.of(LabelId.CODE_REVIEW, labelInput);
 
     BadRequestException thrown =
         assertThrows(
@@ -425,7 +426,7 @@
   @Test
   public void defaultCommitMessage() throws Exception {
     BatchLabelInput input = new BatchLabelInput();
-    input.delete = ImmutableList.of("Code-Review");
+    input.delete = ImmutableList.of(LabelId.CODE_REVIEW);
     gApi.projects().name(allProjects.get()).labels(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
@@ -436,7 +437,7 @@
   public void withCommitMessage() throws Exception {
     BatchLabelInput input = new BatchLabelInput();
     input.commitMessage = "Batch Update Labels";
-    input.delete = ImmutableList.of("Code-Review");
+    input.delete = ImmutableList.of(LabelId.CODE_REVIEW);
     gApi.projects().name(allProjects.get()).labels(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
@@ -447,7 +448,7 @@
   public void commitMessageIsTrimmed() throws Exception {
     BatchLabelInput input = new BatchLabelInput();
     input.commitMessage = " Batch Update Labels ";
-    input.delete = ImmutableList.of("Code-Review");
+    input.delete = ImmutableList.of(LabelId.CODE_REVIEW);
     gApi.projects().name(allProjects.get()).labels(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
index 5fd55ec..83d2256 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
@@ -22,16 +22,23 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
 public class ProjectLevelConfigIT extends AbstractDaemonTest {
+  private static final String PLUGIN_NAME = "test-plugin";
+
   @Inject private ProjectOperations projectOperations;
+  @Inject private PluginConfigFactory pluginConfigFactory;
 
   @Before
   public void setUp() throws Exception {
@@ -168,7 +175,7 @@
     expectedCfg.setString("s3", null, "k5", "childValue3");
     expectedCfg.setString("s3", "ss", "k6", "childValue4");
 
-    assertThat(state.getConfig(configName).getWithInheritance(true).toText())
+    assertThat(state.getConfig(configName).getWithInheritance(/* merge= */ true).toText())
         .isEqualTo(expectedCfg.toText());
 
     assertThat(state.getConfig(configName).get().toText()).isEqualTo(cfg.toText());
@@ -185,4 +192,27 @@
     ProjectState state = projectCache.get(project).get();
     assertThat(state.getConfig(configName).get().toText()).isEmpty();
   }
+
+  @Test
+  public void emptySubSectionsCanBeRead() throws Exception {
+    updatePluginConfig(project, "[section \"subsection\"]");
+    Config cfg = pluginConfigFactory.getProjectPluginConfigWithInheritance(project, PLUGIN_NAME);
+    assertThat(cfg.getSubsections("section")).containsExactly("subsection");
+  }
+
+  private void updatePluginConfig(Project.NameKey project, String pluginConfig) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = testRepo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = testRepo.getRevWalk().parseCommit(ref.getObjectId());
+      testRepo.update(
+          RefNames.REFS_CONFIG,
+          testRepo
+              .commit()
+              .parent(head)
+              .message("Configure plugin")
+              .add(PLUGIN_NAME + ".config", pluginConfig));
+    }
+    projectCache.evict(project);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index 1e8d978..2e68b54 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -52,7 +53,7 @@
             () ->
                 gApi.projects()
                     .name(allProjects.get())
-                    .label("Code-Review")
+                    .label(LabelId.CODE_REVIEW)
                     .update(new LabelDefinitionInput()));
     assertThat(thrown).hasMessageThat().contains("Authentication required");
   }
@@ -72,7 +73,7 @@
             () ->
                 gApi.projects()
                     .name(allProjects.get())
-                    .label("Code-Review")
+                    .label(LabelId.CODE_REVIEW)
                     .update(new LabelDefinitionInput()));
     assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
   }
@@ -83,13 +84,13 @@
     input.name = "Foo-Review";
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.name).isEqualTo(input.name);
 
     assertThat(gApi.projects().name(allProjects.get()).label("Foo-Review").get()).isNotNull();
     assertThrows(
         ResourceNotFoundException.class,
-        () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+        () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
   }
 
   @Test
@@ -98,13 +99,13 @@
     input.name = " Foo-Review ";
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.name).isEqualTo("Foo-Review");
 
     assertThat(gApi.projects().name(allProjects.get()).label("Foo-Review").get()).isNotNull();
     assertThrows(
         ResourceNotFoundException.class,
-        () -> gApi.projects().name(allProjects.get()).label("Code-Review").get());
+        () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
   }
 
   @Test
@@ -115,7 +116,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("name cannot be empty");
   }
 
@@ -127,7 +128,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("invalid name: " + input.name);
   }
 
@@ -169,10 +170,10 @@
     input.function = LabelFunction.NO_OP.getFunctionName();
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.function).isEqualTo(input.function);
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().function)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().function)
         .isEqualTo(input.function);
   }
 
@@ -182,10 +183,10 @@
     input.function = " " + LabelFunction.NO_OP.getFunctionName() + " ";
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.function).isEqualTo(LabelFunction.NO_OP.getFunctionName());
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().function)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().function)
         .isEqualTo(LabelFunction.NO_OP.getFunctionName());
   }
 
@@ -197,7 +198,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("function cannot be empty");
   }
 
@@ -209,7 +210,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("unknown function: " + input.function);
   }
 
@@ -221,7 +222,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("values cannot be empty");
   }
 
@@ -243,7 +244,7 @@
             "Looks Very Bad");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.values)
         .containsExactly(
             "+2", "Looks Very Good",
@@ -252,7 +253,7 @@
             "-1", "Looks Bad",
             "-2", "Looks Very Bad");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().values)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().values)
         .containsExactly(
             "+2", "Looks Very Good",
             "+1", "Looks Good",
@@ -279,7 +280,7 @@
             " Looks Very Bad ");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.values)
         .containsExactly(
             "+2", "Looks Very Good",
@@ -288,7 +289,7 @@
             "-1", "Looks Bad",
             "-2", "Looks Very Bad");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().values)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().values)
         .containsExactly(
             "+2", "Looks Very Good",
             "+1", "Looks Good",
@@ -305,7 +306,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("invalid value: invalidValue");
   }
 
@@ -317,7 +318,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("description for value '+1' cannot be empty");
   }
 
@@ -332,7 +333,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("duplicate value: 1");
   }
 
@@ -342,10 +343,11 @@
     input.defaultValue = 1;
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.defaultValue).isEqualTo(input.defaultValue);
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().defaultValue)
+    assertThat(
+            gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().defaultValue)
         .isEqualTo(input.defaultValue);
   }
 
@@ -357,7 +359,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("invalid default value: " + input.defaultValue);
   }
 
@@ -369,10 +371,10 @@
         ImmutableList.of("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches).containsExactlyElementsIn(input.branches);
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .containsExactlyElementsIn(input.branches);
   }
 
@@ -383,11 +385,11 @@
         ImmutableList.of(" refs/heads/master ", " refs/heads/foo/* ", " ^refs/heads/stable-.* ");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches)
         .containsExactly("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .containsExactly("refs/heads/master", "refs/heads/foo/*", "^refs/heads/stable-.*");
   }
 
@@ -397,10 +399,10 @@
     input.branches = ImmutableList.of("refs/heads/master", "", " ");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches).containsExactly("refs/heads/master");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .containsExactly("refs/heads/master");
   }
 
@@ -408,15 +410,15 @@
   public void branchesCanBeUnset() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.branches = ImmutableList.of("refs/heads/master");
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .isNotNull();
 
     input.branches = ImmutableList.of();
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches).isNull();
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .isNull();
   }
 
@@ -428,7 +430,7 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).label("Code-Review").update(input));
+            () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
     assertThat(thrown).hasMessageThat().contains("invalid branch: refs heads master");
   }
 
@@ -438,10 +440,10 @@
     input.branches = ImmutableList.of("master", "refs/meta/config");
 
     LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(updatedLabel.branches).containsExactly("refs/heads/master", "refs/meta/config");
 
-    assertThat(gApi.projects().name(allProjects.get()).label("Code-Review").get().branches)
+    assertThat(gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get().branches)
         .containsExactly("refs/heads/master", "refs/meta/config");
   }
 
@@ -582,6 +584,65 @@
   }
 
   @Test
+  public void setCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresIfListOfFilesDidNotChange)
+        .isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfListOfFilesDidNotChange = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfListOfFilesDidNotChange).isTrue();
+
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresIfListOfFilesDidNotChange)
+        .isTrue();
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType("foo", lt -> lt.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresIfListOfFilesDidNotChange)
+        .isTrue();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyAllScoresIfListOfFilesDidNotChange = false;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyAllScoresIfListOfFilesDidNotChange).isNull();
+
+    assertThat(
+            gApi.projects()
+                .name(project.get())
+                .label("foo")
+                .get()
+                .copyAllScoresIfListOfFilesDidNotChange)
+        .isNull();
+  }
+
+  @Test
   public void setCopyAllScoresIfNoChange() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
@@ -866,12 +927,12 @@
     LabelDefinitionInfo updatedLabel =
         gApi.projects()
             .name(allProjects.get())
-            .label("Code-Review")
+            .label(LabelId.CODE_REVIEW)
             .update(new LabelDefinitionInput());
     LabelAssert.assertCodeReviewLabel(updatedLabel);
 
     LabelAssert.assertCodeReviewLabel(
-        gApi.projects().name(allProjects.get()).label("Code-Review").get());
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).get());
 
     assertThat(projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG))
         .isEqualTo(refsMetaConfigHead);
@@ -881,7 +942,7 @@
   public void defaultCommitMessage() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.function = LabelFunction.NO_OP.getFunctionName();
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Update label");
@@ -892,7 +953,7 @@
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.function = LabelFunction.NO_OP.getFunctionName();
     input.commitMessage = "Set NoOp function";
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo(input.commitMessage);
@@ -903,7 +964,7 @@
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.function = LabelFunction.NO_OP.getFunctionName();
     input.commitMessage = " Set NoOp function ";
-    gApi.projects().name(allProjects.get()).label("Code-Review").update(input);
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
     assertThat(
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Set NoOp function");
diff --git a/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java b/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
index 1e33c69..df84fd7 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
@@ -44,7 +44,7 @@
 
   @Test
   public void userWithDirectMembershipInServiceUserIsAServiceUser() throws Exception {
-    TestAccount user = accountCreator.create(null, "Service Users");
+    TestAccount user = accountCreator.create(null, ServiceUserClassifier.SERVICE_USERS);
     assertThat(serviceUserClassifier.isServiceUser(user.id())).isTrue();
   }
 
@@ -91,7 +91,7 @@
 
   private AccountGroup.UUID serviceUsersUUID() {
     return groupCache
-        .get(AccountGroup.nameKey("Service Users"))
+        .get(AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS))
         .orElseThrow(() -> new IllegalStateException("unable to find 'Service Users'"))
         .getGroupUUID();
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/BUILD b/javatests/com/google/gerrit/acceptance/server/change/BUILD
index 500ab06..19ca946 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/change/BUILD
@@ -5,7 +5,19 @@
     group = "server_change",
     labels = ["server"],
     deps = [
+        ":util",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/util/time",
     ],
 )
+
+java_library(
+    name = "util",
+    srcs = ["CommentsUtil.java"],
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "@guava//jar",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
new file mode 100644
index 0000000..9bd8e9c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
@@ -0,0 +1,525 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.MoreCollectors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+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.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class CommentContextIT extends AbstractDaemonTest {
+  /** The commit message of a single commit. */
+  private static final String SUBJECT =
+      String.join(
+          "\n",
+          "Commit Header",
+          "",
+          "This commit is doing something extremely important",
+          "",
+          "Footer: value");
+
+  private static final String FILE_CONTENT =
+      String.join("\n", "Line 1 of file", "", "Line 3 of file", "", "", "Line 6 of file");
+  private static final ObjectId dummyCommit =
+      ObjectId.fromString("93e2901bc0b4719ef6081ee6353b49c9cdd97614");
+
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Before
+  public void setup() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+  }
+
+  @Test
+  public void commentContextForGitSubmoduleFiles() throws Exception {
+    String submodulePath = "submodule_path";
+
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo).addGitSubmodule(submodulePath, dummyCommit);
+    PushOneCommit.Result pushResult = push.to("refs/for/master");
+    String changeId = pushResult.getChangeId();
+    CommentInput comment =
+        CommentsUtil.newComment(submodulePath, Side.REVISION, 1, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, pushResult.getCommit().name(), comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).path).isEqualTo(submodulePath);
+    assertThat(comments.get(0).contextLines)
+        .isEqualTo(createContextLines("1", "Subproject commit " + dummyCommit.getName()));
+  }
+
+  @Test
+  public void commentContextForCommitMessageForLineComment() throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = CommentsUtil.newComment(COMMIT_MSG, Side.REVISION, 7, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+
+    // The first few lines of the commit message are the headers, e.g.
+    // Parent: ...
+    // Author: ...
+    // AuthorDate: ...
+    // etc...
+    assertThat(comments.get(0).contextLines)
+        .containsExactlyElementsIn(createContextLines("7", "Commit Header"));
+  }
+
+  @Test
+  public void commentContextForMergeList() throws Exception {
+    PushOneCommit.Result result = createMergeCommitChange("refs/for/master");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = CommentsUtil.newComment(MERGE_LIST, Side.REVISION, 1, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).contextLines)
+        .containsExactlyElementsIn(createContextLines("1", "Merge List:"));
+  }
+
+  @Test
+  public void commentContextForCommitMessageForRangeComment() throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment =
+        CommentsUtil.newComment(
+            COMMIT_MSG, Side.REVISION, createCommentRange(7, 9), "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+
+    // The first few lines of the commit message are the headers, e.g.
+    // Parent: ...
+    // Author: ...
+    // AuthorDate: ...
+    // etc...
+    assertThat(comments.get(0).contextLines)
+        .containsExactlyElementsIn(
+            createContextLines(
+                "7",
+                "Commit Header",
+                "8",
+                "",
+                "9",
+                "This commit is doing something extremely important"));
+  }
+
+  @Test
+  public void commentContextForCommitMessageInvalidLine() throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment =
+        CommentsUtil.newComment(COMMIT_MSG, Side.REVISION, 100, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).contextLines).isEmpty();
+  }
+
+  @Test
+  public void listChangeCommentsWithContextEnabled() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    ImmutableList.Builder<String> content = ImmutableList.builder();
+    for (int i = 1; i <= 10; i++) {
+      content.add("line_" + i);
+    }
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    CommentsUtil.addCommentOnLine(gApi, r2, "nit: please fix", 1);
+    CommentsUtil.addCommentOnRange(gApi, r2, "looks good", createCommentRange(2, 5));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(2);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("nit: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("1", "line_1"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(
+            createContextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+  }
+
+  @Test
+  public void listChangeDraftsWithContextEnabled() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                "line_1\nline_2\nline_3",
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    DraftInput in = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "comment 1");
+    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).createDraft(in);
+
+    // Test the getAsList interface
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).draftsRequest().withContext(true).getAsList();
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).message).isEqualTo("comment 1");
+    assertThat(comments.get(0).contextLines)
+        .containsExactlyElementsIn(createContextLines("2", "line_2"));
+
+    // Also test the get interface
+    Map<String, List<CommentInfo>> commentsMap =
+        gApi.changes().id(r2.getChangeId()).draftsRequest().withContext(true).get();
+    assertThat(commentsMap).hasSize(1);
+    assertThat(commentsMap.values().iterator().next()).hasSize(1);
+    CommentInfo onlyComment = commentsMap.values().iterator().next().get(0);
+    assertThat(onlyComment.message).isEqualTo("comment 1");
+    assertThat(onlyComment.contextLines)
+        .containsExactlyElementsIn(createContextLines("2", "line_2"));
+  }
+
+  @Test
+  public void commentContextForCommentsOnDifferentPatchsets() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    ImmutableList.Builder<String> content = ImmutableList.builder();
+    for (int i = 1; i <= 10; i++) {
+      content.add("line_" + i);
+    }
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                String.join("\n", content.build()),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    PushOneCommit.Result r3 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    CommentsUtil.addCommentOnLine(gApi, r2, "r2: please fix", 1);
+    CommentsUtil.addCommentOnRange(gApi, r2, "r2: looks good", createCommentRange(2, 3));
+    CommentsUtil.addCommentOnLine(gApi, r3, "r3: please fix", 6);
+    CommentsUtil.addCommentOnRange(gApi, r3, "r3: looks good", createCommentRange(7, 8));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(4);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r2: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("1", "line_1"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r2: looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("2", "line_2", "3", "line_3"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r3: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("6", "line_6"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r3: looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(createContextLines("7", "line_7", "8", "line_8"));
+  }
+
+  @Test
+  public void commentContextIsEmptyForPatchsetLevelComments() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment =
+        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).contextLines).isEmpty();
+  }
+
+  @Test
+  public void commentContextWithZeroPadding() throws Exception {
+    String changeId = createChangeWithComment(3, 4);
+    assertContextLines(changeId, /* contextPadding= */ 0, ImmutableList.of(3, 4));
+  }
+
+  @Test
+  public void commentContextWithSmallPadding() throws Exception {
+    String changeId = createChangeWithComment(3, 4);
+    assertContextLines(changeId, /* contextPadding= */ 1, ImmutableList.of(2, 3, 4, 5));
+  }
+
+  @Test
+  public void commentContextWithSmallPaddingAtTheBeginningOfFile() throws Exception {
+    String changeId = createChangeWithComment(1, 2);
+    assertContextLines(changeId, /* contextPadding= */ 2, ImmutableList.of(1, 2, 3, 4));
+  }
+
+  @Test
+  public void commentContextWithPaddingLargerThanFileSize() throws Exception {
+    String changeId = createChangeWithComment(3, 3);
+    assertContextLines(
+        changeId,
+        /* contextPadding= */ 20,
+        ImmutableList.of(1, 2, 3, 4, 5, 6)); // file only contains six lines.
+  }
+
+  @Test
+  public void commentContextWithLargePaddingReturnsAdjustedMaximumPadding() throws Exception {
+    String changeId = createChangeWithCommentLarge(250, 250);
+    assertContextLines(
+        changeId,
+        /* contextPadding= */ 300,
+        IntStream.range(200, 301).boxed().collect(ImmutableList.toImmutableList()));
+  }
+
+  @Test
+  public void commentContextReturnsCorrectContentTypeForCommitMessage() throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = CommentsUtil.newComment(COMMIT_MSG, Side.REVISION, 7, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).path).isEqualTo(COMMIT_MSG);
+    assertThat(comments.get(0).sourceContentType)
+        .isEqualTo(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE);
+  }
+
+  @Test
+  public void commentContextReturnsCorrectContentType_Java() throws Exception {
+    String javaContent =
+        "public class Main {\n"
+            + " public static void main(String[]args){\n"
+            + " if(args==null){\n"
+            + " System.err.println(\"Something\");\n"
+            + " }\n"
+            + " }\n"
+            + " }";
+    String fileName = "src.java";
+    String changeId = createChangeWithContent(fileName, javaContent, /* line= */ 4);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).path).isEqualTo(fileName);
+    assertThat(comments.get(0).contextLines)
+        .isEqualTo(createContextLines("4", " System.err.println(\"Something\");"));
+    assertThat(comments.get(0).sourceContentType).isEqualTo("text/x-java");
+  }
+
+  @Test
+  public void commentContextReturnsCorrectContentType_Cpp() throws Exception {
+    String cppContent =
+        "#include <iostream>\n"
+            + "\n"
+            + "int main() {\n"
+            + "    std::cout << \"Hello World!\";\n"
+            + "    return 0;\n"
+            + "}";
+    String fileName = "src.cpp";
+    String changeId = createChangeWithContent(fileName, cppContent, /* line= */ 4);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(comments.get(0).path).isEqualTo(fileName);
+    assertThat(comments.get(0).contextLines)
+        .isEqualTo(createContextLines("4", "    std::cout << \"Hello World!\";"));
+    assertThat(comments.get(0).sourceContentType).isEqualTo("text/x-c++src");
+  }
+
+  private String createChangeWithContent(String fileName, String fileContent, int line)
+      throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, fileName, fileContent, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = CommentsUtil.newComment(fileName, Side.REVISION, line, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+    return changeId;
+  }
+
+  private String createChangeWithComment(int startLine, int endLine) throws Exception {
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    Comment.Range commentRange = createCommentRange(startLine, endLine);
+    CommentInput comment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, commentRange, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+    return changeId;
+  }
+
+  private String createChangeWithCommentLarge(int startLine, int endLine) throws Exception {
+    StringBuilder largeContent = new StringBuilder();
+    for (int i = 0; i < 1000; i++) {
+      largeContent.append("line " + i + "\n");
+    }
+    PushOneCommit.Result result =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, largeContent.toString(), "topic");
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    Comment.Range commentRange = createCommentRange(startLine, endLine);
+    CommentInput comment =
+        CommentsUtil.newComment(FILE_NAME, Side.REVISION, commentRange, "comment", false);
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
+    return changeId;
+  }
+
+  private void assertContextLines(
+      String changeId, int contextPadding, ImmutableList<Integer> expectedLines) throws Exception {
+    List<CommentInfo> comments =
+        gApi.changes()
+            .id(changeId)
+            .commentsRequest()
+            .withContext(true)
+            .contextPadding(contextPadding)
+            .getAsList();
+
+    assertThat(comments).hasSize(1);
+    assertThat(
+            comments.get(0).contextLines.stream()
+                .map(c -> c.lineNumber)
+                .collect(Collectors.toList()))
+        .containsExactlyElementsIn(expectedLines);
+  }
+
+  private Comment.Range createCommentRange(int startLine, int endLine) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.endLine = endLine;
+    return range;
+  }
+
+  private List<ContextLineInfo> createContextLines(String... args) {
+    List<ContextLineInfo> result = new ArrayList<>();
+    for (int i = 0; i < args.length; i += 2) {
+      int lineNbr = Integer.parseInt(args[i]);
+      String contextLine = args[i + 1];
+      ContextLineInfo info = new ContextLineInfo(lineNbr, contextLine);
+      result.add(info);
+    }
+    return result;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index b4dd4b3..b29c031 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -23,14 +23,11 @@
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
-import static java.util.stream.Collectors.groupingBy;
 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.common.collect.Lists;
-import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -54,7 +51,6 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -72,7 +68,6 @@
 import com.google.inject.Provider;
 import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -80,7 +75,6 @@
 import java.util.function.Function;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -124,7 +118,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
+      DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       assertThat(result).hasSize(1);
@@ -145,10 +139,10 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput c1 = newDraft(path, Side.REVISION, line, "ps-1");
-      DraftInput c2 = newDraft(path, Side.PARENT, line, "auto-merge of ps-1");
-      DraftInput c3 = newDraftOnParent(path, 1, line, "parent-1 of ps-1");
-      DraftInput c4 = newDraftOnParent(path, 2, line, "parent-2 of ps-1");
+      DraftInput c1 = CommentsUtil.newDraft(path, Side.REVISION, line, "ps-1");
+      DraftInput c2 = CommentsUtil.newDraft(path, Side.PARENT, line, "auto-merge of ps-1");
+      DraftInput c3 = CommentsUtil.newDraftOnParent(path, 1, line, "parent-1 of ps-1");
+      DraftInput c4 = CommentsUtil.newDraftOnParent(path, 2, line, "parent-2 of ps-1");
       addDraft(changeId, revId, c1);
       addDraft(changeId, revId, c2);
       addDraft(changeId, revId, c3);
@@ -174,7 +168,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", false);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       revision(r).review(input);
@@ -193,8 +187,9 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
-    addComments(changeId, ps1, comment);
+    CommentInput comment =
+        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
 
     Map<String, List<CommentInfo>> results = getPublishedComments(changeId, ps1);
     assertThatMap(results).keys().containsExactly(PATCHSET_LEVEL);
@@ -207,8 +202,9 @@
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
     String commentMessage = "to be deleted";
-    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, commentMessage);
-    addComments(changeId, revId, comment);
+    CommentInput comment =
+        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, commentMessage);
+    CommentsUtil.addComments(gApi, changeId, revId, comment);
 
     Map<String, List<CommentInfo>> results = getPublishedComments(changeId, revId);
     CommentInfo oldComment = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL));
@@ -227,8 +223,9 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
-    addComments(changeId, ps1, comment);
+    CommentInput comment =
+        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentsUtil.addComments(gApi, changeId, ps1, comment);
 
     String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
     assertThat(emailBody).contains("Patchset");
@@ -241,10 +238,11 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentInput input = CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     input.line = 1;
     BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+        assertThrows(
+            BadRequestException.class, () -> CommentsUtil.addComments(gApi, changeId, ps1, input));
     assertThat(ex.getMessage()).contains("line");
   }
 
@@ -254,10 +252,11 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentInput input = CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     input.range = createLineRange(1, 3);
     BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+        assertThrows(
+            BadRequestException.class, () -> CommentsUtil.addComments(gApi, changeId, ps1, input));
     assertThat(ex.getMessage()).contains("range");
   }
 
@@ -267,10 +266,11 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    CommentInput input = CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     input.side = Side.REVISION;
     BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+        assertThrows(
+            BadRequestException.class, () -> CommentsUtil.addComments(gApi, changeId, ps1, input));
     assertThat(ex.getMessage()).contains("side");
   }
 
@@ -279,7 +279,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     addDraft(changeId, revId, comment);
     Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
     assertThatMap(results).keys().containsExactly(PATCHSET_LEVEL);
@@ -290,7 +290,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput draft = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment 1");
+    DraftInput draft = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment 1");
     CommentInfo returned = addDraft(changeId, revId, draft);
     deleteDraft(changeId, revId, returned.id);
     Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
@@ -302,7 +302,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     comment.line = 1;
     BadRequestException ex =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
@@ -314,7 +314,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     comment.range = createLineRange(1, 3);
     BadRequestException ex =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
@@ -326,7 +326,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     comment.side = Side.REVISION;
     BadRequestException ex =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
@@ -338,10 +338,10 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     addDraft(changeId, revId, comment);
     Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
-    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput update = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
     update.line = 1;
     BadRequestException ex =
@@ -355,10 +355,10 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     addDraft(changeId, revId, comment);
     Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
-    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput update = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
     update.range = createLineRange(1, 3);
     BadRequestException ex =
@@ -372,10 +372,10 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput comment = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     addDraft(changeId, revId, comment);
     Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
-    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    DraftInput update = CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
     update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
     update.side = Side.REVISION;
     BadRequestException ex =
@@ -395,7 +395,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", false);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       revision(r).review(input);
@@ -403,7 +403,7 @@
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
 
       input = new ReviewInput();
-      comment = newComment(file, Side.REVISION, line, "comment 1 reply", false);
+      comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1 reply", false);
       comment.inReplyTo = actual.id;
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
@@ -427,7 +427,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", true);
+      CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", true);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       revision(r).review(input);
@@ -448,10 +448,11 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1", false);
-      CommentInput c2 = newComment(file, Side.PARENT, line, "auto-merge of ps-1", false);
-      CommentInput c3 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
-      CommentInput c4 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
+      CommentInput c1 = CommentsUtil.newComment(file, Side.REVISION, line, "ps-1", false);
+      CommentInput c2 =
+          CommentsUtil.newComment(file, Side.PARENT, line, "auto-merge of ps-1", false);
+      CommentInput c3 = CommentsUtil.newCommentOnParent(file, 1, line, "parent-1 of ps-1");
+      CommentInput c4 = CommentsUtil.newCommentOnParent(file, 2, line, "parent-2 of ps-1");
       input.comments = new HashMap<>();
       input.comments.put(file, ImmutableList.of(c1, c2, c3, c4));
       revision(r).review(input);
@@ -470,9 +471,9 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       ReviewInput input = new ReviewInput();
-      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1", false);
-      CommentInput c2 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
-      CommentInput c3 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
+      CommentInput c1 = CommentsUtil.newComment(file, Side.REVISION, line, "ps-1", false);
+      CommentInput c2 = CommentsUtil.newCommentOnParent(file, 1, line, "parent-1 of ps-1");
+      CommentInput c3 = CommentsUtil.newCommentOnParent(file, 2, line, "parent-2 of ps-1");
       input.comments = new HashMap<>();
       input.comments.put(file, ImmutableList.of(c1, c2, c3));
       revision(r).review(input);
@@ -489,7 +490,8 @@
   public void postCommentOnCommitMessageOnAutoMerge() throws Exception {
     PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
     ReviewInput input = new ReviewInput();
-    CommentInput c = newComment(Patch.COMMIT_MSG, Side.PARENT, 0, "comment on auto-merge", false);
+    CommentInput c =
+        CommentsUtil.newComment(Patch.COMMIT_MSG, Side.PARENT, 0, "comment on auto-merge", false);
     input.comments = new HashMap<>();
     input.comments.put(Patch.COMMIT_MSG, ImmutableList.of(c));
     BadRequestException thrown =
@@ -508,7 +510,7 @@
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
 
-    DraftInput draft = newDraft(file, Side.REVISION, 0, "comment");
+    DraftInput draft = CommentsUtil.newDraft(file, Side.REVISION, 0, "comment");
     addDraft(changeId, revId, draft);
     Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
     CommentInfo draftInfo = Iterables.getOnlyElement(drafts.get(draft.path));
@@ -516,7 +518,7 @@
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.KEEP;
     reviewInput.message = "foo";
-    CommentInput comment = newComment(file, Side.REVISION, 0, "comment", false);
+    CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 0, "comment", false);
     // Replace the existing draft.
     comment.id = draftInfo.id;
     reviewInput.comments = new HashMap<>();
@@ -545,14 +547,14 @@
 
     String draftRefName = RefNames.refsDraftComments(r1.getChange().getId(), admin.id());
 
-    DraftInput draft = newDraft(file, Side.REVISION, 1, "comment");
+    DraftInput draft = CommentsUtil.newDraft(file, Side.REVISION, 1, "comment");
     addDraft(changeId, "1", draft);
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.PUBLISH;
     reviewInput.message = "foo";
     gApi.changes().id(r1.getChangeId()).revision(1).review(reviewInput);
 
-    addDraft(changeId, "2", newDraft(file, Side.REVISION, 2, "comment2"));
+    addDraft(changeId, "2", CommentsUtil.newDraft(file, Side.REVISION, 2, "comment2"));
     reviewInput = new ReviewInput();
     reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
     reviewInput.message = "bar";
@@ -581,7 +583,8 @@
     List<CommentInput> expectedComments = new ArrayList<>();
     for (Integer line : lines) {
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment " + line, false);
+      CommentInput comment =
+          CommentsUtil.newComment(file, Side.REVISION, line, "comment " + line, false);
       expectedComments.add(comment);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
@@ -610,11 +613,11 @@
     String revId = r.getCommit().getName();
     String draftRefName = RefNames.refsDraftComments(r.getChange().getId(), user.id());
 
-    DraftInput comment1 = newDraft("file_1", Side.REVISION, 1, "comment 1");
+    DraftInput comment1 = CommentsUtil.newDraft("file_1", Side.REVISION, 1, "comment 1");
     CommentInfo commentInfo1 = addDraft(changeId, revId, comment1);
     assertThat(getHeadOfDraftCommentsRef(draftRefName).getParentCount()).isEqualTo(0);
 
-    DraftInput comment2 = newDraft("file_2", Side.REVISION, 2, "comment 2");
+    DraftInput comment2 = CommentsUtil.newDraft("file_2", Side.REVISION, 2, "comment 2");
     CommentInfo commentInfo2 = addDraft(changeId, revId, comment2);
     assertThat(getHeadOfDraftCommentsRef(draftRefName).getParentCount()).isEqualTo(0);
 
@@ -639,7 +642,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
+      DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
@@ -662,7 +665,7 @@
     String parentCommentUuid =
         changeOperations.change(changeId).currentPatchset().newComment().create();
 
-    DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+    DraftInput draft = CommentsUtil.newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
     draft.inReplyTo = parentCommentUuid;
     String createdDraftUuid = addDraft(changeId, draft).id;
     TestHumanComment actual =
@@ -676,7 +679,7 @@
     String parentRobotCommentUuid =
         changeOperations.change(changeId).currentPatchset().newRobotComment().create();
 
-    DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+    DraftInput draft = CommentsUtil.newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
     draft.inReplyTo = parentRobotCommentUuid;
     String createdDraftUuid = addDraft(changeId, draft).id;
     TestHumanComment actual =
@@ -690,9 +693,9 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraft(file, Side.REVISION, 0, "foo");
+    DraftInput comment = CommentsUtil.newDraft(file, Side.REVISION, 0, "foo");
     CommentInfo commentInfo = addDraft(changeId, revId, comment);
-    DraftInput draftInput = newDraft(file, Side.REVISION, 0, "bar");
+    DraftInput draftInput = CommentsUtil.newDraft(file, Side.REVISION, 0, "bar");
     draftInput.id = "anything_but_" + commentInfo.id;
     BadRequestException e =
         assertThrows(
@@ -707,7 +710,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraft(file, Side.REVISION, -666, "foo");
+    DraftInput comment = CommentsUtil.newDraft(file, Side.REVISION, -666, "foo");
     BadRequestException e =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
     assertThat(e).hasMessageThat().contains("line must be >= 0");
@@ -719,7 +722,8 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput draftInput = newDraft(file, Side.REVISION, createLineRange(2, 3), "bar");
+    DraftInput draftInput =
+        CommentsUtil.newDraft(file, Side.REVISION, createLineRange(2, 3), "bar");
     draftInput.line = 666;
     BadRequestException e =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, draftInput));
@@ -731,7 +735,7 @@
   @Test
   public void putDraft_invalidInReplyTo() throws Exception {
     Change.Id changeId = changeOperations.newChange().create();
-    DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+    DraftInput draft = CommentsUtil.newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
     draft.inReplyTo = "invalid";
     BadRequestException exception =
         assertThrows(BadRequestException.class, () -> addDraft(changeId, draft));
@@ -743,10 +747,10 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     String revId = r.getCommit().getName();
-    DraftInput comment = newDraft("file_foo", Side.REVISION, 0, "foo");
+    DraftInput comment = CommentsUtil.newDraft("file_foo", Side.REVISION, 0, "foo");
     CommentInfo commentInfo = addDraft(changeId, revId, comment);
     assertThat(getDraftComments(changeId, revId).keySet()).containsExactly("file_foo");
-    DraftInput draftInput = newDraft("file_bar", Side.REVISION, 0, "bar");
+    DraftInput draftInput = CommentsUtil.newDraft("file_bar", Side.REVISION, 0, "bar");
     updateDraft(changeId, revId, draftInput, commentInfo.id);
     assertThat(getDraftComments(changeId, revId).keySet()).containsExactly("file_bar");
   }
@@ -754,10 +758,10 @@
   @Test
   public void putDraft_updateInvalidInReplyTo() throws Exception {
     Change.Id changeId = changeOperations.newChange().create();
-    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    DraftInput originalDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "foo");
     CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
 
-    DraftInput updatedDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput updatedDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     updatedDraftInput.inReplyTo = "invalid";
     BadRequestException exception =
         assertThrows(
@@ -771,10 +775,10 @@
     Change.Id changeId = changeOperations.newChange().create();
     String parentCommentUuid =
         changeOperations.change(changeId).currentPatchset().newComment().create();
-    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    DraftInput originalDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "foo");
     CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
 
-    DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput updateDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     updateDraftInput.inReplyTo = parentCommentUuid;
     updateDraft(changeId, updateDraftInput, originalDraft.id);
     assertThat(changeOperations.change(changeId).draftComment(originalDraft.id).get().parentUuid())
@@ -786,10 +790,10 @@
     Change.Id changeId = changeOperations.newChange().create();
     String parentRobotCommentUuid =
         changeOperations.change(changeId).currentPatchset().newRobotComment().create();
-    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    DraftInput originalDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "foo");
     CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
 
-    DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput updateDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     updateDraftInput.inReplyTo = parentRobotCommentUuid;
     updateDraft(changeId, updateDraftInput, originalDraft.id);
     assertThat(changeOperations.change(changeId).draftComment(originalDraft.id).get().parentUuid())
@@ -799,10 +803,10 @@
   @Test
   public void putDraft_updateTag() throws Exception {
     Change.Id changeId = changeOperations.newChange().create();
-    DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+    DraftInput originalDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "foo");
     CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
 
-    DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput updateDraftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     String tag = "täg";
     updateDraftInput.tag = tag;
     updateDraft(changeId, updateDraftInput, originalDraft.id);
@@ -828,7 +832,7 @@
 
     // Each user can only see their own drafts.
     requestScopeOperations.setApiUser(accountId);
-    DraftInput draftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+    DraftInput draftInput = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 0, "bar");
     draftInput.message = "Another comment text.";
     gApi.changes()
         .id(changeId.get())
@@ -851,7 +855,7 @@
 
     List<DraftInput> expectedDrafts = new ArrayList<>();
     for (Integer line : lines) {
-      DraftInput comment = newDraft(file, Side.REVISION, line, "comment " + line);
+      DraftInput comment = CommentsUtil.newDraft(file, Side.REVISION, line, "comment " + line);
       expectedDrafts.add(comment);
       addDraft(changeId, revId, comment);
     }
@@ -870,7 +874,7 @@
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
-      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
+      DraftInput comment = CommentsUtil.newDraft(path, Side.REVISION, line, "comment 1");
       CommentInfo returned = addDraft(changeId, revId, comment);
       CommentInfo actual = getDraftComment(changeId, revId, returned.id);
       assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
@@ -884,7 +888,7 @@
       Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      DraftInput draft = newDraft("file1", Side.REVISION, line, "comment 1");
+      DraftInput draft = CommentsUtil.newDraft("file1", Side.REVISION, line, "comment 1");
       CommentInfo returned = addDraft(changeId, revId, draft);
       deleteDraft(changeId, revId, returned.id);
       Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
@@ -909,7 +913,7 @@
       Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
 
       ReviewInput input = new ReviewInput();
-      CommentInput comment = newComment(file, Side.REVISION, line, "comment 1", false);
+      CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", false);
       comment.updated = timestamp;
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
@@ -935,11 +939,11 @@
     PushOneCommit.Result r1 = createChange();
     String changeId = r1.getChangeId();
     String revId = r1.getCommit().getName();
-    addComment(r1, "nit: trailing whitespace");
-    addComment(r1, "nit: trailing whitespace");
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace");
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace");
     Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
     assertThat(result.get(FILE_NAME)).hasSize(2);
-    addComment(r1, "nit: trailing whitespace", true, false, null);
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace", true, false, null);
     result = getPublishedComments(changeId, revId);
     assertThat(result.get(FILE_NAME)).hasSize(2);
 
@@ -949,7 +953,7 @@
             .to("refs/for/master");
     changeId = r2.getChangeId();
     revId = r2.getCommit().getName();
-    addComment(r2, "nit: trailing whitespace", true, false, null);
+    CommentsUtil.addComment(gApi, r2, "nit: trailing whitespace", true, false, null);
     result = getPublishedComments(changeId, revId);
     assertThat(result.get(FILE_NAME)).hasSize(1);
   }
@@ -967,17 +971,17 @@
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "nit: trailing whitespace"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "typo: content"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "typo: content"));
 
     requestScopeOperations.setApiUser(user.id());
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "+1, please fix"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "+1, please fix"));
 
     requestScopeOperations.setApiUser(admin.id());
     Map<String, List<CommentInfo>> actual = gApi.changes().id(r1.getChangeId()).drafts();
@@ -1018,8 +1022,8 @@
             .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent", r1.getChangeId())
             .to("refs/for/master");
 
-    addComment(r1, "nit: trailing whitespace");
-    addComment(r2, "typo: content");
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace");
+    CommentsUtil.addComment(gApi, r2, "typo: content");
 
     Map<String, List<CommentInfo>> actual =
         gApi.changes().id(r2.getChangeId()).commentsRequest().get();
@@ -1047,61 +1051,6 @@
   }
 
   @Test
-  public void listChangeCommentsWithContextEnabled() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-
-    ImmutableList.Builder<String> content = ImmutableList.builder();
-    for (int i = 1; i <= 10; i++) {
-      content.add("line_" + i);
-    }
-
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                testRepo,
-                SUBJECT,
-                FILE_NAME,
-                content.build().stream().collect(Collectors.joining("\n")),
-                r1.getChangeId())
-            .to("refs/for/master");
-
-    addCommentOnLine(r2, "nit: please fix", 1);
-    addCommentOnRange(r2, "looks good", commentRangeInLines(2, 5));
-
-    List<CommentInfo> comments =
-        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
-
-    assertThat(comments).hasSize(2);
-
-    assertThat(
-            comments.stream()
-                .filter(c -> c.message.equals("nit: please fix"))
-                .collect(MoreCollectors.onlyElement())
-                .contextLines)
-        .containsExactlyElementsIn(contextLines("1", "line_1"));
-
-    assertThat(
-            comments.stream()
-                .filter(c -> c.message.equals("looks good"))
-                .collect(MoreCollectors.onlyElement())
-                .contextLines)
-        .containsExactlyElementsIn(
-            contextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
-  }
-
-  private List<ContextLineInfo> contextLines(String... args) {
-    List<ContextLineInfo> result = new ArrayList<>();
-    for (int i = 0; i < args.length; i += 2) {
-      int lineNbr = Integer.parseInt(args[i]);
-      String contextLine = args[i + 1];
-      ContextLineInfo info = new ContextLineInfo(lineNbr, contextLine);
-      result.add(info);
-    }
-    return result;
-  }
-
-  @Test
   public void listChangeCommentsAnonymousDoesNotRequireAuth() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
@@ -1110,8 +1059,8 @@
             .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "new cntent", r1.getChangeId())
             .to("refs/for/master");
 
-    addComment(r1, "nit: trailing whitespace");
-    addComment(r2, "typo: content");
+    CommentsUtil.addComment(gApi, r1, "nit: trailing whitespace");
+    CommentsUtil.addComment(gApi, r2, "typo: content");
 
     List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList();
     assertThat(comments.stream().map(c -> c.message).collect(toList()))
@@ -1129,7 +1078,7 @@
       PushOneCommit.Result r = createChange();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      DraftInput comment = newDraft("file1", Side.REVISION, line, "comment 1");
+      DraftInput comment = CommentsUtil.newDraft("file1", Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       assertThat(gApi.changes().query("change:" + changeId + " has:draft").get()).hasSize(1);
     }
@@ -1163,39 +1112,42 @@
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "Is it that bad?"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "Is it that bad?"));
     addDraft(
         r1.getChangeId(),
         r1.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, createLineRange(0, 7), "what happened to this?"));
+        CommentsUtil.newDraft(
+            FILE_NAME, Side.PARENT, createLineRange(0, 7), "what happened to this?"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 15), "better now"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 15), "better now"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 2, "typo: content"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "typo: content"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 1, "comment 1 on base"));
+        CommentsUtil.newDraft(FILE_NAME, Side.PARENT, 1, "comment 1 on base"));
     addDraft(
         r2.getChangeId(),
         r2.getCommit().getName(),
-        newDraft(FILE_NAME, Side.PARENT, 2, "comment 2 on base"));
+        CommentsUtil.newDraft(FILE_NAME, Side.PARENT, 2, "comment 2 on base"));
 
     PushOneCommit.Result other = createChange();
     // Drafts on other changes aren't returned.
     addDraft(
         other.getChangeId(),
         other.getCommit().getName(),
-        newDraft(FILE_NAME, Side.REVISION, 1, "unrelated comment"));
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 1, "unrelated comment"));
 
     requestScopeOperations.setApiUser(admin.id());
     // Drafts by other users aren't returned.
     addDraft(
-        r2.getChangeId(), r2.getCommit().getName(), newDraft(FILE_NAME, Side.REVISION, 2, "oops"));
+        r2.getChangeId(),
+        r2.getCommit().getName(),
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "oops"));
     requestScopeOperations.setApiUser(user.id());
 
     ReviewInput reviewInput = new ReviewInput();
@@ -1325,7 +1277,7 @@
     pub.line = 1;
     pub.message = "published comment";
     pub.path = FILE_NAME;
-    ReviewInput rin = newInput(pub);
+    ReviewInput rin = CommentsUtil.newInput(pub);
     rin.tag = "tag1";
     gApi.changes().id(r.getChangeId()).current().review(rin);
 
@@ -1349,7 +1301,7 @@
   public void draftCommentsWithTagPublishPatchset() throws Exception {
     PushOneCommit.Result result = createChange();
 
-    DraftInput draft = newDraft(FILE_NAME, Side.REVISION, 2, "draft");
+    DraftInput draft = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "draft");
     draft.tag = "old_tag";
     addDraft(result.getChangeId(), result.getCommit().name(), draft);
 
@@ -1369,7 +1321,7 @@
   public void draftCommentsWithTagPublishAllRevisions() throws Exception {
     PushOneCommit.Result result = createChange();
 
-    DraftInput draft = newDraft(FILE_NAME, Side.REVISION, 2, "draft");
+    DraftInput draft = CommentsUtil.newDraft(FILE_NAME, Side.REVISION, 2, "draft");
     draft.tag = "old_tag";
     addDraft(result.getChangeId(), result.getCommit().name(), draft);
 
@@ -1395,30 +1347,32 @@
     // PS1 has three comments in three different threads, PS2 has one comment in one thread.
     PushOneCommit.Result result = createChange("change 1", FILE_NAME, "content 1");
     String changeId1 = result.getChangeId();
-    addComment(result, "comment 1", false, true, null);
-    addComment(result, "comment 2", false, null, null);
-    addComment(result, "comment 3", false, false, null);
+    CommentsUtil.addComment(gApi, result, "comment 1", false, true, null);
+    CommentsUtil.addComment(gApi, result, "comment 2", false, null, null);
+    CommentsUtil.addComment(gApi, result, "comment 3", false, false, null);
     PushOneCommit.Result result2 = amendChange(changeId1);
-    addComment(result2, "comment4", false, true, null);
+    CommentsUtil.addComment(gApi, result2, "comment4", false, true, null);
 
     // Change2 has two comments in one thread, the first is unresolved and the second is resolved.
     result = createChange("change 2", FILE_NAME, "content 2");
     String changeId2 = result.getChangeId();
-    addComment(result, "comment 1", false, true, null);
+    CommentsUtil.addComment(gApi, result, "comment 1", false, true, null);
     Map<String, List<CommentInfo>> comments =
         getPublishedComments(changeId2, result.getCommit().name());
     assertThat(comments).hasSize(1);
     assertThat(comments.get(FILE_NAME)).hasSize(1);
-    addComment(result, "comment 2", false, false, comments.get(FILE_NAME).get(0).id);
+    CommentsUtil.addComment(
+        gApi, result, "comment 2", false, false, comments.get(FILE_NAME).get(0).id);
 
     // Change3 has two comments in one thread, the first is resolved, the second is unresolved.
     result = createChange("change 3", FILE_NAME, "content 3");
     String changeId3 = result.getChangeId();
-    addComment(result, "comment 1", false, false, null);
+    CommentsUtil.addComment(gApi, result, "comment 1", false, false, null);
     comments = getPublishedComments(result.getChangeId(), result.getCommit().name());
     assertThat(comments).hasSize(1);
     assertThat(comments.get(FILE_NAME)).hasSize(1);
-    addComment(result, "comment 2", false, true, comments.get(FILE_NAME).get(0).id);
+    CommentsUtil.addComment(
+        gApi, result, "comment 2", false, true, comments.get(FILE_NAME).get(0).id);
 
     try (AutoCloseable ignored = disableNoteDb()) {
       ChangeInfo changeInfo1 = Iterables.getOnlyElement(query(changeId1));
@@ -1436,7 +1390,7 @@
   @Test
   public void deleteCommentCannotBeAppliedByUser() throws Exception {
     PushOneCommit.Result result = createChange();
-    CommentInput targetComment = addComment(result.getChangeId());
+    CommentInput targetComment = CommentsUtil.addComment(gApi, result.getChangeId());
 
     Map<String, List<CommentInfo>> commentsMap =
         getPublishedComments(result.getChangeId(), result.getCommit().name());
@@ -1467,29 +1421,29 @@
     String ps1 = result1.getCommit().name();
 
     // 2nd commit: Add (c1) to PS1.
-    CommentInput c1 = newComment("a.txt", "comment 1");
-    addComments(changeId, ps1, c1);
+    CommentInput c1 = CommentsUtil.newComment("a.txt", "comment 1");
+    CommentsUtil.addComments(gApi, changeId, ps1, c1);
 
     // 3rd commit: Add (c2, c3) to PS1.
-    CommentInput c2 = newComment("a.txt", "comment 2");
-    CommentInput c3 = newComment("a.txt", "comment 3");
-    addComments(changeId, ps1, c2, c3);
+    CommentInput c2 = CommentsUtil.newComment("a.txt", "comment 2");
+    CommentInput c3 = CommentsUtil.newComment("a.txt", "comment 3");
+    CommentsUtil.addComments(gApi, changeId, ps1, c2, c3);
 
     // 4th commit: Add (c4) to PS1.
-    CommentInput c4 = newComment("a.txt", "comment 4");
-    addComments(changeId, ps1, c4);
+    CommentInput c4 = CommentsUtil.newComment("a.txt", "comment 4");
+    CommentsUtil.addComments(gApi, changeId, ps1, c4);
 
     // 5th commit: Create PS2.
     PushOneCommit.Result result2 = amendChange(changeId, "refs/for/master", "b.txt", "b");
     String ps2 = result2.getCommit().name();
 
     // 6th commit: Add (c5) to PS1.
-    CommentInput c5 = newComment("a.txt", "comment 5");
-    addComments(changeId, ps1, c5);
+    CommentInput c5 = CommentsUtil.newComment("a.txt", "comment 5");
+    CommentsUtil.addComments(gApi, changeId, ps1, c5);
 
     // 7th commit: Add (c6) to PS2.
-    CommentInput c6 = newComment("b.txt", "comment 6");
-    addComments(changeId, ps2, c6);
+    CommentInput c6 = CommentsUtil.newComment("b.txt", "comment 6");
+    CommentsUtil.addComments(gApi, changeId, ps2, c6);
 
     // 8th commit: Create PS3.
     PushOneCommit.Result result3 = amendChange(changeId);
@@ -1500,13 +1454,13 @@
     String ps4 = result4.getCommit().name();
 
     // 10th commit: Add (c7, c8) to PS4.
-    CommentInput c7 = newComment("c.txt", "comment 7");
-    CommentInput c8 = newComment("b.txt", "comment 8");
-    addComments(changeId, ps4, c7, c8);
+    CommentInput c7 = CommentsUtil.newComment("c.txt", "comment 7");
+    CommentInput c8 = CommentsUtil.newComment("b.txt", "comment 8");
+    CommentsUtil.addComments(gApi, changeId, ps4, c7, c8);
 
     // 11th commit: Add (c9) to PS2.
-    CommentInput c9 = newCommentWithOnlyMandatoryFields("b.txt", "comment 9");
-    addComments(changeId, ps2, c9);
+    CommentInput c9 = CommentsUtil.newCommentWithOnlyMandatoryFields("b.txt", "comment 9");
+    CommentsUtil.addComments(gApi, changeId, ps2, c9);
 
     List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(id.get());
     assertThat(commentsBeforeDelete).hasSize(9);
@@ -1549,14 +1503,14 @@
     }
 
     // Make sure that comments can still be added correctly.
-    CommentInput c10 = newComment("a.txt", "comment 10");
-    CommentInput c11 = newComment("b.txt", "comment 11");
-    CommentInput c12 = newComment("a.txt", "comment 12");
-    CommentInput c13 = newComment("c.txt", "comment 13");
-    addComments(changeId, ps1, c10);
-    addComments(changeId, ps2, c11);
-    addComments(changeId, ps3, c12);
-    addComments(changeId, ps4, c13);
+    CommentInput c10 = CommentsUtil.newComment("a.txt", "comment 10");
+    CommentInput c11 = CommentsUtil.newComment("b.txt", "comment 11");
+    CommentInput c12 = CommentsUtil.newComment("a.txt", "comment 12");
+    CommentInput c13 = CommentsUtil.newComment("c.txt", "comment 13");
+    CommentsUtil.addComments(gApi, changeId, ps1, c10);
+    CommentsUtil.addComments(gApi, changeId, ps2, c11);
+    CommentsUtil.addComments(gApi, changeId, ps3, c12);
+    CommentsUtil.addComments(gApi, changeId, ps4, c13);
 
     assertThat(getChangeSortedComments(id.get())).hasSize(13);
     assertThat(getRevisionComments(changeId, ps1)).hasSize(6);
@@ -1572,12 +1526,12 @@
     String changeId = result.getChangeId();
     String ps1 = result.getCommit().name();
 
-    CommentInput c1 = newComment(FILE_NAME, "comment 1");
-    CommentInput c2 = newComment(FILE_NAME, "comment 2");
-    CommentInput c3 = newComment(FILE_NAME, "comment 3");
-    addComments(changeId, ps1, c1);
-    addComments(changeId, ps1, c2);
-    addComments(changeId, ps1, c3);
+    CommentInput c1 = CommentsUtil.newComment(FILE_NAME, "comment 1");
+    CommentInput c2 = CommentsUtil.newComment(FILE_NAME, "comment 2");
+    CommentInput c3 = CommentsUtil.newComment(FILE_NAME, "comment 3");
+    CommentsUtil.addComments(gApi, changeId, ps1, c1);
+    CommentsUtil.addComments(gApi, changeId, ps1, c2);
+    CommentsUtil.addComments(gApi, changeId, ps1, c3);
 
     List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(id.get());
     assertThat(commentsBeforeDelete).hasSize(3);
@@ -1614,10 +1568,10 @@
     String parentRobotCommentUuid =
         changeOperations.change(changeId).currentPatchset().newRobotComment().create();
 
-    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    CommentInput createdCommentInput = CommentsUtil.newComment(COMMIT_MSG, "comment reply");
     createdCommentInput.inReplyTo = parentRobotCommentUuid;
     createdCommentInput.unresolved = null;
-    addComments(changeId, createdCommentInput);
+    CommentsUtil.addComments(gApi, changeId, createdCommentInput);
 
     CommentInfo resultNewComment =
         Iterables.getOnlyElement(
@@ -1637,9 +1591,9 @@
     String parentCommentUuid =
         changeOperations.change(changeId).currentPatchset().newComment().create();
 
-    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    CommentInput createdCommentInput = CommentsUtil.newComment(COMMIT_MSG, "comment reply");
     createdCommentInput.inReplyTo = parentCommentUuid;
-    addComments(changeId, createdCommentInput);
+    CommentsUtil.addComments(gApi, changeId, createdCommentInput);
 
     CommentInfo resultNewComment =
         Iterables.getOnlyElement(
@@ -1655,9 +1609,9 @@
     String parentRobotCommentUuid =
         changeOperations.change(changeId).currentPatchset().newRobotComment().create();
 
-    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    CommentInput createdCommentInput = CommentsUtil.newComment(COMMIT_MSG, "comment reply");
     createdCommentInput.inReplyTo = parentRobotCommentUuid;
-    addComments(changeId, createdCommentInput);
+    CommentsUtil.addComments(gApi, changeId, createdCommentInput);
 
     CommentInfo resultNewComment =
         Iterables.getOnlyElement(
@@ -1671,11 +1625,12 @@
   public void cannotCreateCommentWithInvalidInReplyTo() throws Exception {
     Change.Id changeId = changeOperations.newChange().create();
 
-    CommentInput comment = newComment(COMMIT_MSG, "comment 1 reply");
+    CommentInput comment = CommentsUtil.newComment(COMMIT_MSG, "comment 1 reply");
     comment.inReplyTo = "invalid";
 
     BadRequestException exception =
-        assertThrows(BadRequestException.class, () -> addComments(changeId, comment));
+        assertThrows(
+            BadRequestException.class, () -> CommentsUtil.addComments(gApi, changeId, comment));
     assertThat(exception.getMessage()).contains(String.format("%s not found", comment.inReplyTo));
   }
 
@@ -1685,27 +1640,6 @@
         .collect(toList());
   }
 
-  private CommentInput addComment(String changeId) throws Exception {
-    ReviewInput input = new ReviewInput();
-    CommentInput comment = newComment(FILE_NAME, Side.REVISION, 0, "a message", false);
-    input.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
-    gApi.changes().id(changeId).current().review(input);
-    return comment;
-  }
-
-  private void addComments(Change.Id changeId, CommentInput... commentInputs) throws Exception {
-    ReviewInput input = new ReviewInput();
-    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
-    gApi.changes().id(changeId.get()).current().review(input);
-  }
-
-  private void addComments(String changeId, String revision, CommentInput... commentInputs)
-      throws Exception {
-    ReviewInput input = new ReviewInput();
-    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
-    gApi.changes().id(changeId).revision(revision).review(input);
-  }
-
   /**
    * All the commits, which contain the target comment before, should still contain the comment with
    * the updated message. All the other metas of the commits should be exactly the same.
@@ -1766,64 +1700,6 @@
     return m.matches() ? m.group(1) : msg;
   }
 
-  private ReviewInput newInput(CommentInput c) {
-    ReviewInput in = new ReviewInput();
-    in.comments = new HashMap<>();
-    in.comments.put(c.path, Lists.newArrayList(c));
-    return in;
-  }
-
-  private void addComment(PushOneCommit.Result r, String message) throws Exception {
-    addComment(r, message, false, false, null, null, null);
-  }
-
-  private void addCommentOnLine(PushOneCommit.Result r, String message, int line) throws Exception {
-    addComment(r, message, false, false, null, line, null);
-  }
-
-  private void addCommentOnRange(PushOneCommit.Result r, String message, Comment.Range range)
-      throws Exception {
-    addComment(r, message, false, false, null, null, range);
-  }
-
-  private Comment.Range commentRangeInLines(int startLine, int endLine) {
-    Comment.Range range = new Comment.Range();
-    range.startLine = startLine;
-    range.endLine = endLine;
-    return range;
-  }
-
-  private void addComment(
-      PushOneCommit.Result r,
-      String message,
-      boolean omitDuplicateComments,
-      Boolean unresolved,
-      String inReplyTo)
-      throws Exception {
-    addComment(r, message, omitDuplicateComments, unresolved, inReplyTo, null, null);
-  }
-
-  private void addComment(
-      PushOneCommit.Result r,
-      String message,
-      boolean omitDuplicateComments,
-      Boolean unresolved,
-      String inReplyTo,
-      Integer line,
-      Comment.Range range)
-      throws Exception {
-    CommentInput c = new CommentInput();
-    c.line = line == null ? 1 : line;
-    c.message = message;
-    c.path = FILE_NAME;
-    c.unresolved = unresolved;
-    c.inReplyTo = inReplyTo;
-    c.range = range;
-    ReviewInput in = newInput(c);
-    in.omitDuplicateComments = omitDuplicateComments;
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
-  }
-
   private CommentInfo addDraft(String changeId, String revId, DraftInput in) throws Exception {
     return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
   }
@@ -1876,78 +1752,6 @@
     return gApi.changes().id(changeId).revision(revId).draft(uuid).get();
   }
 
-  private static CommentInput newComment(String file, String message) {
-    return newComment(file, Side.REVISION, 0, message, false);
-  }
-
-  private static CommentInput newCommentWithOnlyMandatoryFields(String path, String message) {
-    CommentInput c = new CommentInput();
-    c.unresolved = false;
-    return populate(c, path, null, null, null, null, message);
-  }
-
-  private static CommentInput newComment(
-      String path, Side side, int line, String message, Boolean unresolved) {
-    CommentInput c = new CommentInput();
-    c.unresolved = unresolved;
-    return populate(c, path, side, null, line, message);
-  }
-
-  private static CommentInput newCommentOnParent(
-      String path, int parent, int line, String message) {
-    CommentInput c = new CommentInput();
-    c.unresolved = false;
-    return populate(c, path, Side.PARENT, parent, line, message);
-  }
-
-  private DraftInput newDraft(String path, Side side, int line, String message) {
-    DraftInput d = new DraftInput();
-    d.unresolved = false;
-    return populate(d, path, side, null, line, message);
-  }
-
-  private DraftInput newDraft(String path, Side side, Comment.Range range, String message) {
-    DraftInput d = new DraftInput();
-    d.unresolved = false;
-    return populate(d, path, side, null, range.startLine, range, message);
-  }
-
-  private DraftInput newDraftOnParent(String path, int parent, int line, String message) {
-    DraftInput d = new DraftInput();
-    d.unresolved = false;
-    return populate(d, path, Side.PARENT, parent, line, message);
-  }
-
-  private DraftInput newDraftWithOnlyMandatoryFields(String path, String message) {
-    DraftInput d = new DraftInput();
-    d.unresolved = false;
-    return populate(d, path, null, null, null, null, message);
-  }
-
-  private static <C extends Comment> C populate(
-      C c,
-      String path,
-      Side side,
-      Integer parent,
-      Integer line,
-      Comment.Range range,
-      String message) {
-    c.path = path;
-    c.side = side;
-    c.parent = parent;
-    c.line = line != null && line != 0 ? line : null;
-    c.message = message;
-    if (range != null) {
-      c.range = range;
-    }
-    return c;
-  }
-
-  private static <C extends Comment> C populate(
-      C c, String path, Side side, Integer parent, int line, String message) {
-    return populate(c, path, side, parent, line, null, message);
-  }
-
   private static Comment.Range createLineRange(int startChar, int endChar) {
     Comment.Range range = new Comment.Range();
     range.startLine = 1;
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
new file mode 100644
index 0000000..c4927f0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
@@ -0,0 +1,193 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.Change;
+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.client.Comment;
+import com.google.gerrit.extensions.client.Side;
+import java.util.Arrays;
+import java.util.HashMap;
+
+/**
+ * A utility class for creating {@link CommentInput} objects, publishing comments and creating draft
+ * comments. Used by tests that require dealing with comments.
+ */
+class CommentsUtil {
+  static CommentInput addComment(GerritApi gApi, String changeId) throws Exception {
+    ReviewInput input = new ReviewInput();
+    CommentInput comment = CommentsUtil.newComment(FILE_NAME, Side.REVISION, 0, "a message", false);
+    input.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
+    gApi.changes().id(changeId).current().review(input);
+    return comment;
+  }
+
+  static void addComments(GerritApi gApi, Change.Id changeId, CommentInput... commentInputs)
+      throws Exception {
+    ReviewInput input = new ReviewInput();
+    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
+    gApi.changes().id(changeId.get()).current().review(input);
+  }
+
+  static void addComments(
+      GerritApi gApi, String changeId, String revision, CommentInput... commentInputs)
+      throws Exception {
+    ReviewInput input = new ReviewInput();
+    input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
+    gApi.changes().id(changeId).revision(revision).review(input);
+  }
+
+  static CommentInput newComment(String file, String message) {
+    return newComment(file, Side.REVISION, 0, message, false);
+  }
+
+  static CommentInput newCommentWithOnlyMandatoryFields(String path, String message) {
+    CommentInput c = new CommentInput();
+    c.unresolved = false;
+    return populate(c, path, null, null, null, null, message);
+  }
+
+  static CommentInput newComment(
+      String path, Side side, int line, String message, Boolean unresolved) {
+    CommentInput c = new CommentInput();
+    c.unresolved = unresolved;
+    return populate(c, path, side, null, line, message);
+  }
+
+  static CommentInput newComment(
+      String path, Side side, Comment.Range range, String message, Boolean unresolved) {
+    CommentInput c = new CommentInput();
+    c.unresolved = unresolved;
+    return populate(c, path, side, null, null, range, message);
+  }
+
+  static CommentInput newCommentOnParent(String path, int parent, int line, String message) {
+    CommentInput c = new CommentInput();
+    c.unresolved = false;
+    return populate(c, path, Side.PARENT, parent, line, message);
+  }
+
+  static DraftInput newDraft(String path, Side side, int line, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, side, null, line, message);
+  }
+
+  static DraftInput newDraft(String path, Side side, Comment.Range range, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, side, null, range.startLine, range, message);
+  }
+
+  static DraftInput newDraftOnParent(String path, int parent, int line, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, Side.PARENT, parent, line, message);
+  }
+
+  static DraftInput newDraftWithOnlyMandatoryFields(String path, String message) {
+    DraftInput d = new DraftInput();
+    d.unresolved = false;
+    return populate(d, path, null, null, null, null, message);
+  }
+
+  static <C extends Comment> C populate(
+      C c,
+      String path,
+      Side side,
+      Integer parent,
+      Integer line,
+      Comment.Range range,
+      String message) {
+    c.path = path;
+    c.side = side;
+    c.parent = parent;
+    c.line = line != null && line != 0 ? line : null;
+    c.message = message;
+    if (range != null) {
+      c.range = range;
+    }
+    return c;
+  }
+
+  static <C extends Comment> C populate(
+      C c, String path, Side side, Integer parent, int line, String message) {
+    return populate(c, path, side, parent, line, null, message);
+  }
+
+  static ReviewInput newInput(CommentInput c) {
+    ReviewInput in = new ReviewInput();
+    in.comments = new HashMap<>();
+    in.comments.put(c.path, Lists.newArrayList(c));
+    return in;
+  }
+
+  static void addComment(GerritApi gApi, PushOneCommit.Result r, String message) throws Exception {
+    addComment(gApi, r, message, false, false, null, null, null);
+  }
+
+  static void addComment(
+      GerritApi gApi,
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo)
+      throws Exception {
+    addComment(gApi, r, message, omitDuplicateComments, unresolved, inReplyTo, null, null);
+  }
+
+  static void addCommentOnLine(GerritApi gApi, PushOneCommit.Result r, String message, int line)
+      throws Exception {
+    addComment(gApi, r, message, false, false, null, line, null);
+  }
+
+  static void addCommentOnRange(
+      GerritApi gApi, PushOneCommit.Result r, String message, Comment.Range range)
+      throws Exception {
+    addComment(gApi, r, message, false, false, null, null, range);
+  }
+
+  static void addComment(
+      GerritApi gApi,
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo,
+      Integer line,
+      Comment.Range range)
+      throws Exception {
+    CommentInput c = new CommentInput();
+    c.line = line == null ? 1 : line;
+    c.message = message;
+    c.path = FILE_NAME;
+    c.unresolved = unresolved;
+    c.inReplyTo = inReplyTo;
+    c.range = range;
+    ReviewInput in = CommentsUtil.newInput(c);
+    in.omitDuplicateComments = omitDuplicateComments;
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/BUILD b/javatests/com/google/gerrit/acceptance/server/experiments/BUILD
new file mode 100644
index 0000000..0f01ffa
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_experiments",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
new file mode 100644
index 0000000..09e6dfe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.experiments;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+/** Tests for {@link ExperimentFeatures} */
+public class ExperimentFeaturesIT extends AbstractDaemonTest {
+
+  @Inject ExperimentFeatures experimentFeatures;
+
+  @Test
+  public void emptyConfig_defaultFeatures_enabled() {
+    for (String defaultFeature : ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES) {
+      assertThat(experimentFeatures.isFeatureEnabled(defaultFeature)).isTrue();
+    }
+
+    assertThat(experimentFeatures.getEnabledExperimentFeatures())
+        .isEqualTo(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"enabledFeature", "enabledThenDisabledFeature"})
+  @GerritConfig(
+      name = "experiments.disabled",
+      values = {"enabledThenDisabledFeature"})
+  public void configOverride_anyFeatureAllowed() {
+    assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
+    assertThat(experimentFeatures.isFeatureEnabled("enabledThenDisabledFeature")).isFalse();
+    assertThat(experimentFeatures.isFeatureEnabled("unknownFeature")).isFalse();
+    ImmutableSet<String> expectedEnabledFeatures =
+        new ImmutableSet.Builder<String>()
+            .addAll(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES)
+            .add("enabledFeature")
+            .build();
+    assertThat(experimentFeatures.getEnabledExperimentFeatures())
+        .isEqualTo(expectedEnabledFeatures);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {"enabledFeature"})
+  @GerritConfig(
+      name = "experiments.disabled",
+      values = {"UiFeature__patchset_comments"})
+  public void configOverride_defaultFeatureDisabled() {
+    assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
+    assertThat(
+            experimentFeatures.isFeatureEnabled(
+                ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS))
+        .isFalse();
+    assertThat(experimentFeatures.getEnabledExperimentFeatures()).containsExactly("enabledFeature");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 9b12f29..f267513 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
@@ -89,7 +90,7 @@
         .add(allow(Permission.FORGE_COMMITTER).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.ABANDON).ref("refs/*").group(REGISTERED_USERS))
-        .add(allowLabel("Code-Review").ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
         .update();
   }
 
@@ -1470,14 +1471,14 @@
 
   private void deleteVote(StagedChange sc, TestAccount account) throws Exception {
     sender.clear();
-    gApi.changes().id(sc.changeId).reviewer(account.email()).deleteVote("Code-Review");
+    gApi.changes().id(sc.changeId).reviewer(account.email()).deleteVote(LabelId.CODE_REVIEW);
   }
 
   private void deleteVote(StagedChange sc, TestAccount account, NotifyHandling notify)
       throws Exception {
     sender.clear();
     DeleteVoteInput in = new DeleteVoteInput();
-    in.label = "Code-Review";
+    in.label = LabelId.CODE_REVIEW;
     in.notify = notify;
     gApi.changes().id(sc.changeId).reviewer(account.email()).deleteVote(in);
   }
@@ -1714,7 +1715,7 @@
         .sent("newpatchset", sc)
         .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
         .to(sc.reviewer)
-        .to(other)
+        .cc(other)
         .cc(sc.ccer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
@@ -1730,7 +1731,7 @@
         .sent("newpatchset", sc)
         .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
         .to(sc.reviewer)
-        .to(other)
+        .cc(other)
         .cc(sc.ccer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/BUILD b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
new file mode 100644
index 0000000..e89e8d1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_permissions",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
new file mode 100644
index 0000000..9e4907c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -0,0 +1,301 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+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.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ExternalUser;
+import com.google.gerrit.server.PropertyMap;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.util.Collection;
+import java.util.Set;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests that permission logic used by {@link ExternalUser} works as expected. */
+public class ExternalUserPermissionIT extends AbstractDaemonTest {
+  private static final AccountGroup.UUID EXTERNAL_GROUP =
+      AccountGroup.uuid("company-auth:it-department");
+
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private ExternalUser.Factory externalUserFactory;
+  @Inject private GroupOperations groupOperations;
+
+  @Before
+  public void setUp() {
+    // Allow only read on refs/heads/master by default
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/heads/*").group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .update();
+  }
+
+  @Override
+  public Module createModule() {
+    /**
+     * Binding a {@link GroupBackend} that pretends a user is part of a group if the external ID
+     * starts with the group UUID.
+     *
+     * <p>Example: Users "company-auth:it-department-1" and "company-auth:it-department-2" are a
+     * member of the group "company-auth:it-department"
+     */
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), GroupBackend.class)
+            .toInstance(
+                new GroupBackend() {
+                  @Override
+                  public boolean handles(AccountGroup.UUID uuid) {
+                    return uuid.get().startsWith("company-auth:");
+                  }
+
+                  @Override
+                  public GroupDescription.Basic get(AccountGroup.UUID uuid) {
+                    return new GroupDescription.Basic() {
+                      @Override
+                      public AccountGroup.UUID getGroupUUID() {
+                        return uuid;
+                      }
+
+                      @Override
+                      public String getName() {
+                        return uuid.get();
+                      }
+
+                      @Override
+                      public String getEmailAddress() {
+                        return uuid.get() + "@example.com";
+                      }
+
+                      @Override
+                      public String getUrl() {
+                        return null;
+                      }
+                    };
+                  }
+
+                  @Override
+                  public Collection<GroupReference> suggest(String name, ProjectState project) {
+                    throw new UnsupportedOperationException("not implemented");
+                  }
+
+                  @Override
+                  public GroupMembership membershipsOf(CurrentUser user) {
+                    return new GroupMembership() {
+                      @Override
+                      public boolean contains(AccountGroup.UUID groupId) {
+                        return user.getExternalIdKeys().stream()
+                            .anyMatch(e -> e.get().startsWith(groupId.get()));
+                      }
+
+                      @Override
+                      public boolean containsAnyOf(Iterable<AccountGroup.UUID> groupIds) {
+                        return ImmutableList.copyOf(groupIds).stream().anyMatch(g -> contains(g));
+                      }
+
+                      @Override
+                      public Set<AccountGroup.UUID> intersection(
+                          Iterable<AccountGroup.UUID> groupIds) {
+                        return ImmutableList.copyOf(groupIds).stream()
+                            .filter(g -> contains(g))
+                            .collect(toImmutableSet());
+                      }
+
+                      @Override
+                      public Set<AccountGroup.UUID> getKnownGroups() {
+                        return ImmutableSet.of();
+                      }
+                    };
+                  }
+
+                  @Override
+                  public boolean isVisibleToAll(AccountGroup.UUID uuid) {
+                    return false;
+                  }
+                });
+      }
+    };
+  }
+
+  @Test
+  public void defaultRefFilter_changeVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    ExternalUser user = createUserInGroup("1", "it-department");
+
+    Change.Id changeOnMaster = changeOperations.newChange().project(project).create();
+    Change.Id changeOnRefsMetaConfig =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    // Check that only the change on the default branch is visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)));
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(EXTERNAL_GROUP))
+        .update();
+    // Check that both changes are visible now
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            "refs/meta/config",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)),
+            RefNames.changeMetaRef(changeOnRefsMetaConfig),
+            RefNames.patchSetRef(PatchSet.id(changeOnRefsMetaConfig, 1)));
+  }
+
+  @Test
+  public void defaultRefFilter_refVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    ExternalUser user = createUserInGroup("1", "it-department");
+    // Check that refs/meta/config isn't visible by default
+    assertThat(getVisibleRefNames(user)).containsExactly("HEAD", "refs/heads/master");
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(EXTERNAL_GROUP))
+        .update();
+    // Check that refs/meta/config became visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly("HEAD", "refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void changeVisibility_changeOnInvisibleBranchNotVisible() throws Exception {
+    // Create a change that is not visible to members of 'externalGroup'
+    Change.Id invisibleChange =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    ExternalUser user = createUserInGroup("1", "it-department");
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                permissionBackend
+                    .user(user)
+                    .change(changeNotesFactory.create(project, invisibleChange))
+                    .check(ChangePermission.READ));
+    assertThat(thrown).hasMessageThat().isEqualTo("read not permitted");
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToAnonymousIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    ExternalUser user = createUserInGroup("1", "it-department");
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToRegisteredUsersIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    ExternalUser user = createUserInGroup("1", "it-department");
+    blockAnonymousRead();
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  @Test
+  public void externalUser_isContainedInternalGroupThatContainsExternalGroup() {
+    AccountGroup.UUID internalGroup =
+        groupOperations.newGroup().addSubgroup(EXTERNAL_GROUP).create();
+    ExternalUser user = createUserInGroup("1", "it-department");
+    assertThat(user.getEffectiveGroups().contains(internalGroup)).isTrue();
+    assertThat(user.getEffectiveGroups().contains(EXTERNAL_GROUP)).isTrue();
+    assertThat(user.getEffectiveGroups().contains(REGISTERED_USERS)).isTrue();
+    assertThat(user.getEffectiveGroups().contains(ANONYMOUS_USERS)).isTrue();
+  }
+
+  @GerritConfig(name = "groups.includeExternalUsersInRegisteredUsersGroup", value = "true")
+  @Test
+  public void externalUser_isContainedInRegisteredUsersIfConfigured() {
+    ExternalUser user = createUserInGroup("1", "it-department");
+    assertThat(user.getEffectiveGroups().contains(REGISTERED_USERS)).isTrue();
+  }
+
+  @GerritConfig(name = "groups.includeExternalUsersInRegisteredUsersGroup", value = "false")
+  @Test
+  public void externalUser_isNotContainedInRegisteredUsersIfNotConfigured() {
+    ExternalUser user = createUserInGroup("1", "it-department");
+    assertThat(user.getEffectiveGroups().contains(REGISTERED_USERS)).isFalse();
+  }
+
+  private ImmutableList<String> getVisibleRefNames(CurrentUser user) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return permissionBackend.user(user).project(project)
+          .filter(
+              repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+          .stream()
+          .map(Ref::getName)
+          .collect(toImmutableList());
+    }
+  }
+
+  ExternalUser createUserInGroup(String userId, String groupId) {
+    return externalUserFactory.create(
+        ImmutableSet.of(),
+        ImmutableSet.of(ExternalId.Key.parse("company-auth:" + groupId + "-" + userId)),
+        PropertyMap.EMPTY);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
new file mode 100644
index 0000000..d68d681
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+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.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.query.change.GroupBackedUser;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests that permission logic used by {@link GroupBackedUser} works as expected. */
+public class GroupBackedUserPermissionIT extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+
+  private final TestGroupBackend testGroupBackend = new TestGroupBackend();
+  private final AccountGroup.UUID externalGroup = AccountGroup.uuid("testbackend:test");
+
+  @Before
+  public void setUp() {
+    // Allow only read on refs/heads/master by default
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .update();
+  }
+
+  @Override
+  public Module createModule() {
+    /** Binding a {@link TestGroupBackend} to test adding external groups * */
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), GroupBackend.class).toInstance(testGroupBackend);
+      }
+    };
+  }
+
+  @Test
+  public void defaultRefFilter_changeVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    Change.Id changeOnMaster = changeOperations.newChange().project(project).create();
+    Change.Id changeOnRefsMetaConfig =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    // Check that only the change on the default branch is visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)));
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+        .update();
+    // Check that both changes are visible now
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            "refs/meta/config",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)),
+            RefNames.changeMetaRef(changeOnRefsMetaConfig),
+            RefNames.patchSetRef(PatchSet.id(changeOnRefsMetaConfig, 1)));
+  }
+
+  @Test
+  public void defaultRefFilter_refVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    // Check that refs/meta/config isn't visible by default
+    assertThat(getVisibleRefNames(user)).containsExactly("HEAD", "refs/heads/master");
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+        .update();
+    // Check that refs/meta/config became visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly("HEAD", "refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void changeVisibility_changeOnInvisibleBranchNotVisible() throws Exception {
+    // Create a change that is not visible to members of 'externalGroup'
+    Change.Id invisibleChange =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                permissionBackend
+                    .user(user)
+                    .change(changeNotesFactory.create(project, invisibleChange))
+                    .check(ChangePermission.READ));
+    assertThat(thrown).hasMessageThat().isEqualTo("read not permitted");
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToAnonymousIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToRegisteredUsersIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    blockAnonymousRead();
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  private ImmutableList<String> getVisibleRefNames(CurrentUser user) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return permissionBackend.user(user).project(project)
+          .filter(
+              repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+          .stream()
+          .map(Ref::getName)
+          .collect(toImmutableList());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
index 127f34b..df668a5 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.AccountGroup;
@@ -65,6 +66,44 @@
   }
 
   @Test
+  @GerritConfig(name = "gerrit.enablePeerIPInReflogRecord", value = "true")
+  public void peerIPIncludedInReflogRecord() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
+      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
+      if (!log.exists()) {
+        log.getParentFile().mkdirs();
+        assertThat(log.createNewFile()).isTrue();
+      }
+
+      gApi.changes().id(id.get()).topic("foo");
+      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
+      assertThat(last.getWho().getEmailAddress())
+          .isEqualTo(admin.username() + "|account-" + admin.id() + "@unknown");
+    }
+  }
+
+  @Test
+  public void emaiIncludedInReflogRecord() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    try (Repository repo = repoManager.openRepository(r.getChange().project())) {
+      File log = new File(repo.getDirectory(), "logs/" + changeMetaRef(id));
+      if (!log.exists()) {
+        log.getParentFile().mkdirs();
+        assertThat(log.createNewFile()).isTrue();
+      }
+
+      gApi.changes().id(id.get()).topic("foo");
+      ReflogEntry last = repo.getReflogReader(changeMetaRef(id)).getLastEntry();
+      assertThat(last.getWho().getEmailAddress()).isEqualTo(admin.email());
+    }
+  }
+
+  @Test
   public void reflogUpdatedBySubmittingChange() throws Exception {
     BranchApi branchApi = gApi.projects().name(project.get()).branch("master");
     List<ReflogEntryInfo> reflog = branchApi.reflog();
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
index d3b40cc..4ce2deb 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
@@ -20,9 +20,10 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
 import com.google.inject.Inject;
 import java.util.Map;
@@ -37,7 +38,7 @@
 
   @Test
   public void blocksWhenUploaderIsOnlyApprover() throws Exception {
-    enableRule("Code-Review", true);
+    enableRule(LabelId.CODE_REVIEW, true);
 
     PushOneCommit.Result r = createChange();
     approve(r.getChangeId());
@@ -50,7 +51,7 @@
     assertThat(result.labels).isNotEmpty();
     assertThat(result.requirements)
         .containsExactly(
-            SubmitRequirement.builder()
+            LegacySubmitRequirement.builder()
                 .setFallbackText("Approval from non-uploader required")
                 .setType("non_uploader_approval")
                 .build());
@@ -58,7 +59,7 @@
 
   @Test
   public void allowsSubmissionWhenChangeHasNonUploaderApproval() throws Exception {
-    enableRule("Code-Review", true);
+    enableRule(LabelId.CODE_REVIEW, true);
 
     // Create change as user
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(project, user);
@@ -74,7 +75,7 @@
 
   @Test
   public void doesNothingByDefault() throws Exception {
-    enableRule("Code-Review", false);
+    enableRule(LabelId.CODE_REVIEW, false);
 
     PushOneCommit.Result r = createChange();
     approve(r.getChangeId());
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
index 6079388..bf8b1f8 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.PrologOptions;
@@ -41,7 +42,7 @@
   public void convertsPrologToSubmitRecord() {
     PrologRuleEvaluator evaluator = makeEvaluator();
 
-    StructureTerm verifiedLabel = makeLabel("Verified", "may");
+    StructureTerm verifiedLabel = makeLabel(LabelId.VERIFIED, "may");
     StructureTerm labels = new StructureTerm("label", verifiedLabel);
 
     List<Term> terms = ImmutableList.of(makeTerm("ok", labels));
@@ -85,12 +86,12 @@
     PrologRuleEvaluator evaluator = makeEvaluator();
 
     SubmitRecord.Label submitRecordLabel1 = new SubmitRecord.Label();
-    submitRecordLabel1.label = "Verified";
+    submitRecordLabel1.label = LabelId.VERIFIED;
     submitRecordLabel1.status = SubmitRecord.Label.Status.REJECT;
     submitRecordLabel1.appliedBy = admin.id();
 
     SubmitRecord.Label submitRecordLabel2 = new SubmitRecord.Label();
-    submitRecordLabel2.label = "Code-Review";
+    submitRecordLabel2.label = LabelId.CODE_REVIEW;
     submitRecordLabel2.status = SubmitRecord.Label.Status.OK;
     submitRecordLabel2.appliedBy = admin.id();
 
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index 5cbc767..6cb13c5 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -27,7 +28,9 @@
 import com.google.gerrit.server.project.SubmitRuleOptions;
 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 org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
@@ -39,7 +42,7 @@
 @NoHttpd
 public class RulesIT extends AbstractDaemonTest {
   private static final String RULE_TEMPLATE =
-      "submit_rule(submit(W)) :- \n" + "%s,\n" + "W = label('OK', ok(user(1000000))).";
+      "submit_rule(submit(W)) :- \n%s,\nW = label('OK', ok(user(1000000))).";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
@@ -89,12 +92,122 @@
     assertThat(statusForRuleRemoveFile()).isEqualTo(SubmitRecord.Status.OK);
   }
 
+  @Test
+  public void testCommitDelta_pass() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('file1\\.txt')");
+    assertThat(statusForRuleAddFile("file1.txt")).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_fail() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('no such file')");
+    assertThat(statusForRuleAddFile("file1.txt")).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+  }
+
+  @Test
+  public void testCommitDelta_addOwners_pass() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('OWNERS', add, _, _)");
+    assertThat(statusForRuleAddFile("foo/OWNERS")).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_addOwners_fail() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('OWNERS', add, _, _)");
+    assertThat(statusForRuleAddFile("foobar")).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+  }
+
+  @Test
+  public void testCommitDelta_regexp() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*')");
+    assertThat(statusForRuleAddFile("foo/bar")).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_add_provideNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'foo')");
+    assertThat(statusForRuleAddFile("foo")).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_modify_provideNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'a.txt')");
+    assertThat(statusForRuleModifyFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_delete_provideNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'a.txt')");
+    assertThat(statusForRuleRemoveFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_rename_provideOldName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'a.txt')");
+    assertThat(statusForRuleRenamedFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_rename_provideNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('.*', _, 'b.txt')");
+    assertThat(statusForRuleRenamedFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_rename_matchOldName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('a\\.txt')");
+    assertThat(statusForRuleRenamedFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testCommitDelta_rename_matchNewName() throws Exception {
+    modifySubmitRules("gerrit:commit_delta('b\\.txt')");
+    assertThat(statusForRuleRenamedFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void typeError() throws Exception {
+    modifySubmitRules("user(1000000)."); // the trailing '.' triggers a type error
+    assertThat(statusForRuleAddFile("foo")).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+  }
+
   private SubmitRecord.Status statusForRule() throws Exception {
     String oldHead = projectOperations.project(project).getHead("master").name();
-    PushOneCommit.Result result1 =
+    PushOneCommit.Result result =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     testRepo.reset(oldHead);
-    return getStatus(result1);
+    return getStatus(result);
+  }
+
+  private SubmitRecord.Status statusForRuleAddFile(String... filenames) throws Exception {
+    Map<String, String> fileToContentMap =
+        Arrays.stream(filenames).collect(ImmutableMap.toImmutableMap(f -> f, f -> "file content"));
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "subject", fileToContentMap);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+    testRepo.reset(oldHead);
+    return getStatus(result);
+  }
+
+  private SubmitRecord.Status statusForRuleModifyFile() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+
+    // create a.txt
+    commitBuilder().add(PushOneCommit.FILE_NAME, "Hey, it's me!").message("subject").create();
+    pushHead(testRepo, "refs/heads/master", false);
+
+    PushOneCommit.Result result =
+        pushFactory
+            .create(
+                user.newIdent(),
+                testRepo,
+                "subject",
+                ImmutableMap.of(PushOneCommit.FILE_NAME, "I've changed!"))
+            .rmFile(PushOneCommit.FILE_NAME)
+            .to("refs/for/master");
+    testRepo.reset(oldHead);
+    return getStatus(result);
   }
 
   private SubmitRecord.Status statusForRuleRemoveFile() throws Exception {
@@ -110,15 +223,30 @@
     return getStatus(result);
   }
 
-  private SubmitRecord.Status getStatus(PushOneCommit.Result result1) throws Exception {
-    ChangeData cd = result1.getChange();
+  private SubmitRecord.Status statusForRuleRenamedFile() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+
+    // create a.txt
+    commitBuilder().add(PushOneCommit.FILE_NAME, "Hey, it's me!").message("subject").create();
+    pushHead(testRepo, "refs/heads/master", false);
+
+    PushOneCommit.Result result =
+        pushFactory
+            .create(user.newIdent(), testRepo, "subject", ImmutableMap.of("b.txt", "Hey, it's me!"))
+            .rmFile(PushOneCommit.FILE_NAME)
+            .to("refs/for/master");
+    testRepo.reset(oldHead);
+    return getStatus(result);
+  }
+
+  private SubmitRecord.Status getStatus(PushOneCommit.Result result) throws Exception {
+    ChangeData cd = result.getChange();
 
     Collection<SubmitRecord> records;
-    try (AutoCloseable changeIndex = disableChangeIndex()) {
-      try (AutoCloseable accountIndex = disableAccountIndex()) {
-        SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
-        records = ruleEvaluator.evaluate(cd);
-      }
+    try (AutoCloseable ignored1 = disableChangeIndex();
+        AutoCloseable ignored2 = disableAccountIndex()) {
+      SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
+      records = ruleEvaluator.evaluate(cd);
     }
 
     assertThat(records).hasSize(1);
diff --git a/javatests/com/google/gerrit/acceptance/server/util/BUILD b/javatests/com/google/gerrit/acceptance/server/util/BUILD
new file mode 100644
index 0000000..ea25784
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_util",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java b/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java
new file mode 100644
index 0000000..06bf1ae
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.util;
+
+import static junit.framework.TestCase.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.gerrit.acceptance.AbstractPluginLogFileTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.inject.Inject;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class PluginLogFileIT extends AbstractPluginLogFileTest {
+  @Inject private InvocationCounter invocationCounter;
+  private static final int NUMBER_OF_THREADS = 5;
+
+  @Test
+  public void testMultiThreadedPluginLogFile() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", Module.class)) {
+      ExecutorService service = Executors.newFixedThreadPool(NUMBER_OF_THREADS);
+      CountDownLatch latch = new CountDownLatch(NUMBER_OF_THREADS);
+      createChange();
+      for (int i = 0; i < NUMBER_OF_THREADS; i++) {
+        service.execute(
+            () -> {
+              try {
+                adminSshSession.exec("gerrit query --format json status:open --my-plugin--opt");
+                adminSshSession.assertSuccess();
+              } catch (Exception e) {
+                fail(e.getMessage());
+              } finally {
+                latch.countDown();
+              }
+            });
+      }
+      latch.await();
+      assertEquals(1, invocationCounter.getCounter());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
index 01b8eae..5429131 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/CreateProjectIT.java
@@ -19,6 +19,7 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.project.ProjectState;
 import java.util.Optional;
@@ -85,4 +86,35 @@
     assertThat(projectState).isPresent();
     assertThat(projectState.get().getName()).isEqualTo(newProjectName);
   }
+
+  @Test
+  public void withEmptyBranches() throws Exception {
+    String newProjectName = name("newProject");
+    adminSshSession.exec("gerrit create-project " + newProjectName);
+    adminSshSession.assertSuccess();
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertHead(newProjectName, "refs/heads/master");
+  }
+
+  @Test
+  public void withInitBranches() throws Exception {
+    String newProjectName = name("newProject");
+    adminSshSession.exec("gerrit create-project --branch init-branch " + newProjectName);
+    adminSshSession.assertSuccess();
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertHead(newProjectName, "refs/heads/init-branch");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.defaultBranch", value = "refs/heads/main")
+  public void withEmptyBranches_WhenDefaultBranchIsSet() throws Exception {
+    String newProjectName = name("newProject");
+    adminSshSession.exec("gerrit create-project " + newProjectName);
+    adminSshSession.assertSuccess();
+    Optional<ProjectState> projectState = projectCache.get(Project.nameKey(newProjectName));
+    assertThat(projectState).isPresent();
+    assertHead(newProjectName, "refs/heads/main");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java b/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java
new file mode 100644
index 0000000..4efa247
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.gerrit.server.query.change.OutputStreamQuery.GSON;
+import static junit.framework.TestCase.assertEquals;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.AbstractDynamicOptionsTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Module;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class DynamicOptionsIT extends AbstractDynamicOptionsTest {
+
+  @Override
+  public Module createSshModule() {
+    return new AbstractDynamicOptionsTest.PluginOneSshModule();
+  }
+
+  @Test
+  public void testDynamicPluginOptions() throws Exception {
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", AbstractDynamicOptionsTest.PluginTwoModule.class)) {
+      List<String> samples = getSamplesList(adminSshSession.exec("ls-samples"));
+      adminSshSession.assertSuccess();
+      assertEquals(Lists.newArrayList("sample1", "sample2"), samples);
+    }
+  }
+
+  protected List<String> getSamplesList(String sshOutput) {
+    return GSON.fromJson(sshOutput, new TypeToken<List<String>>() {}.getType());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java b/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java
new file mode 100644
index 0000000..0596cad
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractLifecycleListenersTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class LifecycleListenersIT extends AbstractLifecycleListenersTest {
+  @Inject private InvocationCheck invocationCheck;
+
+  @Before
+  public void before() {
+    invocationCheck.setStartInvoked(false);
+    invocationCheck.setStopInvoked(false);
+  }
+
+  @Test
+  public void lifecycleListenerSuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      adminSshSession.exec("gerrit query --format json status:open --my-plugin--opt");
+      adminSshSession.assertSuccess();
+      assertTrue(invocationCheck.isStartInvoked());
+      assertTrue(invocationCheck.isStopInvoked());
+    }
+  }
+
+  @Test
+  public void lifecycleListenerUnsuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      adminSshSession.exec("gerrit ls-projects");
+      adminSshSession.assertSuccess();
+      assertFalse(invocationCheck.isStartInvoked());
+      assertFalse(invocationCheck.isStopInvoked());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
index 38293f9..009e05d 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.acceptance.ssh;
 
-import static com.google.common.truth.Truth.assertThat;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.collect.ImmutableListMultimap;
@@ -41,31 +40,12 @@
   private static final Gson GSON = OutputStreamQuery.GSON;
 
   @Test
-  public void queryChangeWithNullAttribute() throws Exception {
-    getChangeWithNullAttribute(
-        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))));
-  }
-
-  @Test
-  public void queryChangeWithSimpleAttribute() throws Exception {
-    getChangeWithSimpleAttribute(
-        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))));
-  }
-
-  @Test
   public void querySingleChangeWithBulkAttribute() throws Exception {
     getSingleChangeWithPluginDefinedBulkAttribute(
         id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))));
   }
 
   @Test
-  public void queryChangeWithOption() throws Exception {
-    getChangeWithOption(
-        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))),
-        (id, opts) -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id, opts))));
-  }
-
-  @Test
   public void queryPluginDefinedAttributeChangeWithOption() throws Exception {
     getChangeWithPluginDefinedBulkAttributeOption(
         id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))),
@@ -85,12 +65,6 @@
   }
 
   @Test
-  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
-    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
-        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
-  }
-
-  @Test
   public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
     getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
         () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
@@ -116,15 +90,6 @@
   }
 
   @Nullable
-  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(String sshOutput)
-      throws Exception {
-    List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
-
-    assertThat(changeAttrs).hasSize(1);
-    return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
-  }
-
-  @Nullable
   private static Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromList(String sshOutput)
       throws Exception {
     List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index bbe7b81..2b37cfd 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -45,6 +45,7 @@
       ImmutableList.of(
           "apropos",
           "close-connection",
+          "convert-ref-storage",
           "flush-caches",
           "gc",
           "logging",
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
index f6421a5..48fd38c 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.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;
@@ -29,7 +28,6 @@
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.CurrentUser.PropertyKey;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -75,19 +73,6 @@
   }
 
   @Test
-  public void resetCurrentApiUserClearsCachedState() throws Exception {
-    requestScopeOperations.setApiUser(user.id());
-    PropertyKey<String> key = PropertyKey.create();
-    atrScope.get().getUser().put(key, "foo");
-    assertThat(atrScope.get().getUser().get(key)).hasValue("foo");
-
-    AcceptanceTestRequestScope.Context oldCtx = requestScopeOperations.resetCurrentApiUser();
-    checkCurrentUser(user.id());
-    assertThat(atrScope.get().getUser().get(key)).isEmpty();
-    assertThat(oldCtx.getUser().get(key)).hasValue("foo");
-  }
-
-  @Test
   public void setApiUserAnonymousSetsAnonymousUser() throws Exception {
     fastCheckCurrentUser(admin.id());
     requestScopeOperations.setApiUserAnonymous();
diff --git a/javatests/com/google/gerrit/auth/BUILD b/javatests/com/google/gerrit/auth/BUILD
new file mode 100644
index 0000000..6a41d01
--- /dev/null
+++ b/javatests/com/google/gerrit/auth/BUILD
@@ -0,0 +1,39 @@
+load("@rules_java//java:defs.bzl", "java_library")
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "auth_tests",
+    size = "medium",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    resource_strip_prefix = "resources",
+    resources = ["//resources/com/google/gerrit/server"],
+    tags = ["no_windows"],
+    visibility = ["//visibility:public"],
+    runtime_deps = [
+        "//java/com/google/gerrit/lucene",
+        "//lib/bouncycastle:bcprov",
+        "//prolog:gerrit-prolog-common",
+    ],
+    deps = [
+        "//java/com/google/gerrit/auth",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/proto/testing",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:guava-retrying",
+        "//lib:jgit",
+        "//lib:jgit-junit",
+        "//lib:protobuf",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java b/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
similarity index 91%
rename from javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java
rename to javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
index 042222f..7a61626 100644
--- a/javatests/com/google/gerrit/server/auth/ldap/LdapRealmTest.java
+++ b/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
@@ -12,19 +12,19 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.ldap;
+package com.google.gerrit.auth.ldap;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.auth.ldap.LdapModule.GROUP_CACHE;
+import static com.google.gerrit.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
+import static com.google.gerrit.auth.ldap.LdapModule.PARENT_GROUPS_CACHE;
+import static com.google.gerrit.auth.ldap.LdapModule.USERNAME_CACHE;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_HTTP;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_HTTPS;
 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.account.externalids.ExternalId.SCHEME_XRI;
-import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_CACHE;
-import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
-import static com.google.gerrit.server.auth.ldap.LdapModule.PARENT_GROUPS_CACHE;
-import static com.google.gerrit.server.auth.ldap.LdapModule.USERNAME_CACHE;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java
rename to javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
index 8f7cc35..1af78e3 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthRealmTest.java
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.oauth;
+package com.google.gerrit.auth.oauth;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
rename to javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java
index 64fa74f..e3357b8 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthTokenCacheTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.oauth;
+package com.google.gerrit.auth.oauth;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
diff --git a/javatests/com/google/gerrit/server/auth/openid/OpenIdRealmTest.java b/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/auth/openid/OpenIdRealmTest.java
rename to javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
index f5373ad..05b0ec0 100644
--- a/javatests/com/google/gerrit/server/auth/openid/OpenIdRealmTest.java
+++ b/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.auth.openid;
+package com.google.gerrit.auth.openid;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index be35d5a..3036811 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -55,7 +55,6 @@
 ELASTICSEARCH_TAGS = [
     "docker",
     "elastic",
-    "exclusive",
 ]
 
 [junit_tests(
diff --git a/javatests/com/google/gerrit/entities/LabelFunctionTest.java b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
index 3941564..0132697 100644
--- a/javatests/com/google/gerrit/entities/LabelFunctionTest.java
+++ b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
@@ -24,7 +24,7 @@
 import org.junit.Test;
 
 public class LabelFunctionTest {
-  private static final String LABEL_NAME = "Verified";
+  private static final String LABEL_NAME = LabelId.VERIFIED;
   private static final LabelId LABEL_ID = LabelId.create(LABEL_NAME);
   private static final Change.Id CHANGE_ID = Change.id(100);
   private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
@@ -39,7 +39,7 @@
   public void checkLabelNameIsCorrect() {
     for (LabelFunction function : LabelFunction.values()) {
       SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
-      assertThat(myLabel.label).isEqualTo("Verified");
+      assertThat(myLabel.label).isEqualTo(LabelId.VERIFIED);
     }
   }
 
diff --git a/javatests/com/google/gerrit/entities/PermissionTest.java b/javatests/com/google/gerrit/entities/PermissionTest.java
index 2915f79..3175671 100644
--- a/javatests/com/google/gerrit/entities/PermissionTest.java
+++ b/javatests/com/google/gerrit/entities/PermissionTest.java
@@ -34,9 +34,9 @@
     assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
     assertThat(Permission.isPermission("no-permission")).isFalse();
 
-    assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.isPermission("Code-Review")).isFalse();
+    assertThat(Permission.isPermission(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isPermission(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isPermission(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
@@ -44,9 +44,9 @@
     assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
     assertThat(Permission.hasRange("no-permission")).isFalse();
 
-    assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.hasRange("Code-Review")).isFalse();
+    assertThat(Permission.hasRange(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.hasRange(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.hasRange(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
@@ -54,9 +54,9 @@
     assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
     assertThat(Permission.isLabel("no-permission")).isFalse();
 
-    assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
-    assertThat(Permission.isLabel("Code-Review")).isFalse();
+    assertThat(Permission.isLabel(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isLabel(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
@@ -64,27 +64,30 @@
     assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
     assertThat(Permission.isLabelAs("no-permission")).isFalse();
 
-    assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
-    assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.isLabelAs("Code-Review")).isFalse();
+    assertThat(Permission.isLabelAs(Permission.LABEL + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isLabelAs(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isLabelAs(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
   public void forLabel() {
-    assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
+    assertThat(Permission.forLabel(LabelId.CODE_REVIEW))
+        .isEqualTo(Permission.LABEL + LabelId.CODE_REVIEW);
   }
 
   @Test
   public void forLabelAs() {
-    assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
+    assertThat(Permission.forLabelAs(LabelId.CODE_REVIEW))
+        .isEqualTo(Permission.LABEL_AS + LabelId.CODE_REVIEW);
   }
 
   @Test
   public void extractLabel() {
-    assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
-    assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
-        .isEqualTo("Code-Review");
-    assertThat(Permission.extractLabel("Code-Review")).isNull();
+    assertThat(Permission.extractLabel(Permission.LABEL + LabelId.CODE_REVIEW))
+        .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.extractLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW))
+        .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.extractLabel(LabelId.CODE_REVIEW)).isNull();
     assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
   }
 
@@ -92,17 +95,23 @@
   public void canBeOnAllProjects() {
     assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
     assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
-    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
+    assertThat(
+            Permission.canBeOnAllProjects(
+                AccessSection.ALL, Permission.LABEL + LabelId.CODE_REVIEW))
         .isTrue();
     assertThat(
-            Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
+            Permission.canBeOnAllProjects(
+                AccessSection.ALL, Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isTrue();
 
     assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
     assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
+    assertThat(
+            Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + LabelId.CODE_REVIEW))
         .isTrue();
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
+    assertThat(
+            Permission.canBeOnAllProjects(
+                "refs/heads/*", Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isTrue();
   }
 
@@ -113,11 +122,11 @@
 
   @Test
   public void getLabel() {
-    assertThat(Permission.create(Permission.LABEL + "Code-Review").getLabel())
-        .isEqualTo("Code-Review");
-    assertThat(Permission.create(Permission.LABEL_AS + "Code-Review").getLabel())
-        .isEqualTo("Code-Review");
-    assertThat(Permission.create("Code-Review").getLabel()).isNull();
+    assertThat(Permission.create(Permission.LABEL + LabelId.CODE_REVIEW).getLabel())
+        .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.create(Permission.LABEL_AS + LabelId.CODE_REVIEW).getLabel())
+        .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.create(LabelId.CODE_REVIEW).getLabel()).isNull();
     assertThat(Permission.create(Permission.ABANDON).getLabel()).isNull();
   }
 
diff --git a/javatests/com/google/gerrit/entities/SubmitRecordTest.java b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
index 0e832f4..578bc18 100644
--- a/javatests/com/google/gerrit/entities/SubmitRecordTest.java
+++ b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableList;
 import java.util.ArrayList;
 import java.util.Collection;
 import org.junit.Test;
@@ -67,4 +68,20 @@
 
     assertThat(SubmitRecord.allRecordsOK(submitRecords)).isFalse();
   }
+
+  @Test
+  public void deepCopy() {
+    SubmitRecord record = new SubmitRecord();
+    record.status = SubmitRecord.Status.CLOSED;
+    record.errorMessage = "ouch";
+    record.requirements =
+        ImmutableList.of(
+            LegacySubmitRequirement.builder().setFallbackText("foo").setType("baz").build());
+    SubmitRecord.Label label = new SubmitRecord.Label();
+    label.label = "Code-Review";
+    record.labels = ImmutableList.of(label);
+
+    assertThat(record).isNotSameInstanceAs(record.deepCopy());
+    assertThat(record).isEqualTo(record.deepCopy());
+  }
 }
diff --git a/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
index 0e4fbc8..12045b1 100644
--- a/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/AccountIdProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class AccountIdProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedAccountId).isEqualTo(accountId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Account_Id proto = Entities.Account_Id.newBuilder().setId(24).build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Account_Id> parser = accountIdProtoConverter.getParser();
-    Entities.Account_Id parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
index 0a73db8..7073fa7 100644
--- a/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/BranchNameKeyProtoConverterTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -55,21 +54,6 @@
     assertThat(convertedNameKey).isEqualTo(nameKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Branch_NameKey proto =
-        Entities.Branch_NameKey.newBuilder()
-            .setProject(Entities.Project_NameKey.newBuilder().setName("project 1"))
-            .setBranch("branch 36")
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Branch_NameKey> parser = branchNameKeyProtoConverter.getParser();
-    Entities.Branch_NameKey parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
index 12f3f33..fe71c42 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeIdProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class ChangeIdProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedChangeId).isEqualTo(changeId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Change_Id proto = Entities.Change_Id.newBuilder().setId(94).build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Change_Id> parser = changeIdProtoConverter.getParser();
-    Entities.Change_Id parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
index e9080b3..745c90c 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeKeyProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class ChangeKeyProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedChangeKey).isEqualTo(changeKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Change_Key proto = Entities.Change_Key.newBuilder().setId("change 36").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Change_Key> parser = changeKeyProtoConverter.getParser();
-    Entities.Change_Key parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
index 72ce896..98329d2 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverterTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -55,21 +54,6 @@
     assertThat(convertedMessageKey).isEqualTo(messageKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ChangeMessage_Key proto =
-        Entities.ChangeMessage_Key.newBuilder()
-            .setChangeId(Entities.Change_Id.newBuilder().setId(704))
-            .setUuid("aabbcc")
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ChangeMessage_Key> parser = messageKeyProtoConverter.getParser();
-    Entities.ChangeMessage_Key parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
index 933ffb4..b185558 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -174,23 +173,6 @@
     assertThat(convertedChangeMessage).isEqualTo(changeMessage);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ChangeMessage proto =
-        Entities.ChangeMessage.newBuilder()
-            .setKey(
-                Entities.ChangeMessage_Key.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(543))
-                    .setUuid("change-message-21"))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ChangeMessage> parser = changeMessageProtoConverter.getParser();
-    Entities.ChangeMessage parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index bc669cc..ae8e06d 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -277,40 +276,6 @@
     assertThat(change.getLastUpdatedOn()).isEqualTo(new Timestamp(987654L));
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Change proto =
-        Entities.Change.newBuilder()
-            .setChangeId(Entities.Change_Id.newBuilder().setId(14))
-            .setChangeKey(Entities.Change_Key.newBuilder().setId("change 1"))
-            .setRowVersion(0)
-            .setCreatedOn(987654L)
-            .setLastUpdatedOn(1234567L)
-            .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
-            .setDest(
-                Entities.Branch_NameKey.newBuilder()
-                    .setProject(Entities.Project_NameKey.newBuilder().setName("project 67"))
-                    .setBranch("branch 74"))
-            .setStatus(Change.STATUS_MERGED)
-            .setCurrentPatchSetId(23)
-            .setSubject("subject XYZ")
-            .setTopic("my topic")
-            .setOriginalSubject("original subject ABC")
-            .setSubmissionId("submission ID 234")
-            .setAssignee(Entities.Account_Id.newBuilder().setId(100001))
-            .setIsPrivate(true)
-            .setWorkInProgress(true)
-            .setReviewStarted(true)
-            .setRevertOf(Entities.Change_Id.newBuilder().setId(180))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Change> parser = changeProtoConverter.getParser();
-    Entities.Change parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
index 88b9fb6..6237ac0 100644
--- a/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/LabelIdProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class LabelIdProtoConverterTest {
@@ -48,17 +47,6 @@
     assertThat(convertedLabelId).isEqualTo(labelId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.LabelId proto = Entities.LabelId.newBuilder().setId("label-23").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.LabelId> parser = labelIdProtoConverter.getParser();
-    Entities.LabelId parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
index 8408b69..447c47f 100644
--- a/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ObjectIdProtoConverterTest.java
@@ -20,7 +20,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
@@ -48,18 +47,6 @@
     assertThat(convertedObjectId).isEqualTo(objectId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ObjectId proto =
-        Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ObjectId> parser = objectIdProtoConverter.getParser();
-    Entities.ObjectId parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
index 11aac0d..be55561 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -65,25 +64,6 @@
     assertThat(convertedKey).isEqualTo(key);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSetApproval_Key proto =
-        Entities.PatchSetApproval_Key.newBuilder()
-            .setPatchSetId(
-                Entities.PatchSet_Id.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                    .setId(14))
-            .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-            .setLabelId(Entities.LabelId.newBuilder().setId("label-8"))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSetApproval_Key> parser = protoConverter.getParser();
-    Entities.PatchSetApproval_Key parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
index bca5eea..bf39ff8 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.Date;
@@ -165,29 +164,6 @@
     assertThat(patchSetApproval.postSubmit()).isEqualTo(false);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSetApproval proto =
-        Entities.PatchSetApproval.newBuilder()
-            .setKey(
-                Entities.PatchSetApproval_Key.newBuilder()
-                    .setPatchSetId(
-                        Entities.PatchSet_Id.newBuilder()
-                            .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setId(14))
-                    .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
-            .setValue(456)
-            .setGranted(987654L)
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSetApproval> parser = protoConverter.getParser();
-    Entities.PatchSetApproval parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
index 530b431..c858582 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetIdProtoConverterTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import org.junit.Test;
 
@@ -55,21 +54,6 @@
     assertThat(convertedPatchSetId).isEqualTo(patchSetId);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSet_Id proto =
-        Entities.PatchSet_Id.newBuilder()
-            .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-            .setId(73)
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSet_Id> parser = patchSetIdProtoConverter.getParser();
-    Entities.PatchSet_Id parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void methodsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index 2519e75..efeb24f 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.Optional;
@@ -148,23 +147,6 @@
                 .build());
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSet proto =
-        Entities.PatchSet.newBuilder()
-            .setId(
-                Entities.PatchSet_Id.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-                    .setId(73))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSet> parser = patchSetProtoConverter.getParser();
-    Entities.PatchSet parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
index 2f693e6..2fa89a5 100644
--- a/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverterTest.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import org.junit.Test;
 
 public class ProjectNameKeyProtoConverterTest {
@@ -50,18 +49,6 @@
     assertThat(convertedNameKey).isEqualTo(nameKey);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.Project_NameKey proto =
-        Entities.Project_NameKey.newBuilder().setName("project 36").build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.Project_NameKey> parser = projectNameKeyProtoConverter.getParser();
-    Entities.Project_NameKey parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
new file mode 100644
index 0000000..4352fe8
--- /dev/null
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -0,0 +1,385 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.client.ReviewerState;
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class ChangeInfoDifferTest {
+
+  private static final String REVISION = "abc123";
+
+  @Test
+  public void getDiff_givenEmptyChangeInfos_returnsEmptyDifference() {
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(new ChangeInfo(), new ChangeInfo());
+
+    // Spot check a few fields, including collections and maps.
+    assertThat(diff.added()._number).isNull();
+    assertThat(diff.added().branch).isNull();
+    assertThat(diff.added().project).isNull();
+    assertThat(diff.added().currentRevision).isNull();
+    assertThat(diff.added().actions).isNull();
+    assertThat(diff.added().messages).isNull();
+    assertThat(diff.added().reviewers).isNull();
+    assertThat(diff.added().hashtags).isNull();
+    assertThat(diff.removed()._number).isNull();
+    assertThat(diff.removed().branch).isNull();
+    assertThat(diff.removed().project).isNull();
+    assertThat(diff.removed().currentRevision).isNull();
+    assertThat(diff.removed().actions).isNull();
+    assertThat(diff.removed().messages).isNull();
+    assertThat(diff.removed().reviewers).isNull();
+    assertThat(diff.removed().hashtags).isNull();
+  }
+
+  @Test
+  public void getDiff_givenUnchangedTopic_returnsNullTopics() {
+    ChangeInfo oldChangeInfo = createChangeInfoWithTopic("topic");
+    ChangeInfo newChangeInfo = createChangeInfoWithTopic(oldChangeInfo.topic);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().topic).isNull();
+    assertThat(diff.removed().topic).isNull();
+  }
+
+  @Test
+  public void getDiff_givenChangedTopic_returnsTopics() {
+    ChangeInfo oldChangeInfo = createChangeInfoWithTopic("old-topic");
+    ChangeInfo newChangeInfo = createChangeInfoWithTopic("new-topic");
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().topic).isEqualTo(newChangeInfo.topic);
+    assertThat(diff.removed().topic).isEqualTo(oldChangeInfo.topic);
+  }
+
+  @Test
+  public void getDiff_givenEqualAssignees_returnsNullAssignee() {
+    ChangeInfo oldChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
+    ChangeInfo newChangeInfo =
+        createChangeInfoWithAccount(
+            new AccountInfo(oldChangeInfo.assignee.name, oldChangeInfo.assignee.email));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isNull();
+    assertThat(diff.removed().assignee).isNull();
+  }
+
+  @Test
+  public void getDiff_givenNewAssignee_returnsAssignee() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isEqualTo(newChangeInfo.assignee);
+    assertThat(diff.removed().assignee).isNull();
+  }
+
+  @Test
+  public void getDiff_withRemovedAssignee_returnsAssignee() {
+    ChangeInfo oldChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
+    ChangeInfo newChangeInfo = new ChangeInfo();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isNull();
+    assertThat(diff.removed().assignee).isEqualTo(oldChangeInfo.assignee);
+  }
+
+  @Test
+  public void getDiff_givenAssigneeWithNewName_returnsNameButNotEmail() {
+    ChangeInfo oldChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("old name", "mail@mail.com"));
+    ChangeInfo newChangeInfo =
+        createChangeInfoWithAccount(new AccountInfo("new name", oldChangeInfo.assignee.email));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().assignee).isNotNull();
+    assertThat(diff.added().assignee.name).isEqualTo(newChangeInfo.assignee.name);
+    assertThat(diff.added().assignee.email).isNull();
+    assertThat(diff.removed().assignee).isNotNull();
+    assertThat(diff.removed().assignee.name).isEqualTo(oldChangeInfo.assignee.name);
+    assertThat(diff.removed().assignee.email).isNull();
+  }
+
+  @Test
+  public void getDiff_whenHashtagsChanged_returnsHashtags() {
+    String removedHashtag = "removed";
+    String addedHashtag = "added";
+    ChangeInfo oldChangeInfo = createChangeInfoWithHashtags(removedHashtag, "existing");
+    ChangeInfo newChangeInfo = createChangeInfoWithHashtags("existing", addedHashtag);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().hashtags).isNotNull();
+    assertThat(diff.added().hashtags).containsExactly(addedHashtag);
+    assertThat(diff.removed().hashtags).isNotNull();
+    assertThat(diff.removed().hashtags).containsExactly(removedHashtag);
+  }
+
+  @Test
+  public void getDiff_whenDuplicateHashtagAdded_returnsHashtag() {
+    String hashtag = "hashtag";
+    ChangeInfo oldChangeInfo = createChangeInfoWithHashtags(hashtag, hashtag);
+    ChangeInfo newChangeInfo = createChangeInfoWithHashtags(hashtag, hashtag, hashtag);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().hashtags).isNotNull();
+    assertThat(diff.added().hashtags).containsExactly(hashtag);
+    assertThat(diff.removed().hashtags).isNull();
+  }
+
+  @Test
+  public void getDiff_whenChangeMessageUnchanged_returnsNullMessage() {
+    String message = "message";
+    ChangeInfo oldChangeInfo = new ChangeInfo(new ChangeMessageInfo(message));
+    ChangeInfo newChangeInfo = new ChangeInfo(new ChangeMessageInfo(message));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNull();
+    assertThat(diff.removed().messages).isNull();
+  }
+
+  @Test
+  public void getDiff_whenChangeMessageAdded_returnsAdded() {
+    ChangeMessageInfo addedMessage = new ChangeMessageInfo("added");
+    ChangeMessageInfo existingMessage = new ChangeMessageInfo("existing");
+    ChangeInfo oldChangeInfo = new ChangeInfo(existingMessage);
+    ChangeInfo newChangeInfo = new ChangeInfo(existingMessage, addedMessage);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNotNull();
+    assertThat(diff.added().messages).containsExactly(addedMessage);
+    assertThat(diff.removed().messages).isNull();
+  }
+
+  @Test
+  public void getDiff_whenChangeMessageRemoved_returnsRemoved() {
+    ChangeMessageInfo removedMessage = new ChangeMessageInfo("removed");
+    ChangeMessageInfo existingMessage = new ChangeMessageInfo("existing");
+    ChangeInfo oldChangeInfo = new ChangeInfo(existingMessage, removedMessage);
+    ChangeInfo newChangeInfo = new ChangeInfo(existingMessage);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNull();
+    assertThat(diff.removed().messages).isNotNull();
+    assertThat(diff.removed().messages).containsExactly(removedMessage);
+  }
+
+  @Test
+  public void getDiff_whenDuplicateMessagesAdded_returnsDuplicates() {
+    ChangeMessageInfo message = new ChangeMessageInfo("message");
+    ChangeInfo oldChangeInfo = new ChangeInfo(message, message);
+    ChangeInfo newChangeInfo = new ChangeInfo(message, message, message, message);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().messages).isNotNull();
+    assertThat(diff.added().messages).containsExactly(message, message);
+    assertThat(diff.removed().messages).isNull();
+  }
+
+  @Test
+  public void getDiff_whenNoNewRevisions_returnsNullRevisions() {
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, new RevisionInfo("ref")));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, new RevisionInfo("ref")));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNull();
+    assertThat(diff.removed().revisions).isNull();
+  }
+
+  @Test
+  public void getDiff_whenOneAddedRevision_returnsRevision() {
+    RevisionInfo addedRevision = new RevisionInfo("ref");
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of());
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, addedRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).ref).isEqualTo(addedRevision.ref);
+    assertThat(diff.removed().revisions).isNull();
+  }
+
+  @Test
+  public void getDiff_whenOneModifiedRevision_returnsModificationsToRevision() {
+    RevisionInfo oldRevision = new RevisionInfo("ref", 1);
+    RevisionInfo newRevision = new RevisionInfo(oldRevision.ref, 2);
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).ref).isNull();
+    assertThat(diff.added().revisions.get(REVISION)._number).isEqualTo(newRevision._number);
+    assertThat(diff.removed().revisions).isNotNull();
+    assertThat(diff.removed().revisions).hasSize(1);
+    assertThat(diff.removed().revisions).containsKey(REVISION);
+    assertThat(diff.removed().revisions.get(REVISION).ref).isNull();
+    assertThat(diff.removed().revisions.get(REVISION)._number).isEqualTo(oldRevision._number);
+  }
+
+  @Test
+  public void getDiff_whenOneModifiedRevisionUploader_returnsModificationsToRevisionUploader() {
+    RevisionInfo oldRevision = new RevisionInfo(new AccountInfo("name", "email@mail.com"));
+    RevisionInfo newRevision =
+        new RevisionInfo(
+            new AccountInfo(oldRevision.uploader.name, oldRevision.uploader.email + "2"));
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNotNull();
+    assertThat(diff.added().revisions).hasSize(1);
+    assertThat(diff.added().revisions).containsKey(REVISION);
+    assertThat(diff.added().revisions.get(REVISION).uploader).isNotNull();
+    assertThat(diff.added().revisions.get(REVISION).uploader.name).isNull();
+    assertThat(diff.added().revisions.get(REVISION).uploader.email)
+        .isEqualTo(newRevision.uploader.email);
+    assertThat(diff.removed().revisions).isNotNull();
+    assertThat(diff.removed().revisions).hasSize(1);
+    assertThat(diff.removed().revisions).containsKey(REVISION);
+    assertThat(diff.removed().revisions.get(REVISION).uploader).isNotNull();
+    assertThat(diff.removed().revisions.get(REVISION).uploader.name).isNull();
+    assertThat(diff.removed().revisions.get(REVISION).uploader.email)
+        .isEqualTo(oldRevision.uploader.email);
+  }
+
+  @Test
+  public void getDiff_whenOneUnchangedRevisionUploader_returnsNullRevision() {
+    RevisionInfo oldRevision = new RevisionInfo(new AccountInfo("name", "email@mail.com"));
+    RevisionInfo newRevision = new RevisionInfo(oldRevision.uploader);
+    ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+    ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().revisions).isNull();
+    assertThat(diff.removed().revisions).isNull();
+  }
+
+  @Test
+  public void getDiff_assertCanConstructAllChangeInfoReferences() throws Exception {
+    buildObjectWithFullFields(ChangeInfo.class);
+  }
+
+  @Test
+  public void getDiff_arrayListInMap() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+
+    AccountInfo i1 = new AccountInfo();
+    i1._accountId = 1;
+    AccountInfo i2 = new AccountInfo();
+    i2._accountId = 2;
+
+    ArrayList<AccountInfo> a1 = new ArrayList<>();
+    ArrayList<AccountInfo> a2 = new ArrayList<>();
+
+    a1.add(i1);
+    a2.add(i1);
+    a2.add(i2);
+    oldChangeInfo.reviewers = ImmutableMap.of(ReviewerState.REVIEWER, a1);
+    newChangeInfo.reviewers = ImmutableMap.of(ReviewerState.REVIEWER, a2);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+    assertThat(diff.added().reviewers).hasSize(1);
+    assertThat(diff.added().reviewers).containsKey(ReviewerState.REVIEWER);
+    assertThat(diff.added().reviewers.get(ReviewerState.REVIEWER)).containsExactly(i2);
+    assertThat(diff.removed().reviewers).isNull();
+  }
+
+  private static Object buildObjectWithFullFields(Class<?> c) throws Exception {
+    if (c == null) {
+      return null;
+    }
+    Object toPopulate = ChangeInfoDiffer.construct(c);
+    for (Field field : toPopulate.getClass().getDeclaredFields()) {
+      Class<?> parameterizedType = getParameterizedType(field);
+      if (!ChangeInfoDiffer.isSimple(field.getType())
+          && !field.getType().isArray()
+          && !Map.class.isAssignableFrom(field.getType())
+          && !Collection.class.isAssignableFrom(field.getType())) {
+        field.set(toPopulate, buildObjectWithFullFields(field.getType()));
+      } else if (Collection.class.isAssignableFrom(field.getType())
+          && parameterizedType != null
+          && !ChangeInfoDiffer.isSimple(parameterizedType)) {
+        field.set(toPopulate, ImmutableList.of(buildObjectWithFullFields(parameterizedType)));
+      }
+    }
+    return toPopulate;
+  }
+
+  private static Class<?> getParameterizedType(Field field) {
+    if (!Collection.class.isAssignableFrom(field.getType())) {
+      return null;
+    }
+    Type genericType = field.getGenericType();
+    if (genericType instanceof ParameterizedType) {
+      return (Class<?>) ((ParameterizedType) genericType).getActualTypeArguments()[0];
+    }
+    return null;
+  }
+
+  private static ChangeInfo createChangeInfoWithTopic(String topic) {
+    ChangeInfo changeInfo = new ChangeInfo();
+    changeInfo.topic = topic;
+    return changeInfo;
+  }
+
+  private static ChangeInfo createChangeInfoWithAccount(AccountInfo accountInfo) {
+    ChangeInfo changeInfo = new ChangeInfo();
+    changeInfo.assignee = accountInfo;
+    return changeInfo;
+  }
+
+  private static ChangeInfo createChangeInfoWithHashtags(String... hashtags) {
+    ChangeInfo changeInfo = new ChangeInfo();
+    changeInfo.hashtags = ImmutableList.copyOf(hashtags);
+    return changeInfo;
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index ba9475f..634231f 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -15,19 +15,24 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
-import static java.util.stream.Collectors.joining;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.Accounts;
 import com.google.gerrit.extensions.api.config.Config;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
+import java.util.ArrayList;
+import java.util.List;
 import org.junit.Test;
 
 public class IndexServletTest {
@@ -55,14 +60,19 @@
     String testCdnPath = "bar-cdn";
     String testFaviconURL = "zaz-url";
 
-    String disabledDefault = IndexHtmlUtil.DEFAULT_EXPERIMENTS.asList().get(0);
+    // Pick any known experiment enabled by default;
+    String disabledDefault = ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS;
+    assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).contains(disabledDefault);
+
     org.eclipse.jgit.lib.Config serverConfig = new org.eclipse.jgit.lib.Config();
     serverConfig.setStringList(
         "experiments", null, "enabled", ImmutableList.of("NewFeature", "DisabledFeature"));
     serverConfig.setStringList(
         "experiments", null, "disabled", ImmutableList.of("DisabledFeature", disabledDefault));
+    ExperimentFeatures experimentFeatures = new ConfigExperimentFeatures(serverConfig);
     IndexServlet servlet =
-        new IndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi, serverConfig);
+        new IndexServlet(
+            testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi, experimentFeatures);
 
     FakeHttpServletResponse response = new FakeHttpServletResponse();
 
@@ -85,14 +95,17 @@
                 + "\\x22\\/config\\/server\\/info\\x22: \\x7b\\x22default_theme\\x22:"
                 + "\\x22my-default-theme\\x22\\x7d, \\x22\\/config\\/server\\/top-menus\\x22: "
                 + "\\x5b\\x5d\\x7d');");
-    String enabledDefaults =
-        IndexHtmlUtil.DEFAULT_EXPERIMENTS.stream()
+    ImmutableSet<String> enabledDefaults =
+        ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.stream()
             .filter(e -> !e.equals(disabledDefault))
-            .collect(joining("\\x22,"));
+            .collect(ImmutableSet.toImmutableSet());
+    List<String> expectedEnabled = new ArrayList<>();
+    expectedEnabled.add("NewFeature");
+    expectedEnabled.addAll(enabledDefaults);
     assertThat(output)
         .contains(
-            "window.ENABLED_EXPERIMENTS = JSON.parse('\\x5b\\x22NewFeature\\x22,\\x22"
-                + enabledDefaults
+            "window.ENABLED_EXPERIMENTS = JSON.parse('\\x5b\\x22"
+                + String.join("\\x22,", expectedEnabled)
                 + "\\x22\\x5d');</script>");
   }
 }
diff --git a/javatests/com/google/gerrit/integration/git/BUILD b/javatests/com/google/gerrit/integration/git/BUILD
index 28755af..86b3f36 100644
--- a/javatests/com/google/gerrit/integration/git/BUILD
+++ b/javatests/com/google/gerrit/integration/git/BUILD
@@ -1,13 +1,32 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
+# TODO(davido): This was only needed as own rule, to provide a dedicated
+# tag to skip Git version v2 protocol tests. That was particularly
+# needed for RBE, because this test assumes that git client version is
+# at least 2.17.1. Once Bazel docker image for Ubuntu 20.04 is available
+# and we removed our own RBE docker image, we can merge this rule with
+# the other rules in this package.
 acceptance_tests(
     srcs = ["GitProtocolV2IT.java"],
     group = "protocol-v2",
     labels = ["git-protocol-v2"],
 )
 
+# This rule can be also merged with the other tests in this package.
 acceptance_tests(
     srcs = ["UploadArchiveIT.java"],
     group = "upload-archive",
     labels = ["git-upload-archive"],
 )
+
+acceptance_tests(
+    srcs = glob(
+        ["*.java"],
+        exclude = [
+            "GitProtocolV2IT.java",
+            "UploadArchiveIT.java",
+        ],
+    ),
+    group = "git_tests",
+    labels = ["git"],
+)
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index 3ffd61e..892cabe 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -42,7 +42,6 @@
 import java.io.File;
 import java.net.InetSocketAddress;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
@@ -91,15 +90,13 @@
       projectOperations
           .project(project)
           .forUpdate()
-          .add(deny(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+          .add(deny(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.ANONYMOUS_USERS))
           .add(
               allow(Permission.READ)
                   .ref("refs/heads/master")
                   .group(SystemGroupBackend.REGISTERED_USERS))
           .update();
 
-      setProtocolV2(project);
-
       // Retrieve HTTP url
       String url = config.getString("gerrit", null, "canonicalweburl");
       String urlDestinationTemplate =
@@ -214,8 +211,6 @@
       Project.NameKey allRefsVisibleProject = Project.nameKey("all-refs-visible");
       gApi.projects().create(allRefsVisibleProject.get());
 
-      setProtocolV2(allRefsVisibleProject);
-
       // Set up project permission to allow reading all refs
       projectOperations
           .project(allRefsVisibleProject)
@@ -270,8 +265,6 @@
       Project.NameKey privateProject = Project.nameKey("private-project");
       gApi.projects().create(privateProject.get());
 
-      setProtocolV2(privateProject);
-
       // Disallow general read permissions for anonymous users
       projectOperations
           .project(allProjectsName)
@@ -339,12 +332,6 @@
                 UTF_8));
   }
 
-  private void setProtocolV2(Project.NameKey projectName) throws Exception {
-    execute(
-        ImmutableList.of("git", "config", "protocol.version", "2"),
-        sitePaths.site_path.resolve("git").resolve(projectName.get() + Constants.DOT_GIT).toFile());
-  }
-
   private static void assertGitProtocolV2Refs(String commit, String out) {
     assertThat(out).containsMatch("(git|ls-remote)< version 2");
     assertThat(out).contains("refs/changes/01/1/1");
diff --git a/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
new file mode 100644
index 0000000..c33ef8e
--- /dev/null
+++ b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.integration.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AccountCreator;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class NoAccessSameAsNotFoundIT extends StandaloneSiteTest {
+  private static final String PASSWORD = "secret";
+  private static final String REPO = "foo";
+
+  @Inject private @GerritServerConfig Config config;
+  @Inject private AccountCreator accountCreator;
+  @Inject private GerritApi gApi;
+  @Inject private ProjectOperations projectOperations;
+
+  private String repoUrl;
+
+  @Test
+  public void sameResponseForNonExistingAndNonAccessibleRepo() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      setup(ctx);
+
+      // clone non-existing REPO
+      String nonExistingResponse = cloneRepo();
+
+      // create REPO and make it non-accessible then clone it again
+      createNonAccessibleRepo();
+      String noAccessResponse = cloneRepo();
+
+      // make sure the response is identical in both cases
+      assertThat(noAccessResponse).isEqualTo(nonExistingResponse);
+    }
+  }
+
+  private void setup(ServerContext ctx) throws Exception {
+    ctx.getInjector().injectMembers(this);
+
+    TestAccount user = accountCreator.user();
+    gApi.accounts().id(user.username()).setHttpPassword(PASSWORD);
+
+    String canonical = config.getString("gerrit", null, "canonicalweburl");
+    repoUrl =
+        String.format(
+            "http://%s:%s@%s/a/%s", user.username(), PASSWORD, canonical.substring(7), REPO);
+  }
+
+  private String cloneRepo() {
+    try {
+      return execute(ImmutableList.of("git", "clone", repoUrl));
+    } catch (Exception e) {
+      return e.getMessage();
+    }
+  }
+
+  private void createNonAccessibleRepo() throws RestApiException {
+    // Create project
+    Project.NameKey project = Project.nameKey(REPO);
+    gApi.projects().create(project.get());
+
+    // Block access for everyone
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+        .update();
+  }
+
+  private String execute(ImmutableList<String> cmd) throws Exception {
+    return execute(cmd, sitePaths.data_dir.toFile(), ImmutableMap.of());
+  }
+}
diff --git a/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java b/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java
new file mode 100644
index 0000000..c42f00d
--- /dev/null
+++ b/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.integration.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+// TODO(davido): In addition to push over HTTP also add a test for push over SSH
+public class PushToRefsUsersIT extends StandaloneSiteTest {
+  private static final String ADMIN_PASSWORD = "secret";
+  private final String[] GIT_CLONE = new String[] {"git", "clone"};
+  private final String[] GIT_FETCH_USERS_SELF =
+      new String[] {"git", "fetch", "origin", "refs/users/self"};
+  private final String[] GIT_CO_FETCH_HEAD = new String[] {"git", "checkout", "FETCH_HEAD"};
+  private final String[] GIT_CONFIG_USER_EMAIL =
+      new String[] {"git", "config", "user.email", "admin@example.com"};
+  private final String[] GIT_CONFIG_USER_NAME =
+      new String[] {"git", "config", "user.name", "Administrator"};
+  private final String[] GIT_COMMIT = new String[] {"git", "commit", "-am", "OOO"};
+  private final String[] GIT_PUSH_USERS_SELF =
+      new String[] {"git", "push", "origin", "HEAD:refs/users/self"};
+
+  @Inject private GerritApi gApi;
+  @Inject private @GerritServerConfig Config config;
+  @Inject private AllUsersName allUsersName;
+
+  @Test
+  public void testPushToRefsUsersOverHttp() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Setup admin password
+      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+
+      // Get authenticated Git/HTTP URL
+      String urlWithCredentials =
+          config
+              .getString("gerrit", null, "canonicalweburl")
+              .replace("http://", "http://" + admin.username() + ":" + ADMIN_PASSWORD + "@");
+
+      // Clone All-Users repository
+      execute(
+          ImmutableList.<String>builder()
+              .add(GIT_CLONE)
+              .add(urlWithCredentials + "/a/" + allUsersName)
+              .add(sitePaths.data_dir.toFile().getAbsolutePath())
+              .build(),
+          sitePaths.site_path);
+
+      // Fetch refs/users/self for admin user
+      execute(GIT_FETCH_USERS_SELF);
+
+      // Checkout FETCH_HEAD
+      execute(GIT_CO_FETCH_HEAD);
+
+      // Set admin user status to OOO
+      Files.write(
+          sitePaths.data_dir.resolve("account.config"),
+          "  status = OOO".getBytes(UTF_8),
+          StandardOpenOption.APPEND);
+
+      // Set user email
+      execute(GIT_CONFIG_USER_EMAIL);
+
+      // Set user name
+      execute(GIT_CONFIG_USER_NAME);
+
+      // Commit
+      execute(GIT_COMMIT);
+
+      // Push
+      assertThat(execute(GIT_PUSH_USERS_SELF)).contains("Processing changes: refs: 1, done");
+
+      // Verify user status
+      assertThat(gApi.accounts().id(admin.id().get()).detail().status).isEqualTo("OOO");
+    }
+  }
+
+  private String execute(String... cmds) throws Exception {
+    return execute(ImmutableList.<String>builder().add(cmds).build(), sitePaths.data_dir);
+  }
+
+  private String execute(ImmutableList<String> cmd, Path path) throws Exception {
+    return execute(cmd, path.toFile(), ImmutableMap.of());
+  }
+}
diff --git a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
index 66b02ff..c720905 100644
--- a/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
+++ b/javatests/com/google/gerrit/integration/git/UploadArchiveIT.java
@@ -168,7 +168,11 @@
         .add("archive")
         .add("-f=" + format)
         .add("--prefix=" + commit + "/")
+        // --remote makes git execute "git archive" on the server through SSH.
+        // The Gerrit/JGit version of the command understands the --compression-level
+        // argument below.
         .add("--remote=" + sshDestination)
+        .add("--compression-level=1") // set to 1 to reduce the memory footprint
         .add(commit)
         .add(FILE_NAME)
         .build();
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 248c7d1..7ab7ae9 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -49,6 +49,7 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/account/externalids/testing",
@@ -73,6 +74,7 @@
         "//lib:jgit",
         "//lib:jgit-junit",
         "//lib:protobuf",
+        "//lib:soy",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
@@ -82,5 +84,6 @@
         "//lib/truth:truth-java8-extension",
         "//lib/truth:truth-proto-extension",
         "//proto:cache_java_proto",
+        "//proto:entities_java_proto",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 1672ce1..463af35 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.EnableReverseDnsLookup;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -80,8 +80,8 @@
           @Override
           protected void configure() {
             bind(Boolean.class)
-                .annotatedWith(EnableReverseDnsLookup.class)
-                .toInstance(Boolean.TRUE);
+                .annotatedWith(EnablePeerIPInReflogRecord.class)
+                .toInstance(Boolean.FALSE);
             bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(config);
             bind(String.class)
                 .annotatedWith(AnonymousCowardName.class)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
new file mode 100644
index 0000000..8fe7662
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
@@ -0,0 +1,42 @@
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.comment.CommentContextCacheImpl.CommentContextSerializer.INSTANCE;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.comment.CommentContextKey;
+import org.junit.Test;
+
+public class CommentContextSerializerTest {
+  @Test
+  public void roundTripValue() {
+    CommentContext commentContext =
+        CommentContext.create(ImmutableMap.of(1, "line_1", 2, "line_2"), "text/x-java");
+
+    byte[] serialized = INSTANCE.serialize(commentContext);
+    CommentContext deserialized = INSTANCE.deserialize(serialized);
+
+    assertThat(commentContext).isEqualTo(deserialized);
+  }
+
+  @Test
+  public void roundTripKey() {
+    Project.NameKey proj = Project.NameKey.parse("project");
+    Change.Id changeId = Change.Id.tryParse("1234").get();
+
+    CommentContextKey k =
+        CommentContextKey.builder()
+            .project(proj)
+            .changeId(changeId)
+            .id("commentId")
+            .path("pathHash")
+            .patchset(1)
+            .contextPadding(3)
+            .build();
+    byte[] serialized = CommentContextKey.Serializer.INSTANCE.serialize(k);
+    assertThat(k).isEqualTo(CommentContextKey.Serializer.INSTANCE.deserialize(serialized));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
index 7fe73d5..8df9292 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -4,13 +4,11 @@
     name = "tests",
     srcs = glob(["*.java"]),
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/serialize/entities",
-        "//java/com/google/gerrit/server/cache/testing",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java
new file mode 100644
index 0000000..ecab07d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.patch.filediff.FileDiffCacheKey;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+/** Tests the protobuf serializer for the {@link FileDiffCacheKey}. */
+public class FileDiffCacheKeySerializerTest {
+  private static final ObjectId COMMIT_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId COMMIT_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    FileDiffCacheKey key =
+        FileDiffCacheKey.builder()
+            .project(Project.nameKey("project/x"))
+            .oldCommit(COMMIT_ID_1)
+            .newCommit(COMMIT_ID_2)
+            .newFilePath("some_file.txt")
+            .renameScore(65)
+            .diffAlgorithm(DiffAlgorithm.HISTOGRAM)
+            .whitespace(Whitespace.IGNORE_ALL)
+            .build();
+
+    byte[] serialized = FileDiffCacheKey.Serializer.INSTANCE.serialize(key);
+
+    assertThat(FileDiffCacheKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
new file mode 100644
index 0000000..17fd959
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class FileDiffOutputSerializerTest {
+  @Test
+  public void roundTrip() {
+    ImmutableList<TaggedEdit> edits =
+        ImmutableList.of(
+            TaggedEdit.create(Edit.create(1, 5, 3, 4), true),
+            TaggedEdit.create(Edit.create(21, 30, 150, 158), false));
+
+    FileDiffOutput fileDiff =
+        FileDiffOutput.builder()
+            .oldCommitId(ObjectId.fromString("dd4d2a1498870ca5fe415b33f65d052d69d9eaf5"))
+            .newCommitId(ObjectId.fromString("0cfaab3f2ba76f71798da0a2651f41be8d45f842"))
+            .comparisonType(ComparisonType.againstOtherPatchSet())
+            .oldPath(Optional.of("old_file_path.txt"))
+            .newPath(Optional.empty())
+            .changeType(ChangeType.DELETED)
+            .patchType(Optional.of(PatchType.UNIFIED))
+            .size(23)
+            .sizeDelta(10)
+            .headerLines(ImmutableList.of("header line 1", "header line 2"))
+            .edits(edits)
+            .build();
+
+    byte[] serialized = FileDiffOutput.Serializer.INSTANCE.serialize(fileDiff);
+    assertThat(FileDiffOutput.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(fileDiff);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
new file mode 100644
index 0000000..12d8d00
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
@@ -0,0 +1,49 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheKey;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class GitFileDiffKeySerializerTest {
+  private static final ObjectId TREE_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId TREE_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    GitFileDiffCacheKey key =
+        GitFileDiffCacheKey.builder()
+            .project(Project.nameKey("project/x"))
+            .oldTree(TREE_ID_1)
+            .newTree(TREE_ID_2)
+            .newFilePath("some_file.txt")
+            .renameScore(65)
+            .diffAlgorithm(DiffAlgorithm.HISTOGRAM)
+            .whitespace(Whitespace.IGNORE_ALL)
+            .build();
+
+    byte[] serialized = GitFileDiffCacheKey.Serializer.INSTANCE.serialize(key);
+
+    assertThat(GitFileDiffCacheKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
new file mode 100644
index 0000000..93441a4
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.FileMode;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.server.patch.filediff.Edit;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff;
+import com.google.gerrit.server.patch.gitfilediff.GitFileDiff.Serializer;
+import java.util.Optional;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class GitFileDiffSerializerTest {
+  private static final ObjectId OLD_ID =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId NEW_ID =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    ImmutableList<Edit> edits =
+        ImmutableList.of(Edit.create(1, 5, 3, 4), Edit.create(21, 30, 150, 158));
+
+    GitFileDiff gitFileDiff =
+        GitFileDiff.builder()
+            .edits(edits)
+            .fileHeader("file_header")
+            .oldPath(Optional.of("old_file_path.txt"))
+            .newPath(Optional.empty())
+            .oldId(AbbreviatedObjectId.fromObjectId(OLD_ID))
+            .newId(AbbreviatedObjectId.fromObjectId(NEW_ID))
+            .changeType(ChangeType.DELETED)
+            .patchType(Optional.of(PatchType.UNIFIED))
+            .oldMode(Optional.of(FileMode.REGULAR_FILE))
+            .newMode(Optional.of(FileMode.REGULAR_FILE))
+            .build();
+
+    byte[] serialized = Serializer.INSTANCE.serialize(gitFileDiff);
+    assertThat(Serializer.INSTANCE.deserialize(serialized)).isEqualTo(gitFileDiff);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
new file mode 100644
index 0000000..caf1fbb
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
@@ -0,0 +1,43 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey.Serializer;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class GitModifiedFilesCacheKeySerializerTest {
+  private static final ObjectId TREE_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId TREE_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    GitModifiedFilesCacheKey key =
+        GitModifiedFilesCacheKey.builder()
+            .project(Project.NameKey.parse("Project/X"))
+            .aTree(TREE_ID_1)
+            .bTree(TREE_ID_2)
+            .renameScore(65)
+            .build();
+    byte[] serialized = Serializer.INSTANCE.serialize(key);
+    assertThat(Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
new file mode 100644
index 0000000..8d301e4
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.InternalGroupSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.InternalGroupSerializer.serialize;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class InternalGroupSerializerTest {
+  static final InternalGroup MINIMAL_VALUES_SET =
+      InternalGroup.builder()
+          .setId(AccountGroup.id(123456))
+          .setNameKey(AccountGroup.nameKey("group name"))
+          .setOwnerGroupUUID(AccountGroup.uuid("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
+          .setVisibleToAll(false)
+          .setGroupUUID(AccountGroup.uuid("deadbeefdeadbeefdeadbeefdeadbeef12345678"))
+          .setCreatedOn(TimeUtil.nowTs())
+          .setMembers(ImmutableSet.of(Account.id(123), Account.id(321)))
+          .setSubgroups(
+              ImmutableSet.of(
+                  AccountGroup.uuid("87654321deadbeefdeadbeefdeadbeefdeadbeef"),
+                  AccountGroup.uuid("deadbeefdeadbeefdeadbeefdeadbeef87654321")))
+          .build();
+
+  static final InternalGroup ALL_VALUES_SET =
+      MINIMAL_VALUES_SET
+          .toBuilder()
+          .setDescription("description")
+          .setRefState(ObjectId.fromString("12345678deadbeefdeadbeefdeadbeefdeadbeef"))
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    assertThat(deserialize(serialize(MINIMAL_VALUES_SET))).isEqualTo(MINIMAL_VALUES_SET);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
index a82fdb9..ad460cd 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -38,6 +38,8 @@
           .setCopyAnyScore(!LabelType.DEF_COPY_ANY_SCORE)
           .setCopyMaxScore(!LabelType.DEF_COPY_MAX_SCORE)
           .setCopyMinScore(!LabelType.DEF_COPY_MIN_SCORE)
+          .setCopyAllScoresIfListOfFilesDidNotChange(
+              !LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
           .setCopyAllScoresOnMergeFirstParentUpdate(
               !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
           .setCopyAllScoresOnTrivialRebase(!LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
new file mode 100644
index 0000000..b39ba57
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
@@ -0,0 +1,42 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ModifiedFilesCacheKeySerializerTest {
+  private static final ObjectId COMMIT_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId COMMIT_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    ModifiedFilesCacheKey key =
+        ModifiedFilesCacheKey.builder()
+            .project(Project.NameKey.parse("Project/X"))
+            .aCommit(COMMIT_ID_1)
+            .bCommit(COMMIT_ID_2)
+            .renameScore(65)
+            .build();
+    byte[] serialized = ModifiedFilesCacheKey.Serializer.INSTANCE.serialize(key);
+    assertThat(ModifiedFilesCacheKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
new file mode 100644
index 0000000..bff0c5d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
@@ -0,0 +1,56 @@
+//  Copyright (C) 2020 The Android Open Source Project
+//
+//  Licensed under the Apache License, Version 2.0 (the "License");
+//  you may not use this file except in compliance with the License.
+//  You may obtain a copy of the License at
+//
+//  http://www.apache.org/licenses/LICENSE-2.0
+//
+//  Unless required by applicable law or agreed to in writing, software
+//  distributed under the License is distributed on an "AS IS" BASIS,
+//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//  See the License for the specific language governing permissions and
+//  limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl.ValueSerializer;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import java.util.Optional;
+import org.junit.Test;
+
+public class ModifiedFilesSerializerTest {
+  @Test
+  public void roundTrip() {
+    ImmutableList.Builder<ModifiedFile> builder = ImmutableList.builder();
+
+    builder.add(
+        ModifiedFile.builder()
+            .changeType(ChangeType.DELETED)
+            .oldPath(Optional.of("file_1.txt"))
+            .newPath(Optional.of("file_2.txt"))
+            .build());
+    builder.add(
+        ModifiedFile.builder()
+            .changeType(ChangeType.ADDED)
+            .oldPath(Optional.empty())
+            .newPath(Optional.of("file_3.txt"))
+            .build());
+
+    // Note: the default value for strings in protocol buffers is the empty string, hence the
+    // serializer will not be able to differentiate between an empty optional and an optional
+    // with an empty string, i.e. if we serialize an optional with an empty string, the deserialized
+    // object will be an empty optional. That should not be problematic in this case because file
+    // paths cannot be empty anyway.
+
+    ImmutableList<ModifiedFile> modifiedFiles = builder.build();
+
+    byte[] serialized = ValueSerializer.INSTANCE.serialize(modifiedFiles);
+
+    assertThat(ValueSerializer.INSTANCE.deserialize(serialized)).isEqualTo(modifiedFiles);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index e5f3e23..82991b6 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -109,7 +109,8 @@
           });
     }
     LabelType lt =
-        label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
+        label(
+            LabelId.VERIFIED, value(1, LabelId.VERIFIED), value(0, "No score"), value(-1, "Fails"));
     pc.upsertLabelType(lt);
     save(pc);
   }
@@ -137,12 +138,16 @@
   public void noNormalizeByPermission() throws Exception {
     projectOperations
         .allProjectsForUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
-        .add(allowLabel("Verified").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(allowLabel(LabelId.VERIFIED).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
         .update();
 
-    PatchSetApproval cr = psa(userId, "Code-Review", 2);
-    PatchSetApproval v = psa(userId, "Verified", 1);
+    PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 2);
+    PatchSetApproval v = psa(userId, LabelId.VERIFIED, 1);
     assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
@@ -150,12 +155,16 @@
   public void normalizeByType() throws Exception {
     projectOperations
         .allProjectsForUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
-        .add(allowLabel("Verified").ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-5, 5))
+        .add(allowLabel(LabelId.VERIFIED).ref("refs/heads/*").group(REGISTERED_USERS).range(-5, 5))
         .update();
 
-    PatchSetApproval cr = psa(userId, "Code-Review", 5);
-    PatchSetApproval v = psa(userId, "Verified", 5);
+    PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 5);
+    PatchSetApproval v = psa(userId, LabelId.VERIFIED, 5);
     assertEquals(
         Result.create(list(), list(copy(cr, 2), copy(v, 1)), list()),
         norm.normalize(notes, list(cr, v)));
@@ -163,8 +172,8 @@
 
   @Test
   public void emptyPermissionRangeKeepsResult() throws Exception {
-    PatchSetApproval cr = psa(userId, "Code-Review", 1);
-    PatchSetApproval v = psa(userId, "Verified", 1);
+    PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 1);
+    PatchSetApproval v = psa(userId, LabelId.VERIFIED, 1);
     assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
@@ -172,11 +181,15 @@
   public void explicitZeroVoteOnNonEmptyRangeIsPresent() throws Exception {
     projectOperations
         .allProjectsForUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-1, 1))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
         .update();
 
-    PatchSetApproval cr = psa(userId, "Code-Review", 0);
-    PatchSetApproval v = psa(userId, "Verified", 0);
+    PatchSetApproval cr = psa(userId, LabelId.CODE_REVIEW, 0);
+    PatchSetApproval v = psa(userId, LabelId.VERIFIED, 0);
     assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index 6163ea7..8e4f436 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -52,12 +52,45 @@
 
   private final Gson gson = new EventGsonProvider().get();
 
+  static class CustomEvent extends Event {
+    static String TYPE = "custom-type";
+
+    public String customField;
+
+    protected CustomEvent() {
+      super(TYPE);
+    }
+  }
+
   @Before
   public void setTimeForTesting() {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
   }
 
   @Test
+  public void customEvent() {
+    CustomEvent event = new CustomEvent();
+    event.customField = "customValue";
+    String json = gson.toJson(event);
+    CustomEvent resullt = gson.fromJson(json, CustomEvent.class);
+    assertThat(resullt.type).isEqualTo(CustomEvent.TYPE);
+    assertThat(resullt.customField).isEqualTo(event.customField);
+  }
+
+  @Test
+  public void customEventSimulateClassloaderIssue() {
+    EventTypes.register(CustomEvent.TYPE, CustomEvent.class);
+    CustomEvent event = new CustomEvent();
+    event.customField = "customValue";
+    // Need to serialise using the Event interface instead of json.getClass()
+    // for simulating the serialisation of an object owned by another class loader
+    String json = gson.toJson(event, Event.class);
+    CustomEvent resullt = gson.fromJson(json, CustomEvent.class);
+    assertThat(resullt.type).isEqualTo(CustomEvent.TYPE);
+    assertThat(resullt.customField).isEqualTo(event.customField);
+  }
+
+  @Test
   public void refUpdatedEvent() {
     RefUpdatedEvent event = new RefUpdatedEvent();
 
diff --git a/javatests/com/google/gerrit/server/git/delegate/DelegateRepositoryTest.java b/javatests/com/google/gerrit/server/git/delegate/DelegateRepositoryTest.java
new file mode 100644
index 0000000..74e0fac
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/delegate/DelegateRepositoryTest.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.delegate;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.DelegateRepository;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class DelegateRepositoryTest {
+
+  @Test
+  public void shouldDelegateRepositoryFromAnyPackage() throws IOException {
+    Repository foo = new InMemoryRepositoryManager().createRepository(Project.nameKey("foo"));
+    try (TestDelegateRepository delegateRepository = new TestDelegateRepository(foo)) {
+      assertThat(delegateRepository.delegate()).isSameInstanceAs(foo);
+    }
+  }
+
+  static class TestDelegateRepository extends DelegateRepository {
+    protected TestDelegateRepository(Repository delegate) {
+      super(delegate);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
index 698acd8..7eb6bc7 100644
--- a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
+++ b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 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;
@@ -60,17 +61,18 @@
   }
 
   @Test
-  public void noCache_tipsFromObjectIdDelegatesToRefDbAndFiltersByPrefix() throws Exception {
+  public void noCache_tipsFromObjectIdDelegatesToRefDb() throws Exception {
     Ref refBla = newRef("refs/bla", "badc0feebadc0feebadc0feebadc0feebadc0fee");
-    Ref refheads = newRef(RefNames.REFS_HEADS, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    String patchSetRef = RefNames.REFS_CHANGES + "01/1/1";
+    Ref patchSet = newRef(patchSetRef, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
 
     RefDatabase mockRefDb = mock(RefDatabase.class);
     ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
     when(mockRefDb.getTipsWithSha1(ObjectId.zeroId()))
-        .thenReturn(ImmutableSet.of(refBla, refheads));
+        .thenReturn(ImmutableSet.of(refBla, patchSet));
 
-    assertThat(cache.tipsFromObjectId(ObjectId.zeroId(), RefNames.REFS_HEADS))
-        .containsExactly(refheads);
+    assertThat(cache.patchSetIdsFromObjectId(ObjectId.zeroId()))
+        .containsExactly(PatchSet.Id.fromRef(patchSetRef));
     verify(mockRefDb).getTipsWithSha1(ObjectId.zeroId());
     verifyNoMoreInteractions(mockRefDb);
   }
@@ -107,25 +109,14 @@
   }
 
   @Test
-  public void advertisedRefs_tipsFromObjectIdWithNoPrefix() throws Exception {
+  public void advertisedRefs_patchSetIdsFromObjectId() throws Exception {
     Map<String, Ref> refs = setupTwoChanges();
     ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
 
     assertThat(
-            cache.tipsFromObjectId(
-                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), null))
-        .containsExactly(refs.get("refs/changes/01/1/1"));
-  }
-
-  @Test
-  public void advertisedRefs_tipsFromObjectIdWithPrefix() throws Exception {
-    Map<String, Ref> refs = setupTwoChanges();
-    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
-
-    assertThat(
-            cache.tipsFromObjectId(
-                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), "/refs/some"))
-        .isEmpty();
+            cache.patchSetIdsFromObjectId(
+                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee")))
+        .containsExactly(PatchSet.Id.fromRef("refs/changes/01/1/1"));
   }
 
   private static Ref newRef(String name, String sha1) {
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index 20fe387..e24d481 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -20,13 +20,13 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index bf2ade9..7da4785 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroupByIdAudit;
 import com.google.gerrit.entities.AccountGroupMemberAudit;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.account.GroupUuid;
-import com.google.gerrit.server.group.InternalGroup;
 import java.sql.Timestamp;
 import java.util.Set;
 import org.eclipse.jgit.lib.PersonIdent;
diff --git a/javatests/com/google/gerrit/server/group/db/BUILD b/javatests/com/google/gerrit/server/group/db/BUILD
index 3303338..9f9f459 100644
--- a/javatests/com/google/gerrit/server/group/db/BUILD
+++ b/javatests/com/google/gerrit/server/group/db/BUILD
@@ -6,7 +6,6 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/common/data/testing:common-data-test-util",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index c1f3615..4b5d6b2 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -19,8 +19,6 @@
 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 static org.hamcrest.CoreMatchers.instanceOf;
-import static org.hamcrest.MatcherAssert.assertThat;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
@@ -28,11 +26,11 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.testing.InternalGroupSubject;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.truth.OptionalSubject;
@@ -114,8 +112,9 @@
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("Name of the group " + groupUuid);
     }
   }
@@ -136,8 +135,9 @@
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("ID of the group " + groupUuid);
     }
   }
@@ -215,8 +215,9 @@
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
     }
   }
@@ -524,8 +525,9 @@
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("Name of the group " + groupUuid);
     }
   }
@@ -591,8 +593,9 @@
     groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
-      Throwable thrown = assertThrows(Throwable.class, () -> groupConfig.commit(metaDataUpdate));
-      assertThat(thrown.getCause(), instanceOf(ConfigInvalidException.class));
+      IOException thrown =
+          assertThrows(IOException.class, () -> groupConfig.commit(metaDataUpdate));
+      assertThat(thrown.getCause()).isInstanceOf(ConfigInvalidException.class);
       assertThat(thrown).hasMessageThat().contains("Owner UUID of the group " + groupUuid);
     }
   }
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 40a6978..a7b25b8 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -24,8 +24,8 @@
 import com.google.common.collect.Table;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -100,8 +100,8 @@
             label(SubmitRecord.Label.Status.MAY, "Label-1", null),
             label(SubmitRecord.Label.Status.OK, "Label-2", 1));
 
-    SubmitRequirement sr =
-        SubmitRequirement.builder()
+    LegacySubmitRequirement sr =
+        LegacySubmitRequirement.builder()
             .setType("short_type")
             .setFallbackText("Fallback text may contain special symbols like < > \\ / ; :")
             .build();
@@ -119,8 +119,11 @@
             label(SubmitRecord.Label.Status.OK, "Label-2", 1));
 
     // Doesn't have any custom data value
-    SubmitRequirement sr =
-        SubmitRequirement.builder().setFallbackText("short_type").setType("ci_status").build();
+    LegacySubmitRequirement sr =
+        LegacySubmitRequirement.builder()
+            .setFallbackText("short_type")
+            .setType("ci_status")
+            .build();
     r.requirements = Collections.singletonList(sr);
 
     assertStoredRecordRoundTrip(r);
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index f5d3bf7..e5b2ffb 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -107,6 +107,32 @@
   }
 
   @Test
+  public void threeLevelTreeWithMultipleSources() throws Exception {
+    Predicate<ChangeData> in = parse("-status:abandoned (foo:a OR file:b)");
+    Predicate<ChangeData> out = rewrite(in);
+    assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
+
+    Predicate<ChangeData> firstIndexedSubQuery = parse("-status:abandoned");
+
+    assertThat(out.getChild(0)).isEqualTo(query(firstIndexedSubQuery));
+
+    assertThat(out.getChild(1).getClass()).isSameInstanceAs(OrSource.class);
+    OrSource indexedSubTree = (OrSource) out.getChild(1);
+
+    Predicate<ChangeData> secondIndexedSubQuery = parse("foo:a OR file:b");
+    assertThat(indexedSubTree.getChildren())
+        .containsExactly(
+            query(secondIndexedSubQuery.getChild(1)), secondIndexedSubQuery.getChild(0))
+        .inOrder();
+
+    // Same at the assertions above, that were added for readability
+    assertThat(out.getChild(0)).isEqualTo(query(in.getChild(0)));
+    assertThat(indexedSubTree.getChildren())
+        .containsExactly(query(in.getChild(1).getChild(1)), in.getChild(1).getChild(0))
+        .inOrder();
+  }
+
+  @Test
   public void threeLevelTreeWithSomeIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("-foo:a (file:b OR file:c)");
     Predicate<ChangeData> out = rewrite(in);
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 733d784..8d019f3 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -24,8 +24,8 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import org.eclipse.jgit.lib.Config;
@@ -76,7 +76,7 @@
       // Create a performance log record.
       TraceContext.newTimer("test").close();
 
-      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
@@ -90,7 +90,7 @@
               () -> {
                 // Verify that the tags and force logging flag have been propagated to the new
                 // thread.
-                SortedMap<String, SortedSet<Object>> threadTagMap =
+                Map<String, ? extends Set<Object>> threadTagMap =
                     LoggingContext.getInstance().getTags().asMap();
                 expect.that(threadTagMap.keySet()).containsExactly("foo");
                 expect.that(threadTagMap.get("foo")).containsExactly("bar");
diff --git a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
index f6f3b46..200c49d 100644
--- a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -21,8 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -157,7 +156,7 @@
   }
 
   private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
-    SortedMap<String, SortedSet<Object>> actualTagMap = tags.getTags().asMap();
+    Map<String, ? extends Set<Object>> actualTagMap = tags.getTags().asMap();
     assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
     for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
       assertThat(actualTagMap.get(expectedEntry.getKey()))
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
index 13f2035..6a3632d 100644
--- a/javatests/com/google/gerrit/server/logging/TraceContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -21,8 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.logging.TraceContext.TraceIdConsumer;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.junit.After;
 import org.junit.Test;
 
@@ -254,7 +253,7 @@
   }
 
   private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
-    SortedMap<String, SortedSet<Object>> actualTagMap =
+    Map<String, ? extends Set<Object>> actualTagMap =
         LoggingContext.getInstance().getTags().asMap();
     assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
     for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java
new file mode 100644
index 0000000..2ec5e4d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.template.soy.shared.SoyAstCache;
+import java.nio.file.Paths;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MailSoySauceProviderTest {
+
+  private SitePaths sitePaths;
+  private DynamicSet<MailSoyTemplateProvider> set;
+
+  @Before
+  public void setUp() throws Exception {
+    sitePaths = new SitePaths(Paths.get("."));
+    set = new DynamicSet<>();
+  }
+
+  @Test
+  public void soyCompilation() {
+    MailSoySauceProvider provider =
+        new MailSoySauceProvider(
+            sitePaths,
+            new SoyAstCache(),
+            new PluginSetContext<>(set, PluginMetrics.DISABLED_INSTANCE));
+    assertThat(provider.get()).isNotNull(); // should not throw
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 83c6542..a5cb456 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -45,7 +45,7 @@
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.DefaultUrlFormatter;
-import com.google.gerrit.server.config.EnableReverseDnsLookup;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -153,8 +153,8 @@
                     .annotatedWith(CanonicalWebUrl.class)
                     .toInstance("http://localhost:8080/");
                 bind(Boolean.class)
-                    .annotatedWith(EnableReverseDnsLookup.class)
-                    .toInstance(Boolean.TRUE);
+                    .annotatedWith(EnablePeerIPInReflogRecord.class)
+                    .toInstance(Boolean.FALSE);
                 bind(Realm.class).to(FakeRealm.class);
                 bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
                 bind(AccountCache.class).toInstance(accountCache);
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 6a32fa1..2573a5e 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -167,6 +167,17 @@
             + "Submitted-with: NOT_READY\n"
             + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
             + "Submitted-with: NEED: Alternative-Code-Review\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Subject: This is a test change\n"
+            + "Submitted-with: NOT_READY\n"
+            + "Submitted-with: Rule-Name: gerrit~PrologRule\n" // Rule-Name footer is ignored
+            + "Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+            + "Submitted-with: NEED: Code-Review\n");
     assertParseFails("Update change\n\nPatch-set: 1\nSubmitted-with: OOPS\n");
     assertParseFails("Update change\n\nPatch-set: 1\nSubmitted-with: NEED: X+Y\n");
     assertParseFails(
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index dd3238fa..5f375ad 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -17,7 +17,6 @@
 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 static com.google.gerrit.server.notedb.ChangeNotesState.Serializer.toByteString;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
@@ -33,13 +32,15 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -332,7 +333,8 @@
             .uploader(Account.id(2000))
             .createdOn(cols.createdOn())
             .build();
-    ByteString ps1Bytes = toByteString(ps1, PatchSetProtoConverter.INSTANCE);
+    Entities.PatchSet ps1Proto = PatchSetProtoConverter.INSTANCE.toProto(ps1);
+    ByteString ps1Bytes = Protos.toByteString(ps1Proto);
     assertThat(ps1Bytes.size()).isEqualTo(66);
 
     PatchSet ps2 =
@@ -342,7 +344,8 @@
             .uploader(Account.id(3000))
             .createdOn(cols.lastUpdatedOn())
             .build();
-    ByteString ps2Bytes = toByteString(ps2, PatchSetProtoConverter.INSTANCE);
+    Entities.PatchSet ps2Proto = PatchSetProtoConverter.INSTANCE.toProto(ps2);
+    ByteString ps2Bytes = Protos.toByteString(ps2Proto);
     assertThat(ps2Bytes.size()).isEqualTo(66);
     assertThat(ps2Bytes).isNotEqualTo(ps1Bytes);
 
@@ -352,8 +355,8 @@
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .addPatchSet(ps2Bytes)
-            .addPatchSet(ps1Bytes)
+            .addPatchSet(ps2Proto)
+            .addPatchSet(ps1Proto)
             .build());
   }
 
@@ -363,22 +366,23 @@
         PatchSetApproval.builder()
             .key(
                 PatchSetApproval.key(
-                    PatchSet.id(ID, 1), Account.id(2001), LabelId.create("Code-Review")))
+                    PatchSet.id(ID, 1), Account.id(2001), LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .granted(new Timestamp(1212L))
             .build();
-    ByteString a1Bytes = toByteString(a1, PatchSetApprovalProtoConverter.INSTANCE);
-    assertThat(a1Bytes.size()).isEqualTo(43);
+    Entities.PatchSetApproval psa1 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a1);
+    ByteString a1Bytes = Protos.toByteString(psa1);
 
     PatchSetApproval a2 =
         PatchSetApproval.builder()
             .key(
                 PatchSetApproval.key(
-                    PatchSet.id(ID, 1), Account.id(2002), LabelId.create("Verified")))
+                    PatchSet.id(ID, 1), Account.id(2002), LabelId.create(LabelId.VERIFIED)))
             .value(-1)
             .granted(new Timestamp(3434L))
             .build();
-    ByteString a2Bytes = toByteString(a2, PatchSetApprovalProtoConverter.INSTANCE);
+    Entities.PatchSetApproval psa2 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a2);
+    ByteString a2Bytes = Protos.toByteString(psa2);
     assertThat(a2Bytes.size()).isEqualTo(49);
     assertThat(a2Bytes).isNotEqualTo(a1Bytes);
 
@@ -390,8 +394,8 @@
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .addApproval(a2Bytes)
-            .addApproval(a1Bytes)
+            .addApproval(psa2)
+            .addApproval(psa1)
             .build());
   }
 
@@ -634,6 +638,39 @@
   }
 
   @Test
+  public void serializeAllAttentionSetUpdates() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .allAttentionSetUpdates(
+                ImmutableList.of(
+                    AttentionSetUpdate.createFromRead(
+                        Instant.EPOCH.plusSeconds(23),
+                        Account.id(1000),
+                        AttentionSetUpdate.Operation.ADD,
+                        "reason 1"),
+                    AttentionSetUpdate.createFromRead(
+                        Instant.EPOCH.plusSeconds(42),
+                        Account.id(2000),
+                        AttentionSetUpdate.Operation.REMOVE,
+                        "reason 2")))
+            .build(),
+        newProtoBuilder()
+            .addAllAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
+                    .setTimestampMillis(23_000) // epoch millis
+                    .setAccount(1000)
+                    .setOperation("ADD")
+                    .setReason("reason 1"))
+            .addAllAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
+                    .setTimestampMillis(42_000) // epoch millis
+                    .setAccount(2000)
+                    .setOperation("REMOVE")
+                    .setReason("reason 2"))
+            .build());
+  }
+
+  @Test
   public void serializeAssigneeUpdates() throws Exception {
     assertRoundTrip(
         newBuilder()
@@ -689,7 +726,8 @@
             Account.id(1000),
             new Timestamp(1212L),
             PatchSet.id(ID, 1));
-    ByteString m1Bytes = toByteString(m1, ChangeMessageProtoConverter.INSTANCE);
+    Entities.ChangeMessage m1Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m1);
+    ByteString m1Bytes = Protos.toByteString(m1Proto);
     assertThat(m1Bytes.size()).isEqualTo(35);
 
     ChangeMessage m2 =
@@ -698,7 +736,8 @@
             Account.id(2000),
             new Timestamp(3434L),
             PatchSet.id(ID, 2));
-    ByteString m2Bytes = toByteString(m2, ChangeMessageProtoConverter.INSTANCE);
+    Entities.ChangeMessage m2Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m2);
+    ByteString m2Bytes = Protos.toByteString(m2Proto);
     assertThat(m2Bytes.size()).isEqualTo(35);
     assertThat(m2Bytes).isNotEqualTo(m1Bytes);
 
@@ -708,8 +747,8 @@
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
             .setColumns(colsProto)
-            .addChangeMessage(m2Bytes)
-            .addChangeMessage(m1Bytes)
+            .addChangeMessage(m2Proto)
+            .addChangeMessage(m1Proto)
             .build());
   }
 
@@ -793,6 +832,9 @@
                     "attentionSet",
                     new TypeLiteral<ImmutableSet<AttentionSetUpdate>>() {}.getType())
                 .put(
+                    "allAttentionSetUpdates",
+                    new TypeLiteral<ImmutableList<AttentionSetUpdate>>() {}.getType())
+                .put(
                     "assigneeUpdates",
                     new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
                 .put("submitRecords", new TypeLiteral<ImmutableList<SubmitRecord>>() {}.getType())
@@ -801,6 +843,7 @@
                     "publishedComments",
                     new TypeLiteral<ImmutableListMultimap<ObjectId, HumanComment>>() {}.getType())
                 .put("updateCount", int.class)
+                .put("mergedOn", Timestamp.class)
                 .build());
   }
 
@@ -924,7 +967,7 @@
                 "labels",
                 new TypeLiteral<List<SubmitRecord.Label>>() {}.getType(),
                 "requirements",
-                new TypeLiteral<List<SubmitRequirement>>() {}.getType(),
+                new TypeLiteral<List<LegacySubmitRequirement>>() {}.getType(),
                 "errorMessage",
                 String.class));
     assertThatSerializedClass(SubmitRecord.Label.class)
@@ -933,7 +976,7 @@
                 "label", String.class,
                 "status", SubmitRecord.Label.Status.class,
                 "appliedBy", Account.Id.class));
-    assertThatSerializedClass(SubmitRequirement.class)
+    assertThatSerializedClass(LegacySubmitRequirement.class)
         .hasAutoValueMethods(
             ImmutableMap.of(
                 "fallbackText", String.class,
@@ -941,6 +984,19 @@
   }
 
   @Test
+  public void serializeMergedOn() throws Exception {
+    assertRoundTrip(
+        newBuilder().mergedOn(new Timestamp(234567L)).build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setMergedOnMillis(234567L)
+            .setHasMergedOn(true)
+            .setColumns(colsProto.toBuilder())
+            .build());
+  }
+
+  @Test
   public void changeMessageFields() throws Exception {
     assertThatSerializedClass(ChangeMessage.Key.class)
         .hasAutoValueMethods(ImmutableMap.of("changeId", Change.Id.class, "uuid", String.class));
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 6b9859f4..de49cdf 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.CommentRange;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmissionId;
@@ -153,12 +154,12 @@
     String tag2 = "ip";
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) -1);
     update.setTag(tag1);
     update.commit();
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) 1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.setTag(tag2);
     update.commit();
 
@@ -177,7 +178,7 @@
     Change c = newChange();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) -1);
     update.setChangeMessage("integration verification");
     update.setTag(integrationTag);
     update.commit();
@@ -231,8 +232,8 @@
   public void approvalsOnePatchSet() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Verified", (short) 1);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -242,13 +243,13 @@
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(0).accountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(0).value()).isEqualTo((short) -1);
     assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
 
     assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).accountId().get()).isEqualTo(1);
-    assertThat(psas.get(1).label()).isEqualTo("Verified");
+    assertThat(psas.get(1).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(psas.get(1).value()).isEqualTo((short) 1);
     assertThat(psas.get(1).granted()).isEqualTo(psas.get(0).granted());
   }
@@ -257,13 +258,13 @@
   public void approvalsMultiplePatchSets() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
     PatchSet.Id ps1 = c.currentPatchSetId();
 
     incrementPatchSet(c);
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
     PatchSet.Id ps2 = c.currentPatchSetId();
 
@@ -274,14 +275,14 @@
     PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
     assertThat(psa1.patchSetId()).isEqualTo(ps1);
     assertThat(psa1.accountId().get()).isEqualTo(1);
-    assertThat(psa1.label()).isEqualTo("Code-Review");
+    assertThat(psa1.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa1.value()).isEqualTo((short) -1);
     assertThat(psa1.granted()).isEqualTo(truncate(after(c, 2000)));
 
     PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
     assertThat(psa2.patchSetId()).isEqualTo(ps2);
     assertThat(psa2.accountId().get()).isEqualTo(1);
-    assertThat(psa2.label()).isEqualTo("Code-Review");
+    assertThat(psa2.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa2.value()).isEqualTo((short) +1);
     assertThat(psa2.granted()).isEqualTo(truncate(after(c, 4000)));
   }
@@ -290,22 +291,22 @@
   public void approvalsMultipleApprovals() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
         Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     notes = newNotes(c);
     psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
-    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) 1);
   }
 
@@ -313,11 +314,11 @@
   public void approvalsMultipleUsers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -327,13 +328,13 @@
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(0).accountId().get()).isEqualTo(1);
-    assertThat(psas.get(0).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(0).value()).isEqualTo((short) -1);
     assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
 
     assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).accountId().get()).isEqualTo(2);
-    assertThat(psas.get(1).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(1).value()).isEqualTo((short) 1);
     assertThat(psas.get(1).granted()).isEqualTo(truncate(after(c, 3000)));
   }
@@ -413,8 +414,8 @@
   public void putOtherUsersApprovals() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.putApprovalFor(otherUser.getAccountId(), "Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
+    update.putApprovalFor(otherUser.getAccountId(), LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -425,11 +426,11 @@
     assertThat(approvals).hasSize(2);
 
     assertThat(approvals.get(0).accountId()).isEqualTo(changeOwner.getAccountId());
-    assertThat(approvals.get(0).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
 
     assertThat(approvals.get(1).accountId()).isEqualTo(otherUser.getAccountId());
-    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo((short) -1);
   }
 
@@ -438,8 +439,8 @@
     Change c = newChange();
     SubmissionId submissionId = new SubmissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.putApproval("Verified", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.commit();
 
     update = newUpdate(c, changeOwner);
@@ -449,21 +450,21 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null))));
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null))));
     update.commit();
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 2);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
     assertThat(approvals).hasSize(2);
-    assertThat(approvals.get(0).label()).isEqualTo("Verified");
+    assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
     assertThat(approvals.get(0).postSubmit()).isFalse();
-    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo((short) 2);
     assertThat(approvals.get(1).postSubmit()).isTrue();
   }
@@ -473,8 +474,8 @@
     Change c = newChange();
     SubmissionId submissionId = new SubmissionId(c);
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
-    update.putApproval("Verified", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.commit();
 
     Account.Id ownerId = changeOwner.getAccountId();
@@ -486,10 +487,10 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", ownerId),
-                submitLabel("Code-Review", "NEED", null))));
+                submitLabel(LabelId.VERIFIED, "OK", ownerId),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null))));
     update.putApproval("Other-Label", (short) 1);
-    update.putApprovalFor(ownerId, "Code-Review", (short) 2);
+    update.putApprovalFor(ownerId, LabelId.CODE_REVIEW, (short) 2);
     update.commit();
 
     update = newUpdate(c, otherUser);
@@ -501,11 +502,11 @@
     List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
     assertThat(approvals).hasSize(3);
     assertThat(approvals.get(0).accountId()).isEqualTo(ownerId);
-    assertThat(approvals.get(0).label()).isEqualTo("Verified");
+    assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo(1);
     assertThat(approvals.get(0).postSubmit()).isFalse();
     assertThat(approvals.get(1).accountId()).isEqualTo(ownerId);
-    assertThat(approvals.get(1).label()).isEqualTo("Code-Review");
+    assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo(2);
     assertThat(approvals.get(1).postSubmit()).isFalse(); // During submit.
     assertThat(approvals.get(2).accountId()).isEqualTo(otherId);
@@ -582,11 +583,11 @@
     update.commit();
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -618,12 +619,12 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null)),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null)),
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
 
@@ -635,14 +636,14 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null)));
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null)));
     assertThat(recs.get(1))
         .isEqualTo(
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null)));
     assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toString());
   }
@@ -656,7 +657,8 @@
     update.merge(
         submissionId,
         ImmutableList.of(
-            submitRecord("OK", null, submitLabel("Code-Review", "OK", otherUser.getAccountId()))));
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", otherUser.getAccountId()))));
     update.commit();
 
     incrementPatchSet(c);
@@ -666,17 +668,88 @@
         submissionId,
         ImmutableList.of(
             submitRecord(
-                "OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId()))));
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", changeOwner.getAccountId()))));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getSubmitRecords())
         .containsExactly(
-            submitRecord("OK", null, submitLabel("Code-Review", "OK", changeOwner.getAccountId())));
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", changeOwner.getAccountId())));
     assertThat(notes.getChange().getSubmissionId()).isEqualTo(submissionId.toString());
   }
 
   @Test
+  public void mergedOnEmptyIfNotSubmitted() throws Exception {
+    Change c = newChange();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    // Make sure unrelevent update does not set mergedOn.
+    update.setTopic("topic");
+    update.commit();
+    assertThat(newNotes(c).getMergedOn()).isEmpty();
+  }
+
+  @Test
+  public void mergedOnSetWhenSubmitted() throws Exception {
+    Change c = newChange();
+
+    SubmissionId submissionId = new SubmissionId(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Update patch set 1");
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", otherUser.getAccountId()))));
+    update.commit();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getMergedOn()).isPresent();
+    Timestamp mergedOn = notes.getMergedOn().get();
+    assertThat(mergedOn).isEqualTo(notes.getChange().getLastUpdatedOn());
+
+    // Next update does not change mergedOn date.
+    update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getMergedOn().get()).isEqualTo(mergedOn);
+    assertThat(notes.getMergedOn().get()).isLessThan(notes.getChange().getLastUpdatedOn());
+  }
+
+  @Test
+  public void latestMergedOn() throws Exception {
+    Change c = newChange();
+    SubmissionId submissionId = new SubmissionId(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Update patch set 1");
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", otherUser.getAccountId()))));
+    update.commit();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getMergedOn()).isPresent();
+    Timestamp mergedOn = notes.getMergedOn().get();
+    assertThat(mergedOn).isEqualTo(notes.getChange().getLastUpdatedOn());
+
+    incrementPatchSet(c);
+    update = newUpdate(c, changeOwner);
+    update.setSubjectForCommit("Update patch set 2");
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "OK", null, submitLabel(LabelId.CODE_REVIEW, "OK", changeOwner.getAccountId()))));
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getMergedOn().get()).isGreaterThan(mergedOn);
+    assertThat(notes.getMergedOn().get()).isEqualTo(notes.getChange().getLastUpdatedOn());
+  }
+
+  @Test
   public void emptyChangeUpdate() throws Exception {
     Change c = newChange();
     Ref initial = repo.exactRef(changeMetaRef(c.getId()));
@@ -699,6 +772,13 @@
   }
 
   @Test
+  public void defaultAttentionSetUpdatesIsEmpty() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates()).isEmpty();
+  }
+
+  @Test
   public void addAttentionStatus() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -712,6 +792,19 @@
   }
 
   @Test
+  public void addAllAttentionUpdates() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates()).containsExactly(addTimestamp(attentionSetUpdate, c));
+  }
+
+  @Test
   public void filterLatestAttentionStatus() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -730,6 +823,28 @@
   }
 
   @Test
+  public void DoesNotFilterLatestAttentionSetUpdates() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate firstAttentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(firstAttentionSetUpdate));
+    update.commit();
+    update = newUpdate(c, changeOwner);
+    firstAttentionSetUpdate = addTimestamp(firstAttentionSetUpdate, c);
+
+    AttentionSetUpdate secondAttentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(secondAttentionSetUpdate));
+    update.commit();
+    secondAttentionSetUpdate = addTimestamp(secondAttentionSetUpdate, c);
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates())
+        .containsExactly(secondAttentionSetUpdate, firstAttentionSetUpdate);
+  }
+
+  @Test
   public void addAttentionStatus_rejectTimestamp() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -767,6 +882,8 @@
   public void addAttentionStatusForMultipleUsers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
+    // put the user as cc to ensure that the user took part in this change.
+    update.putReviewer(otherUser.getAccount().id(), CC);
     AttentionSetUpdate attentionSetUpdate0 =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
     AttentionSetUpdate attentionSetUpdate1 =
@@ -1050,7 +1167,7 @@
     assertThat(ts5).isGreaterThan(ts4);
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
     Timestamp ts6 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts6).isGreaterThan(ts5);
@@ -1081,7 +1198,7 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
     Timestamp ts10 = newNotes(c).getChange().getLastUpdatedOn();
@@ -1185,7 +1302,7 @@
     RevCommit commit = tr.commit().message("PS2").create();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setCommit(rw, commit);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.setChangeMessage("This is a message");
     update.putComment(
         HumanComment.Status.PUBLISHED,
@@ -1323,10 +1440,10 @@
   public void multipleUpdatesInManager() throws Exception {
     Change c = newChange();
     ChangeUpdate update1 = newUpdate(c, changeOwner);
-    update1.putApproval("Verified", (short) 1);
+    update1.putApproval(LabelId.VERIFIED, (short) 1);
 
     ChangeUpdate update2 = newUpdate(c, otherUser);
-    update2.putApproval("Code-Review", (short) 2);
+    update2.putApproval(LabelId.CODE_REVIEW, (short) 2);
 
     try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
       updateManager.add(update1);
@@ -1339,11 +1456,11 @@
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
-    assertThat(psas.get(0).label()).isEqualTo("Verified");
+    assertThat(psas.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(psas.get(0).value()).isEqualTo((short) 1);
 
     assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().id());
-    assertThat(psas.get(1).label()).isEqualTo("Code-Review");
+    assertThat(psas.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(1).value()).isEqualTo((short) 2);
   }
 
@@ -1377,7 +1494,7 @@
       updateManager.add(update1);
 
       ChangeUpdate update2 = newUpdate(c, otherUser);
-      update2.putApproval("Code-Review", (short) 2);
+      update2.putApproval(LabelId.CODE_REVIEW, (short) 2);
       updateManager.add(update2);
 
       updateManager.execute();
@@ -1416,11 +1533,11 @@
   public void multipleUpdatesAcrossRefs() throws Exception {
     Change c1 = newChange();
     ChangeUpdate update1 = newUpdate(c1, changeOwner);
-    update1.putApproval("Verified", (short) 1);
+    update1.putApproval(LabelId.VERIFIED, (short) 1);
 
     Change c2 = newChange();
     ChangeUpdate update2 = newUpdate(c2, otherUser);
-    update2.putApproval("Code-Review", (short) 2);
+    update2.putApproval(LabelId.CODE_REVIEW, (short) 2);
 
     Ref initial1 = repo.exactRef(update1.getRefName());
     assertThat(initial1).isNotNull();
@@ -1442,11 +1559,11 @@
 
     PatchSetApproval approval1 =
         newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
-    assertThat(approval1.label()).isEqualTo("Verified");
+    assertThat(approval1.label()).isEqualTo(LabelId.VERIFIED);
 
     PatchSetApproval approval2 =
         newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
-    assertThat(approval2.label()).isEqualTo("Code-Review");
+    assertThat(approval2.label()).isEqualTo(LabelId.CODE_REVIEW);
   }
 
   @Test
@@ -2660,7 +2777,8 @@
 
     ChangeUpdate failingUpdate = newUpdate(c, internalUser);
     assertThrows(
-        IllegalStateException.class, () -> failingUpdate.putApproval("Code-Review", (short) 1));
+        IllegalStateException.class,
+        () -> failingUpdate.putApproval(LabelId.CODE_REVIEW, (short) 1));
   }
 
   @Test
@@ -2822,7 +2940,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setPatchSetId(PatchSet.id(c.getId(), c.currentPatchSetId().get() + 1));
     update.setChangeMessage("Should be ignored");
-    update.putApproval("Code-Review", (short) 2);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 2);
     CommentRange range = new CommentRange(1, 1, 2, 1);
     HumanComment comment =
         newComment(
@@ -3179,12 +3297,12 @@
     assertThat(newNotes(c).getUpdateCount()).isEqualTo(1);
 
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.commit();
     assertThat(newNotes(c).getUpdateCount()).isEqualTo(2);
 
     update = newUpdate(c, changeOwner);
-    update.putApproval("Code-Review", (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
     assertThat(newNotes(c).getUpdateCount()).isEqualTo(3);
   }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 6cfd9f2d..68a1d9d 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -39,8 +40,8 @@
   public void approvalsCommitFormatSimple() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
     ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
-    update.putApproval("Verified", (short) 1);
-    update.putApproval("Code-Review", (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
     update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
@@ -61,8 +62,7 @@
             + "Reviewer: Gerrit User 1 <1@gerrit>\n"
             + "CC: Gerrit User 2 <2@gerrit>\n"
             + "Label: Code-Review=-1\n"
-            + "Label: Verified=+1\n"
-            + "Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Reviewer was added\"}\n",
+            + "Label: Verified=+1\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
@@ -135,7 +135,7 @@
   public void approvalTombstoneCommitFormat() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.removeApproval("Code-Review");
+    update.removeApproval(LabelId.CODE_REVIEW);
     update.commit();
 
     assertBodyEquals(
@@ -155,12 +155,12 @@
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
-                submitLabel("Code-Review", "NEED", null)),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null)),
             submitRecord(
                 "NOT_READY",
                 null,
-                submitLabel("Verified", "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
 
@@ -245,8 +245,7 @@
     update.commit();
 
     assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n"
-            + "Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Reviewer was added\"}\n",
+        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n",
         update.getResult());
   }
 
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
new file mode 100644
index 0000000..5bf5154
--- /dev/null
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import 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.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.io.IOException;
+import java.util.Map;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TreeFormatter;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test class for diff related logic of {@link DiffOperations}. */
+public class DiffOperationsTest {
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private DiffOperations diffOperations;
+
+  private static final Project.NameKey testProjectName = Project.nameKey("test-project");
+  private Repository repo;
+
+  private final String fileName1 = "file_1.txt";
+  private final String fileContent1 = "File content 1";
+  private final String fileName2 = "file_2.txt";
+  private final String fileContent2 = "File content 2";
+
+  @Before
+  public void setUpInjector() throws Exception {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+    repo = repoManager.createRepository(testProjectName);
+  }
+
+  @Test
+  public void diffModifiedFileAgainstParent() throws Exception {
+    ImmutableMap<String, String> oldFiles =
+        ImmutableMap.of(fileName1, fileContent1, fileName2, fileContent2);
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    ImmutableMap<String, String> newFiles =
+        ImmutableMap.of(fileName1, fileContent1, fileName2, fileContent2 + "\nnew line here");
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    FileDiffOutput diffOutput =
+        diffOperations.getModifiedFileAgainstParent(
+            testProjectName, newCommitId, /* parentNum=*/ null, fileName2, /* whitespace=*/ null);
+
+    assertThat(diffOutput.oldCommitId()).isEqualTo(oldCommitId);
+    assertThat(diffOutput.newCommitId()).isEqualTo(newCommitId);
+    assertThat(diffOutput.comparisonType().isAgainstParent()).isTrue();
+    assertThat(diffOutput.edits()).hasSize(1);
+  }
+
+  private ObjectId createCommit(
+      Repository repo, ObjectId parentCommit, ImmutableMap<String, String> fileNameToContent)
+      throws IOException {
+    ObjectId treeId = createTree(repo, fileNameToContent);
+    return createCommitInRepo(repo, treeId, parentCommit);
+  }
+
+  private static ObjectId createCommitInRepo(
+      Repository repo, ObjectId treeId, ObjectId parentCommit) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      PersonIdent committer =
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(treeId);
+      cb.setCommitter(committer);
+      cb.setAuthor(committer);
+      cb.setMessage("Test commit");
+      if (parentCommit != null) {
+        cb.setParentIds(parentCommit);
+      }
+      ObjectId commitId = oi.insert(cb);
+      oi.flush();
+      oi.close();
+      return commitId;
+    }
+  }
+
+  private static ObjectId createTree(
+      Repository repo, ImmutableMap<String, String> fileNameToContent) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = repo.newObjectReader();
+        RevWalk rw = new RevWalk(reader); ) {
+      TreeFormatter formatter = new TreeFormatter();
+      for (Map.Entry<String, String> entry : fileNameToContent.entrySet()) {
+        String fileName = entry.getKey();
+        String fileContent = entry.getValue();
+        ObjectId fileObjId = createBlob(repo, fileContent);
+        formatter.append(fileName, rw.lookupBlob(fileObjId));
+      }
+      ObjectId treeId = oi.insert(formatter);
+      oi.flush();
+      oi.close();
+      return treeId;
+    }
+  }
+
+  private static ObjectId createBlob(Repository repo, String content) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      ObjectId blobId = oi.insert(Constants.OBJ_BLOB, content.getBytes(UTF_8));
+      oi.flush();
+      oi.close();
+      return blobId;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/patch/PatchListTest.java b/javatests/com/google/gerrit/server/patch/PatchListTest.java
index 56adefa..182ce49 100644
--- a/javatests/com/google/gerrit/server/patch/PatchListTest.java
+++ b/javatests/com/google/gerrit/server/patch/PatchListTest.java
@@ -17,12 +17,15 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.server.patch.PatchList.ChangeTypeCmp;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.util.Arrays;
+import java.util.List;
 import org.junit.Test;
 
 public class PatchListTest {
@@ -67,6 +70,15 @@
   }
 
   @Test
+  public void changeTypeOrderIsComplete() {
+    List<ChangeType> changeTypeOrder = ChangeTypeCmp.order;
+    ChangeType[] allTypes = ChangeType.values();
+
+    Arrays.sort(allTypes, PatchList.CHANGE_TYPE_CMP);
+    assertThat(changeTypeOrder).containsExactlyElementsIn(allTypes).inOrder();
+  }
+
+  @Test
   public void largeObjectTombstoneCanBeSerializedAndDeserialized() throws Exception {
     // Serialize
     byte[] serializedObject;
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 87db21f..c5bef59 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PermissionRange;
 import com.google.gerrit.entities.Project;
@@ -651,12 +652,13 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(+1, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/master").group(DEVS).range(+1, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
 
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(2, range);
   }
 
@@ -665,17 +667,18 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
     projectOperations
         .project(parentKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
 
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-1, range);
     assertCanVote(1, range);
     assertCannotVote(-2, range);
@@ -687,18 +690,19 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
     projectOperations
         .project(parentKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
 
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-1, range);
     assertCanVote(1, range);
     assertCannotVote(-2, range);
@@ -832,13 +836,15 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
-        .add(allowLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(-2, 2))
-        .setExclusiveGroup(labelPermissionKey("Code-Review").ref("refs/heads/master"), true)
+        .add(
+            blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/master").group(DEVS).range(-2, 2))
+        .setExclusiveGroup(labelPermissionKey(LabelId.CODE_REVIEW).ref("refs/heads/master"), true)
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-2, range);
   }
 
@@ -1013,12 +1019,17 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, +1))
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(ANONYMOUS_USERS)
+                .range(-1, +1))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
@@ -1028,12 +1039,17 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, +1))
-        .add(allowLabel("Code-Review").ref("refs/heads/master").group(DEVS).range(-2, +2))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(ANONYMOUS_USERS)
+                .range(-1, +1))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/master").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1044,12 +1060,16 @@
         .project(localKey)
         .forUpdate()
         .add(
-            blockLabel("Code-Review").ref("refs/heads/master").group(ANONYMOUS_USERS).range(-1, +1))
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/master")
+                .group(ANONYMOUS_USERS)
+                .range(-1, +1))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1059,11 +1079,12 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, REGISTERED_USERS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-1, range);
     assertCannotVote(1, range);
   }
@@ -1073,16 +1094,18 @@
     projectOperations
         .project(parentKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
         .update();
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1092,12 +1115,12 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
     PermissionRange range =
-        u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review", true);
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW, true);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
@@ -1107,11 +1130,12 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1121,11 +1145,12 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
+        .add(blockLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(CHANGE_OWNER).range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCannotVote(-2, range);
     assertCannotVote(2, range);
   }
@@ -1135,12 +1160,17 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, +2))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
@@ -1150,12 +1180,17 @@
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-1, +1))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-2, range);
     assertCanVote(2, range);
   }
@@ -1165,17 +1200,26 @@
     projectOperations
         .project(parentKey)
         .forUpdate()
-        .add(allowLabel("Code-Review").ref("refs/heads/*").group(DEVS).range(-1, +1))
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/heads/*").group(DEVS).range(-1, +1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, +2))
         .update();
     projectOperations
         .project(localKey)
         .forUpdate()
-        .add(blockLabel("Code-Review").ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +1))
+        .add(
+            blockLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, +1))
         .update();
 
     ProjectControl u = user(localKey, DEVS);
-    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    PermissionRange range =
+        u.controlForRef("refs/heads/master").getRange(LABEL + LabelId.CODE_REVIEW);
     assertCanVote(-1, range);
     assertCannotVote(1, range);
   }
diff --git a/javatests/com/google/gerrit/server/permissions/SectionSortCacheTest.java b/javatests/com/google/gerrit/server/permissions/SectionSortCacheTest.java
new file mode 100644
index 0000000..9ec1625
--- /dev/null
+++ b/javatests/com/google/gerrit/server/permissions/SectionSortCacheTest.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.server.permissions.SectionSortCache.EntryKey;
+import com.google.gerrit.server.permissions.SectionSortCache.EntryVal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test for {@link SectionSortCache} */
+public class SectionSortCacheTest {
+  private SectionSortCache sectionSortCache;
+  private Cache<EntryKey, EntryVal> cache;
+
+  private static final AccessSection sectionA = AccessSection.create("refs/heads/branch_1");
+  private static final AccessSection sectionB = AccessSection.create("refs/base/branch_2");
+  private static final String REF_BASE = "refs/base";
+
+  @Before
+  public void setup() {
+    cache = CacheBuilder.newBuilder().build();
+    sectionSortCache = new SectionSortCache(cache);
+  }
+
+  @Test
+  public void sortSingleElement() {
+    List<AccessSection> input = new ArrayList<>();
+    input.add(sectionA);
+    sectionSortCache.sort(REF_BASE, input);
+    assertThat(input).containsExactly(sectionA);
+  }
+
+  @Test
+  public void sortMultiElements() {
+    List<AccessSection> input = new ArrayList<>();
+    input.add(sectionA);
+    input.add(sectionB);
+    sectionSortCache.sort(REF_BASE, input);
+    assertThat(input).containsExactly(sectionB, sectionA).inOrder();
+  }
+
+  @Test
+  public void sortMultiElementsWhenAlreadyOrdered() {
+    List<AccessSection> input = new ArrayList<>();
+    input.add(sectionB);
+    input.add(sectionA);
+    sectionSortCache.sort(REF_BASE, input);
+    assertThat(input).containsExactly(sectionB, sectionA).inOrder();
+  }
+
+  @Test
+  public void sortMultiElementsWithDuplicates() {
+    AccessSection sectionAClone = sectionA.toBuilder().build();
+    AccessSection sectionBClone = sectionB.toBuilder().build();
+    AccessSection[] input = {sectionBClone, sectionA, sectionAClone, sectionA, sectionB};
+    List<AccessSection> sorted = Arrays.asList(input);
+    sectionSortCache.sort(REF_BASE, sorted);
+    // Cache preserves relative order (reference equality) for identical elements
+    AccessSection[] expected = {sectionBClone, sectionB, sectionA, sectionAClone, sectionA};
+    for (int i = 0; i < sorted.size(); i++) {
+      assert (sorted.get(i) == expected[i]);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/project/GroupListTest.java b/javatests/com/google/gerrit/server/project/GroupListTest.java
index 7d4b7ca..853507d 100644
--- a/javatests/com/google/gerrit/server/project/GroupListTest.java
+++ b/javatests/com/google/gerrit/server/project/GroupListTest.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.git.ValidationError;
 import java.io.IOException;
 import java.util.Collection;
@@ -57,7 +58,7 @@
     GroupReference groupReference = groupList.byUUID(uuid);
 
     assertEquals(uuid, groupReference.getUUID());
-    assertEquals("Service Users", groupReference.getName());
+    assertEquals(ServiceUserClassifier.SERVICE_USERS, groupReference.getName());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index a39821e..aecb5d3 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -77,6 +77,9 @@
           + "  copyMaxScore = "
           + !LabelType.DEF_COPY_MAX_SCORE
           + "\n"
+          + "  copyAllScoresIfListOfFilesDidNotChange = "
+          + !LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE
+          + "\n"
           + "  copyAllScoresOnMergeFirstParentUpdate = "
           + !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE
           + "\n"
@@ -270,6 +273,8 @@
     assertThat(type.isCopyAnyScore()).isNotEqualTo(LabelType.DEF_COPY_ANY_SCORE);
     assertThat(type.isCopyMinScore()).isNotEqualTo(LabelType.DEF_COPY_MIN_SCORE);
     assertThat(type.isCopyMaxScore()).isNotEqualTo(LabelType.DEF_COPY_MAX_SCORE);
+    assertThat(type.isCopyAllScoresIfListOfFilesDidNotChange())
+        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
     assertThat(type.isCopyAllScoresOnMergeFirstParentUpdate())
         .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
     assertThat(type.isCopyAllScoresOnTrivialRebase())
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 88dda0f..99ccd31 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -85,6 +86,7 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.lifecycle.LifecycleManager;
@@ -110,6 +112,7 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
@@ -719,6 +722,24 @@
   }
 
   @Test
+  public void byParentOf() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    RevCommit commit1 = repo1.parseBody(repo1.commit().message("message").create());
+    Change change1 = insert(repo1, newChangeForCommit(repo1, commit1));
+    RevCommit commit2 = repo1.parseBody(repo1.commit(commit1));
+    Change change2 = insert(repo1, newChangeForCommit(repo1, commit2));
+    RevCommit commit3 = repo1.parseBody(repo1.commit(commit1, commit2));
+    Change change3 = insert(repo1, newChangeForCommit(repo1, commit3));
+
+    assertQuery("parentof:" + change1.getId().get());
+    assertQuery("parentof:" + change1.getKey().get());
+    assertQuery("parentof:" + change2.getId().get(), change1);
+    assertQuery("parentof:" + change2.getKey().get(), change1);
+    assertQuery("parentof:" + change3.getId().get(), change2, change1);
+    assertQuery("parentof:" + change3.getKey().get(), change2, change1);
+  }
+
+  @Test
   public void byParentProject() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     TestRepository<Repo> repo2 = createProject("repo2", "repo1");
@@ -1023,7 +1044,7 @@
         Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
 
     LabelType verified =
-        label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig cfg = projectConfigFactory.create(project);
       cfg.load(md);
@@ -1039,7 +1060,7 @@
         .add(allowLabel(verified.getName()).ref(heads).group(REGISTERED_USERS).range(-1, 1))
         .update();
 
-    ReviewInput reviewVerified = new ReviewInput().label("Verified", 1);
+    ReviewInput reviewVerified = new ReviewInput().label(LabelId.VERIFIED, 1);
     ChangeInserter ins = newChange(repo);
     ChangeInserter ins2 = newChange(repo);
     ChangeInserter ins3 = newChange(repo);
@@ -1590,6 +1611,10 @@
     assertThat(nowMs - lastUpdatedMs(change2)).isEqualTo(thirtyHoursInMs);
     assertThat(TimeUtil.nowMs()).isEqualTo(nowMs);
 
+    // Change1 was last updated on 2009-09-30 21:00:00 -0000
+    // Change2 was last updated on 2009-10-02 03:00:00 -0000
+    // The endpoint is 2009-10-03 09:00:00 -0000
+
     assertQuery("-age:1d");
     assertQuery("-age:" + (30 * 60 - 1) + "m");
     assertQuery("-age:2d", change2);
@@ -1597,6 +1622,15 @@
     assertQuery("age:3d");
     assertQuery("age:2d", change1);
     assertQuery("age:1d", change2, change1);
+
+    // Same test as above, but using filter code path.
+    assertQuery(makeIndexedPredicateFilterQuery("-age:1d"));
+    assertQuery(makeIndexedPredicateFilterQuery("-age:" + (30 * 60 - 1) + "m"));
+    assertQuery(makeIndexedPredicateFilterQuery("-age:2d"), change2);
+    assertQuery(makeIndexedPredicateFilterQuery("-age:3d"), change2, change1);
+    assertQuery(makeIndexedPredicateFilterQuery("age:3d"));
+    assertQuery(makeIndexedPredicateFilterQuery("age:2d"), change1);
+    assertQuery(makeIndexedPredicateFilterQuery("age:1d"), change2, change1);
   }
 
   @Test
@@ -1609,6 +1643,9 @@
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
+    // Change1 was last updated on 2009-09-30 21:00:00 -0000
+    // Change2 was last updated on 2009-10-02 03:00:00 -0000
+
     for (String predicate : Lists.newArrayList("before:", "until:")) {
       assertQuery(predicate + "2009-09-29");
       assertQuery(predicate + "2009-09-30");
@@ -1621,6 +1658,22 @@
       assertQuery(predicate + "2009-10-01", change1);
       assertQuery(predicate + "2009-10-03", change2, change1);
     }
+
+    // Same test as above, but using filter code path.
+    for (String predicate : Lists.newArrayList("before:", "until:")) {
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-09-29"));
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-09-30"));
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 16:59:00 -0400\""));
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 20:59:00 -0000\""));
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 20:59:00\""));
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 17:02:00 -0400\""), change1);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 21:02:00 -0000\""), change1);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 21:02:00\""), change1);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-01"), change1);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-03"), change2, change1);
+    }
   }
 
   @Test
@@ -1633,6 +1686,8 @@
     Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
+    // Change1 was last updated on 2009-09-30 21:00:00 -0000
+    // Change2 was last updated on 2009-10-02 03:00:00 -0000
     for (String predicate : Lists.newArrayList("after:", "since:")) {
       assertQuery(predicate + "2009-10-03");
       assertQuery(predicate + "\"2009-10-01 20:59:59 -0400\"", change2);
@@ -1640,6 +1695,197 @@
       assertQuery(predicate + "2009-10-01", change2);
       assertQuery(predicate + "2009-09-30", change2, change1);
     }
+
+    // Same test as above, but using filter code path.
+    for (String predicate : Lists.newArrayList("after:", "since:")) {
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-03"));
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 20:59:59 -0400\""), change2);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-01 20:59:59 -0000\""), change2);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-10-01"), change2);
+      assertQuery(makeIndexedPredicateFilterQuery(predicate + "2009-09-30"), change2, change1);
+    }
+  }
+
+  @Test
+  public void mergedOperatorSupportedByIndexVersion() throws Exception {
+    if (getSchemaVersion() < 61) {
+      assertMissingField(ChangeField.MERGED_ON);
+      assertFailingQuery(
+          "mergedbefore:2009-10-01",
+          "'mergedbefore' operator is not supported by change index version");
+      assertFailingQuery(
+          "mergedafter:2009-10-01",
+          "'mergedafter' operator is not supported by change index version");
+    } else {
+      assertThat(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    }
+  }
+
+  @Test
+  public void byMergedBefore() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+
+    // Stop the clock, will set time to specific test values.
+    resetTimeWithClockStep(0, MILLISECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    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));
+
+    TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
+    submit(change3);
+    TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
+    submit(change2);
+    TestTimeUtil.setClock(new Timestamp(startMs + 3 * thirtyHoursInMs));
+    // Put another approval on the change, just to update it.
+    approve(change1);
+    approve(change3);
+
+    assertThat(TimeUtil.nowMs()).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+
+    // Verify that:
+    // 1. Change1 was not submitted and should be never returned.
+    // 2. Change2 was merged on 2009-10-02 03:00:00 -0000
+    // 3. Change3 was merged on 2009-10-03 09:00:00.0 -0000
+    assertQuery("mergedbefore:2009-10-01");
+    // Changes excluded on the date submitted.
+    assertQuery("mergedbefore:2009-10-02");
+    assertQuery("mergedbefore:\"2009-10-01 22:59:00 -0400\"");
+    assertQuery("mergedbefore:\"2009-10-01 02:59:00\"");
+    assertQuery("mergedbefore:\"2009-10-01 23:02:00 -0400\"", change3);
+    assertQuery("mergedbefore:\"2009-10-02 03:02:00 -0000\"", change3);
+    assertQuery("mergedbefore:\"2009-10-02 03:02:00\"", change3);
+    assertQuery("mergedbefore:2009-10-03", change3);
+    // Changes are sorted by lastUpdatedOn first, then by mergedOn.
+    // Even though Change2 was merged after Change3, Change3 is returned first.
+    assertQuery("mergedbefore:2009-10-04", change3, change2);
+
+    // Same test as above, but using filter code path.
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-01"));
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-02"));
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-01 22:59:00 -0400\""));
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-01 02:59:00\""));
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-01 23:02:00 -0400\""), change3);
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-02 03:02:00 -0000\""), change3);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:\"2009-10-02 03:02:00\""), change3);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-03"), change3);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-04"), change3, change2);
+  }
+
+  @Test
+  public void byMergedAfter() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+
+    // Stop the clock, will set time to specific test values.
+    resetTimeWithClockStep(0, MILLISECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    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));
+    assertThat(TimeUtil.nowMs()).isEqualTo(startMs);
+
+    TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
+    submit(change3);
+
+    TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
+    submit(change2);
+
+    TestTimeUtil.setClock(new Timestamp(startMs + 3 * thirtyHoursInMs));
+    // Put another approval on the change, just to update it.
+    approve(change1);
+    approve(change3);
+
+    assertThat(TimeUtil.nowMs()).isEqualTo(startMs + 3 * thirtyHoursInMs);
+
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+
+    // Verify that:
+    // 1. Change1 was not submitted and should be never returned.
+    // 2. Change2 was merged on 2009-10-02 03:00:00 -0000
+    // 3. Change3 was merged on 2009-10-03 09:00:00.0 -0000
+    assertQuery("mergedafter:2009-10-01", change3, change2);
+    // Changes are sorted by lastUpdatedOn first, then by mergedOn.
+    // Even though Change2 was merged after Change3, Change3 is returned first.
+    assertQuery("mergedafter:\"2009-10-01 22:59:00 -0400\"", change3, change2);
+    assertQuery("mergedafter:\"2009-10-02 02:59:00 -0000\"", change3, change2);
+    assertQuery("mergedafter:\"2009-10-01 23:02:00 -0400\"", change2);
+    assertQuery("mergedafter:\"2009-10-02 03:02:00 -0000\"", change2);
+    // Changes included on the date submitted.
+    assertQuery("mergedafter:2009-10-02", change3, change2);
+    assertQuery("mergedafter:2009-10-03", change2);
+
+    // Same test as above, but using filter code path.
+
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-01"), change3, change2);
+    // Changes are sorted by lastUpdatedOn first, then by mergedOn.
+    // Even though Change2 was merged after Change3, Change3 is returned first.
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-01 22:59:00 -0400\""),
+        change3,
+        change2);
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-02 02:59:00 -0000\""),
+        change3,
+        change2);
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-01 23:02:00 -0400\""), change2);
+    assertQuery(
+        makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-02 03:02:00 -0000\""), change2);
+    // Changes included on the date submitted.
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-02"), change3, change2);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-03"), change2);
+  }
+
+  @Test
+  public void updatedThenMergedOrder() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
+
+    // Stop the clock, will set time to specific test values.
+    resetTimeWithClockStep(0, MILLISECONDS);
+    TestRepository<Repo> repo = createProject("repo");
+    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));
+
+    TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
+    submit(change2);
+    submit(change3);
+    TestTimeUtil.setClock(new Timestamp(startMs + 2 * thirtyHoursInMs));
+    // Approve post submit just to update lastUpdatedOn
+    approve(change3);
+    approve(change2);
+    submit(change1);
+
+    // All Changes were last updated at the same time.
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 2 * thirtyHoursInMs);
+
+    // Changes are sorted by lastUpdatedOn first, then by mergedOn, then by Id in reverse order.
+    // 1. Change3 and Change2 were merged at the same time, but Change3 ID > Change2 ID.
+    // 2. Change1 ID < Change3 ID & Change2 ID but it was merged last.
+    assertQuery("mergedbefore:2009-10-06", change1, change3, change2);
+    assertQuery("mergedafter:2009-09-30", change1, change3, change2);
+    assertQuery("status:merged", change1, change3, change2);
   }
 
   @Test
@@ -2141,21 +2387,24 @@
     TestRepository<Repo> repo = createProject("repo");
     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));
     RevCommit mergeCommit =
         repo.branch("master")
             .commit()
             .message("Merge commit")
             .parent(commit1)
-            .parent(commit2)
+            .parent(commit3)
             .insertChangeId()
             .create();
     Change mergeChange = insert(repo, newChangeForCommit(repo, mergeCommit));
 
     assertQuery("status:open is:merge", mergeChange);
-    assertQuery("status:open -is:merge", change2, change1);
-    assertQuery("status:open", mergeChange, change2, change1);
+    assertQuery("status:open -is:merge", change3, change2, change1);
+    assertQuery("status:open", mergeChange, change3, change2, change1);
   }
 
   @Test
@@ -3072,6 +3321,14 @@
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
     Account.Id user2Id =
         accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+
+    // Add the second user as cc to ensure that user took part of the change and can be added to the
+    // attention set.
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user2Id.toString();
+    addReviewerInput.state = ReviewerState.CC;
+    gApi.changes().id(change.getChangeId()).addReviewer(addReviewerInput);
+
     input = new AttentionSetInput(user2Id.toString(), "reason 2");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
 
@@ -3102,6 +3359,7 @@
     assertQuery("-assignee:" + user.getUserName().get(), change2);
   }
 
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
   public void userDestination() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
@@ -3113,6 +3371,8 @@
         .hasMessageThat()
         .isEqualTo("Unknown named destination: foo");
 
+    Account.Id anotherUserId =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     String destination1 = "refs/heads/master\trepo1";
     String destination2 = "refs/heads/master\trepo2";
     String destination3 = "refs/heads/master\trepo1\nrefs/heads/master\trepo2";
@@ -3128,8 +3388,32 @@
       allUsers.branch(refsUsers).commit().add("destinations/destination4", destination4).create();
       allUsers.branch(refsUsers).commit().add("destinations/destination5", destination5).create();
 
+      String anotherRefsUsers = RefNames.refsUsers(anotherUserId);
+      allUsers
+          .branch(anotherRefsUsers)
+          .commit()
+          .add("destinations/destination6", destination1)
+          .create();
+      allUsers
+          .branch(anotherRefsUsers)
+          .commit()
+          .add("destinations/destination7", destination2)
+          .create();
+      allUsers
+          .branch(anotherRefsUsers)
+          .commit()
+          .add("destinations/destination8", destination3)
+          .create();
+      allUsers
+          .branch(anotherRefsUsers)
+          .commit()
+          .add("destinations/destination9", destination4)
+          .create();
+
       Ref userRef = allUsers.getRepository().exactRef(refsUsers);
+      Ref anotherUserRef = allUsers.getRepository().exactRef(anotherRefsUsers);
       assertThat(userRef).isNotNull();
+      assertThat(anotherUserRef).isNotNull();
     }
 
     assertQuery("destination:destination1", change1);
@@ -3137,38 +3421,87 @@
     assertQuery("destination:destination3", change2, change1);
     assertQuery("destination:destination4");
     assertQuery("destination:destination5");
+    assertQuery("destination:destination6,user=" + anotherUserId, change1);
+    assertQuery("destination:name=destination6,user=" + anotherUserId, change1);
+    assertQuery("destination:user=" + anotherUserId + ",destination7", change2);
+    assertQuery("destination:user=" + anotherUserId + ",name=destination8", change2, change1);
+    assertQuery("destination:destination9,user=" + anotherUserId);
+
+    assertThatQueryException("destination:destination3,user=" + anotherUserId)
+        .hasMessageThat()
+        .isEqualTo("Unknown named destination: destination3");
+    assertThatQueryException("destination:destination3,user=test")
+        .hasMessageThat()
+        .isEqualTo("Account 'test' not found");
+
+    requestContext.setContext(newRequestContext(anotherUserId));
+    // account 1000000 is not visible to 'anotheruser' as they are not an admin
+    assertThatQueryException("destination:destination3,user=" + userId)
+        .hasMessageThat()
+        .isEqualTo("Account '1000000' not found");
   }
 
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
   public void userQuery() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChangeForBranch(repo, "stable"));
 
+    Account.Id anotherUserId =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     String queryListText =
         "query1\tproject:repo\n"
             + "query2\tproject:repo status:open\n"
             + "query3\tproject:repo branch:stable\n"
             + "query4\tproject:repo branch:other";
+    String anotherQueryListText =
+        "query5\tproject:repo\n"
+            + "query6\tproject:repo status:merged\n"
+            + "query7\tproject:repo branch:stable\n"
+            + "query8\tproject:repo branch:other";
 
     try (TestRepository<Repo> allUsers =
             new TestRepository<>(repoManager.openRepository(allUsersName));
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName)) {
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName);
+        MetaDataUpdate anotherMd = metaDataUpdateFactory.create(allUsersName)) {
       VersionedAccountQueries queries = VersionedAccountQueries.forUser(userId);
       queries.load(md);
       queries.setQueryList(queryListText);
       queries.commit(md);
+      VersionedAccountQueries anotherQueries = VersionedAccountQueries.forUser(anotherUserId);
+      anotherQueries.load(anotherMd);
+      anotherQueries.setQueryList(anotherQueryListText);
+      anotherQueries.commit(anotherMd);
     }
 
     assertThatQueryException("query:foo").hasMessageThat().isEqualTo("Unknown named query: foo");
+    assertThatQueryException("query:query1,user=" + anotherUserId)
+        .hasMessageThat()
+        .isEqualTo("Unknown named query: query1");
+    assertThatQueryException("query:query1,user=test")
+        .hasMessageThat()
+        .isEqualTo("Account 'test' not found");
+
+    requestContext.setContext(newRequestContext(anotherUserId));
+    // account 1000000 is not visible to 'anotheruser' as they are not an admin
+    assertThatQueryException("query:query1,user=" + userId)
+        .hasMessageThat()
+        .isEqualTo("Account '1000000' not found");
+    requestContext.setContext(newRequestContext(userId));
 
     assertQuery("query:query1", change2, change1);
     assertQuery("query:query2", change2, change1);
+    assertQuery("query:name=query5,user=" + anotherUserId, change2, change1);
+    assertQuery("query:user=" + anotherUserId + ",name=query6");
     gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(change1.getChangeId()).current().submit();
     assertQuery("query:query2", change2);
     assertQuery("query:query3", change2);
     assertQuery("query:query4");
+    assertQuery("query:query6,user=" + anotherUserId, change1);
+    assertQuery("query:user=" + anotherUserId + ",query7", change2);
+    assertQuery("query:query8,user=" + anotherUserId);
   }
 
   @Test
@@ -3522,6 +3855,62 @@
     return c.getLastUpdatedOn().getTime();
   }
 
+  // Get the last  updated time from ChangeApi
+  protected long lastUpdatedMsApi(Change c) throws Exception {
+    return gApi.changes().id(c.getChangeId()).get().updated.getTime();
+  }
+
+  protected void approve(Change change) throws Exception {
+    gApi.changes().id(change.getChangeId()).current().review(ReviewInput.approve());
+  }
+
+  protected void submit(Change change) throws Exception {
+    approve(change);
+    gApi.changes().id(change.getChangeId()).current().submit();
+  }
+
+  /**
+   * Generates a search query to test {@link com.google.gerrit.index.query.Matchable} implementation
+   * of change {@link IndexPredicate}
+   *
+   * <p>This code path requires triggering the condition, when
+   *
+   * <ol>
+   *   <li>The query is rewritten into multiple {@link IndexedChangeQuery} by {@link
+   *       com.google.gerrit.server.index.change.ChangeIndexRewriter#rewrite}
+   *   <li>The changes are returned from the index by the first {@link IndexedChangeQuery}
+   *   <li>Then constrained in {@link com.google.gerrit.index.query.AndSource#match} by applying all
+   *       parsed predicates from the search query
+   *   <li>Thus, the rest of {@link IndexedChangeQuery} work as filters on the index results, see
+   *       {@link IndexedChangeQuery#match}
+   * </ol>
+   *
+   * The constructed query only constrains by the passed searchTerm for the operator that is being
+   * tested (for all changes without a reviewer):
+   *
+   * <ul>
+   *   <li>The search term 'status:new OR status:merged OR status:abandoned' is used to return all
+   *       changes from the search index.
+   *   <li>The non-indexed search term 'reviewerin:"Empty Group"' is only used to make the right AND
+   *       operand work as a filter (not a data source).
+   *   <li>See how it is rewritten in {@link
+   *       com.google.gerrit.server.index.change.ChangeIndexRewriterTest#threeLevelTreeWithMultipleSources}
+   * </ul>
+   *
+   * @param searchTerm change search term that maps to {@link IndexPredicate} and needs to be tested
+   *     as filter
+   * @return a search query that allows to test the {@code searchTerm} as a filter.
+   */
+  protected String makeIndexedPredicateFilterQuery(String searchTerm) throws Exception {
+    String emptyGroupName = "Empty Group";
+    if (gApi.groups().query(emptyGroupName).get().isEmpty()) {
+      createGroup(emptyGroupName, "Administrators");
+    }
+    String queryPattern =
+        "(status:new OR status:merged OR status:abandoned) AND (reviewerin:\"%s\" OR %s)";
+    return String.format(queryPattern, emptyGroupName, searchTerm);
+  }
+
   private void addComment(int changeId, String message, Boolean unresolved) throws Exception {
     ReviewInput input = new ReviewInput();
     ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 0258e5d..43b9690 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -19,7 +19,6 @@
         "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/httpd",
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index d80eac0..d760003 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -24,6 +24,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -47,7 +48,6 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupField;
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index d0398e9..ebb2f38 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.ComparisonType;
@@ -40,6 +41,7 @@
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.restapi.change.CommentPorter.Metrics;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import java.sql.Timestamp;
 import java.util.Arrays;
@@ -61,6 +63,8 @@
   @Mock private PatchListCache patchListCache;
   @Mock private CommentsUtil commentsUtil;
 
+  private static final CommentPorter.Metrics metrics = new Metrics(new DisabledMetricMaker());
+
   private int uuidCounter = 0;
 
   @Test
@@ -72,7 +76,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
@@ -94,7 +98,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
@@ -116,7 +120,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenThrow(IllegalStateException.class);
@@ -136,7 +140,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
@@ -161,7 +165,7 @@
     PatchSet patchset3 = createPatchset(PatchSet.id(changeId, 3));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2, patchset3);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     // Place the comments on different patchsets to have two different diff requests.
     HumanComment comment1 = createComment(patchset1.id(), "myFile");
     HumanComment comment2 = createComment(patchset2.id(), "myFile");
@@ -191,7 +195,7 @@
     // Leave out patchset 1 (e.g. reserved for draft patchsets in the past).
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 5cefe74..b3e0c56 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -25,6 +25,7 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
 import java.util.stream.Collectors;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -33,7 +34,6 @@
 @RunWith(JUnit4.class)
 public class ListChangeCommentsTest {
 
-  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void commentsLinkedToChangeMessagesIgnoreGerritAutoGenTaggedMessages() {
     /* Comments should not be linked to Gerrit's autogenerated messages */
@@ -55,10 +55,10 @@
         .isEqualTo(getChangeMessage(changeMessages, "cm3").getKey().uuid());
 
     // Make sure no comment is linked to the auto-gen message
-    assertThat(comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet()))
-        .doesNotContain(
-            /* expected: String, actual: ChangeMessage */ getChangeMessage(
-                changeMessages, "cmAutoGenByGerrit"));
+    Set<String> changeMessageIds =
+        comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet());
+    assertThat(changeMessageIds)
+        .doesNotContain(getChangeMessage(changeMessages, "cmAutoGenByGerrit").getKey().uuid());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 250b0ce..5c57ede 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -7,7 +7,6 @@
     resources = ["//prologtests:gerrit_common_test"],
     runtime_deps = ["//prolog:gerrit-prolog-common"],
     deps = [
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
index cf5e8fe..e5dd817 100644
--- a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -34,12 +34,12 @@
 public class IgnoreSelfApprovalRuleTest {
   private static final Change.Id CHANGE_ID = Change.id(100);
   private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
-  private static final LabelType VERIFIED = makeLabel("Verified");
+  private static final LabelType VERIFIED = makeLabel(LabelId.VERIFIED);
   private static final Account.Id USER1 = makeAccount(100001);
 
   @Test
   public void filtersByLabel() {
-    LabelType codeReview = makeLabel("Code-Review");
+    LabelType codeReview = makeLabel(LabelId.CODE_REVIEW);
     PatchSetApproval approvalVerified = makeApproval(VERIFIED.getLabelId(), USER1, 2);
     PatchSetApproval approvalCr = makeApproval(codeReview.getLabelId(), USER1, 2);
 
diff --git a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
index 8622b32..a5357e1 100644
--- a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
+++ b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
@@ -16,13 +16,14 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.gerrit.entities.LabelId;
 import org.junit.Test;
 
 public class PrologRuleEvaluatorTest {
 
   @Test
   public void validLabelNamesAreKept() {
-    for (String labelName : new String[] {"Verified", "Code-Review"}) {
+    for (String labelName : new String[] {LabelId.VERIFIED, LabelId.CODE_REVIEW}) {
       assertThat(PrologRuleEvaluator.checkLabelName(labelName)).isEqualTo(labelName);
     }
   }
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index d6c5b5a..e6a6497 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.GroupUuid;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.Sequences;
@@ -88,7 +89,7 @@
     expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
 
     GroupReference adminsGroup = createGroupReference("Administrators");
-    GroupReference batchUsersGroup = createGroupReference("Service Users");
+    GroupReference batchUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
             .administratorsGroup(adminsGroup)
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index 01a44f3..fc6b412 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.LabelValue;
+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.git.GitRepositoryManager;
@@ -82,7 +83,7 @@
 
   @Test
   public void groupIsCreatedWhenSchemaIsCreated() throws Exception {
-    assertThat(hasGroup("Service Users")).isTrue();
+    assertThat(hasGroup(ServiceUserClassifier.SERVICE_USERS)).isTrue();
     assertThat(hasGroup("Non-Interactive Users")).isFalse();
   }
 
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index e175b95..4fe4ab04 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -10,7 +10,6 @@
     ],
     deps = [
         "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
diff --git a/lib/BUILD b/lib/BUILD
index 0110047..660b041 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -54,6 +54,16 @@
 )
 
 java_library(
+    name = "jgit-ssh-apache",
+    data = ["//lib:LICENSE-jgit"],
+    visibility = ["//visibility:public"],
+    exports = ["@jgit//org.eclipse.jgit.ssh.apache:ssh-apache"],
+    runtime_deps = [
+        "//lib/mina:sshd-sftp",
+    ],
+)
+
+java_library(
     name = "jgit-archive",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
@@ -480,17 +490,17 @@
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
     exports = [
-        ":hamcrest-core",
+        ":hamcrest",
         "@junit//jar",
     ],
-    runtime_deps = [":hamcrest-core"],
+    runtime_deps = [":hamcrest"],
 )
 
 java_library(
-    name = "hamcrest-core",
+    name = "hamcrest",
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
-    exports = ["@hamcrest-core//jar"],
+    exports = ["@hamcrest//jar"],
 )
 
 java_library(
diff --git a/lib/LICENSE-PublicDomain b/lib/LICENSE-PublicDomain
new file mode 100644
index 0000000..8a71ce0
--- /dev/null
+++ b/lib/LICENSE-PublicDomain
@@ -0,0 +1 @@
+This software has been placed in the public domain by its author(s).
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 1da7f50..18b9b91 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -27,6 +27,21 @@
     ],
 )
 
+java_plugin(
+    name = "auto-value-gson-plugin",
+    processor_class = "com.ryanharter.auto.value.gson.factory.AutoValueGsonAdapterFactoryProcessor",
+    deps = [
+        "@auto-value-annotations//jar",
+        "@auto-value-gson-extension//jar",
+        "@auto-value-gson-factory//jar",
+        "@auto-value-gson-runtime//jar",
+        "@auto-value//jar",
+        "@autotransient//jar",
+        "@gson//jar",
+        "@javapoet//jar",
+    ],
+)
+
 java_library(
     name = "auto-value",
     data = ["//lib:LICENSE-Apache2.0"],
@@ -50,3 +65,17 @@
     visibility = ["//visibility:public"],
     exports = ["@auto-value-annotations//jar"],
 )
+
+java_library(
+    name = "auto-value-gson",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exported_plugins = [
+        ":auto-value-gson-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = [
+        "@auto-value-gson-extension//jar",
+        "@auto-value-gson-factory//jar",
+        "@auto-value-gson-runtime//jar",
+    ],
+)
diff --git a/lib/guava.bzl b/lib/guava.bzl
deleted file mode 100644
index 4de39cb..0000000
--- a/lib/guava.bzl
+++ /dev/null
@@ -1,5 +0,0 @@
-GUAVA_VERSION = "29.0-jre"
-
-GUAVA_BIN_SHA1 = "801142b4c3d0f0770dd29abea50906cacfddd447"
-
-GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
diff --git a/lib/guice/BUILD b/lib/guice/BUILD
index 14179d6..f73984b 100644
--- a/lib/guice/BUILD
+++ b/lib/guice/BUILD
@@ -1,9 +1,4 @@
-load("@rules_java//java:defs.bzl", "java_import", "java_library")
-
-java_import(
-    name = "guice-library-no-aop",
-    jars = ["@guice-library-no-aop//file"],
-)
+load("@rules_java//java:defs.bzl", "java_library")
 
 java_library(
     name = "guice",
@@ -19,7 +14,8 @@
     name = "guice-library",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = [":guice-library-no-aop"],
+    exports = ["@guice-library//jar"],
+    runtime_deps = ["aopalliance"],
 )
 
 java_library(
@@ -39,6 +35,12 @@
 )
 
 java_library(
+    name = "aopalliance",
+    data = ["//lib:LICENSE-PublicDomain"],
+    exports = ["@aopalliance//jar"],
+)
+
+java_library(
     name = "javax_inject",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
diff --git a/lib/mina/BUILD b/lib/mina/BUILD
index 70e7c1d..3f23263 100644
--- a/lib/mina/BUILD
+++ b/lib/mina/BUILD
@@ -13,6 +13,13 @@
 )
 
 java_library(
+    name = "sshd-sftp",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@sshd-sftp//jar"],
+)
+
+java_library(
     name = "eddsa",
     data = ["//lib:LICENSE-CC0-1.0"],
     visibility = ["//visibility:public"],
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 13e81f7..ec8e018 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -18,13 +18,14 @@
 dropwizard-core
 duct-tape
 eddsa
-elasticsearch-rest-client
 flogger
 flogger-log4j-backend
 flogger-system-backend
+guava
 guice-assistedinject
-guice-library-no-aop
+guice-library
 guice-servlet
+hamcrest
 httpasyncclient
 httpcore-nio
 impl-log4j
@@ -32,6 +33,7 @@
 jackson-annotations
 jackson-core
 jcl-over-slf4j
+jimfs
 jna
 jruby
 log-api
@@ -41,10 +43,15 @@
 nekohtml
 objenesis
 openid-consumer
+soy
 sshd-mina
 sshd-osgi
+sshd-sftp
 testcontainers
-testcontainers-elasticsearch
+truth
+truth-java8-extension
+truth-liteproto-extension
+truth-proto-extension
 tukaani-xz
 visible-assertions
 xerces
diff --git a/modules/jgit b/modules/jgit
index 8470771..5efd32e 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 84707715108a65a366ef35f2ae04aabecd0b35f6
+Subproject commit 5efd32e91da44bd05ff14dd7b35eccbecf54a095
diff --git a/package.json b/package.json
index c75795d..f52aa74 100644
--- a/package.json
+++ b/package.json
@@ -2,24 +2,27 @@
   "name": "gerrit",
   "version": "3.1.0-SNAPSHOT",
   "description": "Gerrit Code Review",
-  "dependencies": {},
-  "devDependencies": {
+  "dependencies": {
     "@bazel/concatjs": "^5.1.0",
     "@bazel/rollup": "^5.1.0",
     "@bazel/terser": "^5.1.0",
-    "@bazel/typescript": "^5.1.0",
-    "eslint": "^6.6.0",
-    "eslint-config-google": "^0.13.0",
-    "eslint-plugin-html": "^6.0.0",
-    "eslint-plugin-import": "^2.20.1",
-    "eslint-plugin-jsdoc": "^19.2.0",
-    "eslint-plugin-prettier": "^3.1.3",
-    "gts": "^2.0.2",
+    "@bazel/typescript": "^5.1.0"
+  },
+  "devDependencies": {
+    "@typescript-eslint/eslint-plugin": "^4.22.0",
+    "eslint": "^7.24.0",
+    "eslint-config-google": "^0.14.0",
+    "eslint-plugin-html": "^6.1.2",
+    "eslint-plugin-import": "^2.22.1",
+    "eslint-plugin-jsdoc": "^32.3.0",
+    "eslint-plugin-node": "^11.1.0",
+    "eslint-plugin-prettier": "^3.4.0",
+    "gts": "^3.1.0",
     "polymer-cli": "^1.9.11",
-    "prettier": "2.0.5",
-    "rollup": "^2.3.4",
-    "terser": "^4.8.0",
-    "typescript": "3.9.5"
+    "prettier": "2.2.1",
+    "rollup": "^2.45.2",
+    "terser": "^5.6.1",
+    "typescript": "4.1.4"
   },
   "scripts": {
     "clean": "git clean -fdx && bazel clean --expunge",
@@ -31,8 +34,8 @@
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
     "polylint": "npm run safe_bazelisk test polygerrit-ui/app:polylint_test",
-    "test:debug": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --browsers ChromeDev --no-single-run --testFiles",
-    "test:single": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --testFiles"
+    "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --browsers ChromeDev --no-single-run --testFiles",
+    "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --testFiles"
   },
   "repository": {
     "type": "git",
diff --git a/plugins/BUILD b/plugins/BUILD
index 943471a..dd0be66 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -50,6 +50,7 @@
     "//java/com/google/gerrit/server/cache/mem",
     "//java/com/google/gerrit/server/cache/serialize",
     "//java/com/google/gerrit/server/data",
+    "//java/com/google/gerrit/server/git/receive",
     "//java/com/google/gerrit/server/logging",
     "//java/com/google/gerrit/server/schema",
     "//java/com/google/gerrit/server/util/time",
@@ -58,6 +59,7 @@
     "//java/com/google/gerrit/util/logging",
     "//lib/antlr:java-runtime",
     "//lib/auto:auto-value-annotations",
+    "//lib/auto:auto-value-gson",
     "//lib/commons:compress",
     "//lib/commons:dbcp",
     "//lib/commons:lang",
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index c621796..aa2edb4 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit c6217963e42322accc3f0bacb6540f8791f67ab0
+Subproject commit aa2edb431337cc935659ab69d95ca7555cbc787e
diff --git a/plugins/delete-project b/plugins/delete-project
index 7d060da..142875a 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 7d060dab5f311ec498f9a3b0aa58ce797b8c4d28
+Subproject commit 142875ae29b728e4fbad5bc22dc132df37cc4de7
diff --git a/plugins/download-commands b/plugins/download-commands
index 87e3930..5bd359c 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 87e3930cea7c06aea454998abdddf6515a9f103b
+Subproject commit 5bd359c08e10b93d2c08762f75cde01a14e45fc6
diff --git a/plugins/gitiles b/plugins/gitiles
index d83dc8b..5e8809c 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit d83dc8b0cd4b56a769b4c6ce909ba9c3a79aa2a1
+Subproject commit 5e8809c34798022a81cabe1d2d682bb83245a3c7
diff --git a/plugins/replication b/plugins/replication
index fb4854b..b4ebcfc 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit fb4854b57300f1060690b59722eae8c3a18cdab5
+Subproject commit b4ebcfced53c1f4d4e9bcd885978c276fc55070f
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 30ca9c1..09623b9 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 30ca9c1fa624b7389703dd8f8d35cff778e60d83
+Subproject commit 09623b9432d360060f88ae48fb3386e374ca29c0
diff --git a/plugins/webhooks b/plugins/webhooks
index 83dda66..dba493b 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 83dda664bf0ad96249dfd99a03b11d2eaf32703d
+Subproject commit dba493b1679cc07b0f5e3fd9277b306c4693e08a
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
index c9a5d9b..a636119 100644
--- a/polygerrit-ui/FE_Style_Guide.md
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -10,11 +10,20 @@
 Additionally to the rules above, Gerrit frontend uses the following rules (some of them have automated checks,
 some don't):
 
+- [Prefer null over undefined](#prefer-null)
 - [Use destructuring imports only](#destructuring-imports-only)
 - [Use classes and services for storing and manipulating global state](#services-for-global-state)
 - [Pass required services in the constructor for plain classes](#pass-dependencies-in-constructor)
 - [Assign required services in a HTML/Polymer element constructor](#assign-dependencies-in-html-element-constructor)
 
+## <a name="prefer-undefined"></a>Prefer `undefined` over `null`
+
+It is more confusing than helpful to work with both `null` and `undefined`. We prefer to only use `undefined` in
+our code base. Try to avoid `null`.
+
+Some browser and library APIs are using `null`, so we cannot remove `null` completely from our code base. But even
+then try to convert return values and leak as few `nulls` as possible.
+
 ## <a name="destructuring-imports-only"></a>Use destructuring imports only
 Always use destructuring import statement and specify all required names explicitly (e.g. `import {a,b,c} from '...'`)
 where possible.
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 41ee468..c6e6cd9 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -116,7 +116,7 @@
 the command line:
 
 ```sh
-./polygerrit-ui/run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js,plugins/my_plugin/static/my_plugin.html
+./polygerrit-ui/run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js
 ```
 
 If any issues occured, please refer to the Troubleshooting section at the bottom or contact the team!
@@ -195,6 +195,9 @@
 npm run test:debug async-foreach-behavior_test.js
 ```
 
+When converting a test file to typescript, the command for running tests is
+still using the .js suffix and not the new .ts suffix.
+
 Commands `test:debug` and `test:single` assumes that compiled code is located
 in the `./ts-out/polygerrit-ui/app` directory. It's up to you how to achieve it.
 For example, the following options are possible:
@@ -301,7 +304,7 @@
 existing helpers to create an object with all required properties:
 ```
 // Before:
-sinon.stub(element.$.restAPI, 'getPreferences').returns(
+sinon.stub(element.restApiService, 'getPreferences').returns(
     Promise.resolve({default_diff_view: 'UNIFIED'}));
 
 // After:
@@ -431,12 +434,10 @@
     .stub(element, '_reload')
     .callsFake(() => Promise.resolve());
 
-stub('gr-rest-api-interface', {
-  getDiffComments() { return Promise.resolve({}); },
-  getDiffRobotComments() { return Promise.resolve({}); },
-  getDiffDrafts() { return Promise.resolve({}); },
-  _fetchSharedCacheURL() { return Promise.resolve({}); },
-});
+stubRestApi('getDiffComments').returns(Promise.resolve({}));
+stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+stubRestApi('_fetchSharedCacheURL').returns(Promise.resolve({}));
 ```
 
 In such cases, validate the input and output of a stub/fake method. Quite often
@@ -448,61 +449,9 @@
   // GrChangeView._reload method returns an array
   .callsFake(() => Promise.resolve([])); // return [] here
 
-stub('gr-rest-api-interface', {
   ...
   // Fix return type:
-  _fetchSharedCacheURL() { return Promise.resolve({} as ParsedJSON); },
-});
-```
-
-If a method has multiple overloads, you can use one of 2 options:
-```
-// Option 1: less accurate, but shorter:
-function getCommentsStub() {
-  return Promise.resolve({});
-}
-
-stub('gr-rest-api-interface', {
-  ...
-  getDiffComments: (getCommentsStub as unknown) as RestApiService['getDiffComments'],
-  getDiffRobotComments: (getCommentsStub as unknown) as RestApiService['getDiffRobotComments'],
-  getDiffDrafts: (getCommentsStub as unknown) as RestApiService['getDiffDrafts'],
-  ...
-});
-
-// Option 2: more accurate, but longer.
-// Step 1: define the same overloads for stub:
-function getDiffCommentsStub(
-  changeNum: NumericChangeId
-): Promise<PathToCommentsInfoMap | undefined>;
-function getDiffCommentsStub(
-  changeNum: NumericChangeId,
-  basePatchNum: PatchSetNum,
-  patchNum: PatchSetNum,
-  path: string
-): Promise<GetDiffCommentsOutput>;
-
-// Step 2: implement stub method for differnt input
-function getDiffCommentsStub(
-  _: NumericChangeId,
-  basePatchNum?: PatchSetNum,
-):
-  | Promise<PathToCommentsInfoMap | undefined>
-  | Promise<GetDiffCommentsOutput> {
-  if (basePatchNum) {
-    return Promise.resolve({
-      baseComments: [],
-      comments: [],
-    });
-  }
-  return Promise.resolve({});
-}
-
-// Step 3: use stubbed function:
-stub('gr-rest-api-interface', {
-  ...
-  getDiffComments: getDiffCommentsStub,
-  ...
+  stubRestApi('_fetchSharedCacheURL').returns(Promise.resolve({} as ParsedJSON));
 });
 ```
 
diff --git a/polygerrit-ui/app/.eslintignore b/polygerrit-ui/app/.eslintignore
index 16ea228..087a049 100644
--- a/polygerrit-ui/app/.eslintignore
+++ b/polygerrit-ui/app/.eslintignore
@@ -1,3 +1,4 @@
 **/node_modules
 **/rollup.config.js
 node_modules_licenses
+!.eslintrc-bazel.js
diff --git a/polygerrit-ui/app/.eslintrc-bazel.js b/polygerrit-ui/app/.eslintrc-bazel.js
index 977eb45..9a51242 100644
--- a/polygerrit-ui/app/.eslintrc-bazel.js
+++ b/polygerrit-ui/app/.eslintrc-bazel.js
@@ -20,7 +20,7 @@
 // for node_modules.
 
 function getBazelSettings() {
-  const runFilesDir = process.env["RUNFILES_DIR"];
+  const runFilesDir = process.env['RUNFILES_DIR'];
   if (!runFilesDir) {
     // eslint is executed with 'bazel run ...' to fix the source code. It runs
     // against real source code, no special paths for node_modules is set.
@@ -28,18 +28,18 @@
   }
   // eslint is executed with 'bazel test...'. Set path to required node_modules
   return {
-    "import/resolver": {
-      "node": {
-        "paths": [
+    'import/resolver': {
+      node: {
+        paths: [
           `${runFilesDir}/ui_npm/node_modules`,
-          `${runFilesDir}/ui_dev_npm/node_modules`
-        ]
-      }
-    }
+          `${runFilesDir}/ui_dev_npm/node_modules`,
+        ],
+      },
+    },
   };
 }
 
 module.exports = {
-  "extends": "./.eslintrc.js",
-  "settings": getBazelSettings(),
+  extends: './.eslintrc.js',
+  settings: getBazelSettings(),
 };
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index c5dde38..faf126c 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -20,219 +20,225 @@
 const path = require('path');
 
 module.exports = {
-  "extends": ["eslint:recommended", "google"],
-  "parserOptions": {
-    "ecmaVersion": 9,
-    "sourceType": "module"
+  extends: ['eslint:recommended', 'google'],
+  parserOptions: {
+    ecmaVersion: 9,
+    sourceType: 'module',
   },
-  "env": {
-    "browser": true,
-    "es6": true
+  env: {
+    browser: true,
+    es6: true,
   },
-  "rules": {
+  rules: {
     // https://eslint.org/docs/rules/no-confusing-arrow
-    "no-confusing-arrow": "error",
+    'no-confusing-arrow': 'error',
     // https://eslint.org/docs/rules/newline-per-chained-call
-    "newline-per-chained-call": ["error", {"ignoreChainWithDepth": 2}],
+    'newline-per-chained-call': ['error', {ignoreChainWithDepth: 2}],
     // https://eslint.org/docs/rules/arrow-body-style
-    "arrow-body-style": ["error", "as-needed",
-      {"requireReturnForObjectLiteral": true}],
+    'arrow-body-style': ['error', 'as-needed',
+      {requireReturnForObjectLiteral: true}],
     // https://eslint.org/docs/rules/arrow-parens
-    "arrow-parens": ["error", "as-needed"],
+    'arrow-parens': ['error', 'as-needed'],
     // https://eslint.org/docs/rules/block-spacing
-    "block-spacing": ["error", "always"],
+    'block-spacing': ['error', 'always'],
     // https://eslint.org/docs/rules/brace-style
-    "brace-style": ["error", "1tbs", {"allowSingleLine": true}],
+    'brace-style': ['error', '1tbs', {allowSingleLine: true}],
     // https://eslint.org/docs/rules/camelcase
-    "camelcase": "off",
+    'camelcase': 'off',
     // https://eslint.org/docs/rules/comma-dangle
-    "comma-dangle": ["error", {
-      "arrays": "always-multiline",
-      "objects": "always-multiline",
-      "imports": "always-multiline",
-      "exports": "always-multiline",
-      "functions": "never"
+    'comma-dangle': ['error', {
+      arrays: 'always-multiline',
+      objects: 'always-multiline',
+      imports: 'always-multiline',
+      exports: 'always-multiline',
+      functions: 'never',
     }],
     // https://eslint.org/docs/rules/eol-last
-    "eol-last": "off",
+    'eol-last': 'off',
+    'guard-for-in': 'error',
     // https://eslint.org/docs/rules/indent
-    "indent": ["error", 2, {
-      "MemberExpression": 2,
-      "FunctionDeclaration": {"body": 1, "parameters": 2},
-      "FunctionExpression": {"body": 1, "parameters": 2},
-      "CallExpression": {"arguments": 2},
-      "ArrayExpression": 1,
-      "ObjectExpression": 1,
-      "SwitchCase": 1
+    'indent': ['error', 2, {
+      MemberExpression: 2,
+      FunctionDeclaration: {body: 1, parameters: 2},
+      FunctionExpression: {body: 1, parameters: 2},
+      CallExpression: {arguments: 2},
+      ArrayExpression: 1,
+      ObjectExpression: 1,
+      SwitchCase: 1,
     }],
     // https://eslint.org/docs/rules/keyword-spacing
-    "keyword-spacing": ["error", {"after": true, "before": true}],
+    'keyword-spacing': ['error', {after: true, before: true}],
     // https://eslint.org/docs/rules/lines-between-class-members
-    "lines-between-class-members": ["error", "always"],
+    'lines-between-class-members': ['error', 'always'],
     // https://eslint.org/docs/rules/max-len
-    "max-len": [
-      "error",
+    'max-len': [
+      'error',
       80,
       2,
       {
-        "ignoreComments": true,
-        "ignorePattern": "^import .*;$"
-      }
+        ignoreComments: true,
+        ignorePattern: '^import .*;$',
+      },
     ],
     // https://eslint.org/docs/rules/new-cap
-    "new-cap": ["error", {
-      "capIsNewExceptions": ["Polymer", "GestureEventListeners"],
-      "capIsNewExceptionPattern": "^.*Mixin$"
+    'new-cap': ['error', {
+      capIsNewExceptions: ['Polymer'],
+      capIsNewExceptionPattern: '^.*Mixin$',
     }],
     // https://eslint.org/docs/rules/no-console
-    "no-console": ["error", { allow: ["warn", "error", "info", "assert", "group", "groupEnd"] }],
+    'no-console': [
+      'error',
+      {allow: ['warn', 'error', 'info', 'assert', 'group', 'groupEnd']},
+    ],
     // https://eslint.org/docs/rules/no-multiple-empty-lines
-    "no-multiple-empty-lines": ["error", {"max": 1}],
+    'no-multiple-empty-lines': ['error', {max: 1}],
     // https://eslint.org/docs/rules/no-prototype-builtins
-    "no-prototype-builtins": "off",
+    'no-prototype-builtins': 'off',
     // https://eslint.org/docs/rules/no-redeclare
-    "no-redeclare": "off",
+    'no-redeclare': 'off',
     // https://eslint.org/docs/rules/no-trailing-spaces
-    "no-trailing-spaces": "error",
+    'no-trailing-spaces': 'error',
     // https://eslint.org/docs/rules/no-irregular-whitespace
-    "no-irregular-whitespace": "error",
+    'no-irregular-whitespace': 'error',
     // https://eslint.org/docs/rules/array-callback-return
-    "array-callback-return": ['error', { allowImplicit: true }],
+    'array-callback-return': ['error', {allowImplicit: true}],
     // https://eslint.org/docs/rules/no-restricted-syntax
-    "no-restricted-syntax": [
-      "error",
+    'no-restricted-syntax': [
+      'error',
       {
-        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']",
-        "message": "Remove test.only."
+        selector: 'ExpressionStatement > CallExpression > ' +
+            'MemberExpression[object.name=\'test\'][property.name=\'only\']',
+        message: 'Remove test.only.',
       },
       {
-        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']",
-        "message": "Remove suite.only."
-      }
+        selector: 'ExpressionStatement > CallExpression > ' +
+            'MemberExpression[object.name=\'suite\'][property.name=\'only\']',
+        message: 'Remove suite.only.',
+      },
     ],
     // no-undef disables global variable.
     // "globals" declares allowed global variables.
     // https://eslint.org/docs/rules/no-undef
-    "no-undef": ["error"],
+    'no-undef': ['error'],
     // https://eslint.org/docs/rules/no-useless-escape
-    "no-useless-escape": "off",
+    'no-useless-escape': 'off',
     // https://eslint.org/docs/rules/no-var
-    "no-var": "error",
+    'no-var': 'error',
     // https://eslint.org/docs/rules/operator-linebreak
-    "operator-linebreak": "off",
+    'operator-linebreak': 'off',
     // https://eslint.org/docs/rules/object-shorthand
-    "object-shorthand": ["error", "always"],
+    'object-shorthand': ['error', 'always'],
     // https://eslint.org/docs/rules/padding-line-between-statements
-    "padding-line-between-statements": [
-      "error",
+    'padding-line-between-statements': [
+      'error',
       {
-        "blankLine": "always",
-        "prev": "class",
-        "next": "*"
+        blankLine: 'always',
+        prev: 'class',
+        next: '*',
       },
       {
-        "blankLine": "always",
-        "prev": "*",
-        "next": "class"
-      }
+        blankLine: 'always',
+        prev: '*',
+        next: 'class',
+      },
     ],
     // https://eslint.org/docs/rules/prefer-arrow-callback
-    "prefer-arrow-callback": "error",
+    'prefer-arrow-callback': 'error',
     // https://eslint.org/docs/rules/prefer-const
-    "prefer-const": "error",
+    'prefer-const': 'error',
     // https://eslint.org/docs/rules/prefer-promise-reject-errors
-    "prefer-promise-reject-errors": "error",
+    'prefer-promise-reject-errors': 'error',
     // https://eslint.org/docs/rules/prefer-spread
-    "prefer-spread": "error",
+    'prefer-spread': 'error',
     // https://eslint.org/docs/rules/prefer-object-spread
-    "prefer-object-spread": "error",
+    'prefer-object-spread': 'error',
     // https://eslint.org/docs/rules/quote-props
-    "quote-props": ["error", "consistent-as-needed"],
+    'quote-props': ['error', 'consistent-as-needed'],
     // https://eslint.org/docs/rules/semi
-    "semi": ["error", "always"],
+    'semi': ['error', 'always'],
     // https://eslint.org/docs/rules/template-curly-spacing
-    "template-curly-spacing": "error",
+    'template-curly-spacing': 'error',
 
     // https://eslint.org/docs/rules/require-jsdoc
-    "require-jsdoc": 0,
+    'require-jsdoc': 0,
     // https://eslint.org/docs/rules/valid-jsdoc
-    "valid-jsdoc": 0,
+    'valid-jsdoc': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-alignment
-    "jsdoc/check-alignment": 2,
+    'jsdoc/check-alignment': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-examples
-    "jsdoc/check-examples": 0,
+    'jsdoc/check-examples': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-indentation
-    "jsdoc/check-indentation": 0,
+    'jsdoc/check-indentation': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-param-names
-    "jsdoc/check-param-names": 0,
+    'jsdoc/check-param-names': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-syntax
-    "jsdoc/check-syntax": 0,
+    'jsdoc/check-syntax': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-tag-names
-    "jsdoc/check-tag-names": 0,
+    'jsdoc/check-tag-names': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-types
-    "jsdoc/check-types": 0,
+    'jsdoc/check-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-implements-on-classes
-    "jsdoc/implements-on-classes": 2,
+    'jsdoc/implements-on-classes': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-match-description
-    "jsdoc/match-description": 0,
+    'jsdoc/match-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-newline-after-description
-    "jsdoc/newline-after-description": 2,
+    'jsdoc/newline-after-description': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-types
-    "jsdoc/no-types": 0,
+    'jsdoc/no-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-undefined-types
-    "jsdoc/no-undefined-types": 0,
+    'jsdoc/no-undefined-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description
-    "jsdoc/require-description": 0,
+    'jsdoc/require-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description-complete-sentence
-    "jsdoc/require-description-complete-sentence": 0,
+    'jsdoc/require-description-complete-sentence': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-example
-    "jsdoc/require-example": 0,
+    'jsdoc/require-example': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-hyphen-before-param-description
-    "jsdoc/require-hyphen-before-param-description": 0,
+    'jsdoc/require-hyphen-before-param-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-jsdoc
-    "jsdoc/require-jsdoc": 0,
+    'jsdoc/require-jsdoc': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param
-    "jsdoc/require-param": 0,
+    'jsdoc/require-param': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-description
-    "jsdoc/require-param-description": 0,
+    'jsdoc/require-param-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-name
-    "jsdoc/require-param-name": 2,
+    'jsdoc/require-param-name': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns
-    "jsdoc/require-returns": 0,
+    'jsdoc/require-returns': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-check
-    "jsdoc/require-returns-check": 0,
+    'jsdoc/require-returns-check': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-description
-    "jsdoc/require-returns-description": 0,
+    'jsdoc/require-returns-description': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-valid-types
-    "jsdoc/valid-types": 2,
+    'jsdoc/valid-types': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-file-overview
-    "jsdoc/require-file-overview": ["error", {
-      "tags": {
-        "license": {
-          "mustExist": true,
-          "preventDuplicates": true
-        }
-      }
+    'jsdoc/require-file-overview': ['error', {
+      tags: {
+        license: {
+          mustExist: true,
+          preventDuplicates: true,
+        },
+      },
     }],
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-self-import.md
-    "import/no-self-import": 2,
+    'import/no-self-import': 2,
     // The no-cycle rule is slow, because it doesn't cache dependencies.
     // Disable it.
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-cycle.md
-    "import/no-cycle": 0,
+    'import/no-cycle': 0,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-useless-path-segments.md
-    "import/no-useless-path-segments": 2,
+    'import/no-useless-path-segments': 2,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-unused-modules.md
-    "import/no-unused-modules": 2,
+    'import/no-unused-modules': 2,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-default-export.md
-    "import/no-default-export": 2,
+    'import/no-default-export': 2,
     // Prevents certain identifiers being used.
     // Prefer flush() over flushAsynchronousOperations().
-    "id-blacklist": ["error", "flushAsynchronousOperations"],
+    'id-blacklist': ['error', 'flushAsynchronousOperations'],
   },
 
   // List of allowed globals in all files
-  "globals": {
+  globals: {
     // Polygerrit global variables.
     // You must not add anything new in this list!
     // Instead export variables from modules
@@ -240,150 +246,171 @@
     // Global variables from 3rd party libraries.
     // You should not add anything in this list, always try to import
     // If import is not possible - you can extend this list
-    "ShadyCSS": "readonly",
-    "linkify": "readonly",
-    "security": "readonly",
+    ShadyCSS: 'readonly',
+    linkify: 'readonly',
+    security: 'readonly',
   },
-  "overrides": [
+  overrides: [
     {
-      // .js-only rules
-      "files": ["**/*.js"],
-      "rules": {
-        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-type
-        "jsdoc/require-param-type": 2,
-        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-type
-        "jsdoc/require-returns-type": 2,
-        // The rule is required for .js files only, because typescript compiler
-        // always checks import.
-        "import/no-unresolved": 2,
-        "import/named": 2,
+      files: ['.eslintrc.js', '.eslintrc-bazel.js'],
+      env: {
+        browser: false,
+        es6: true,
+        node: true,
       },
-      "globals": {
-        "goog": "readonly",
-      }
     },
     {
-      "files": ["**/*.ts"],
-      "extends": [require.resolve("gts/.eslintrc.json")],
-      "rules": {
-        "no-restricted-imports": ["error", {
-          name: "@polymer/decorators/lib/decorators",
-          message: "Use @polymer/decorators instead",
+      // .js-only rules
+      files: ['**/*.js'],
+      rules: {
+        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-type
+        'jsdoc/require-param-type': 2,
+        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-type
+        'jsdoc/require-returns-type': 2,
+        // The rule is required for .js files only, because typescript compiler
+        // always checks import.
+        'import/no-unresolved': 2,
+        'import/named': 2,
+      },
+      globals: {
+        goog: 'readonly',
+      },
+    },
+    {
+      files: ['**/*.ts'],
+      extends: [require.resolve('gts/.eslintrc.json')],
+      rules: {
+        'no-restricted-imports': ['error', {
+          name: '@polymer/decorators/lib/decorators',
+          message: 'Use @polymer/decorators instead',
         }],
+        '@typescript-eslint/no-explicit-any': 'error',
         // See https://github.com/GoogleChromeLabs/shadow-selection-polyfill/issues/9
-        "@typescript-eslint/ban-ts-ignore": "off",
+        '@typescript-eslint/ban-ts-comment': 'off',
         // The following rules is required to match internal google rules
-        "@typescript-eslint/restrict-plus-operands": "error",
+        '@typescript-eslint/restrict-plus-operands': 'error',
+        '@typescript-eslint/no-unused-vars': [
+          'error',
+          {argsIgnorePattern: '^_'},
+        ],
         // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/node-builtins.md
-        "node/no-unsupported-features/node-builtins": "off",
+        'node/no-unsupported-features/node-builtins': 'off',
         // Disable no-invalid-this for ts files, because it incorrectly reports
         // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
         // At the same time, we are using typescript in a strict mode and
         // it catches almost all errors related to invalid usage of this.
-        "no-invalid-this": "off",
+        'no-invalid-this': 'off',
 
-        "node/no-extraneous-import": "off",
+        'node/no-extraneous-import': 'off',
 
         // Typescript already checks for undef
-        "no-undef": "off",
+        'no-undef': 'off',
 
-        "jsdoc/no-types": 2,
+        'jsdoc/no-types': 2,
       },
-      "parserOptions": {
-        "project": path.resolve(__dirname, "./tsconfig_eslint.json"),
-      }
-    },
-    {
-      "files": ["*.html", "test.js", "test-infra.js"],
-      "rules": {
-        "jsdoc/require-file-overview": "off"
+      parserOptions: {
+        project: path.resolve(__dirname, './tsconfig_eslint.json'),
       },
     },
     {
-      "files": [
-        "*.html",
-        "*_test.js",
-        "a11y-test-utils.js",
+      files: [
+        '*_test.ts',
+        'test-utils.ts',
+      ],
+      rules: {
+        '@typescript-eslint/no-explicit-any': 'off',
+      },
+    },
+    {
+      files: ['*.html', 'test.js', 'test-infra.js'],
+      rules: {
+        'jsdoc/require-file-overview': 'off',
+      },
+    },
+    {
+      files: [
+        '*.html',
+        '*_test.js',
+        'a11y-test-utils.js',
       ],
       // Additional global variables allowed in tests
-      "globals": {
+      globals: {
         // Global variables from 3rd party test libraries/frameworks.
         // You can extend this list if you want to use other global
         // variables from these libraries and import is not possible
-        "MockInteractions": "readonly",
-        "_": "readonly",
-        "axs": "readonly",
-        "a11ySuite": "readonly",
-        "assert": "readonly",
-        "expect": "readonly",
-        "fixture": "readonly",
-        "flush": "readonly",
-        "flushAsynchronousOperations": "readonly",
-        "setup": "readonly",
-        "sinon": "readonly",
-        "stub": "readonly",
-        "suite": "readonly",
-        "suiteSetup": "readonly",
-        "suiteTeardown": "readonly",
-        "teardown": "readonly",
-        "test": "readonly",
-        "fixtureFromElement": "readonly",
-        "fixtureFromTemplate": "readonly",
-      }
+        MockInteractions: 'readonly',
+        _: 'readonly',
+        axs: 'readonly',
+        a11ySuite: 'readonly',
+        assert: 'readonly',
+        expect: 'readonly',
+        fixture: 'readonly',
+        flush: 'readonly',
+        setup: 'readonly',
+        sinon: 'readonly',
+        stub: 'readonly',
+        suite: 'readonly',
+        suiteSetup: 'readonly',
+        suiteTeardown: 'readonly',
+        teardown: 'readonly',
+        test: 'readonly',
+        fixtureFromElement: 'readonly',
+        fixtureFromTemplate: 'readonly',
+      },
     },
     {
-      "files": "import-href.js",
-      "globals": {
-        "HTMLImports": "readonly",
-      }
+      files: 'import-href.js',
+      globals: {
+        HTMLImports: 'readonly',
+      },
     },
     {
-      "files": ["samples/**/*.js"],
-      "globals": {
+      files: ['samples/**/*.js'],
+      globals: {
         // Settings for samples. You can add globals here if you want to use it
-        "Gerrit": "readonly",
-        "Polymer": "readonly",
-      }
+        Gerrit: 'readonly',
+        Polymer: 'readonly',
+      },
     },
     {
-      "files": ["test/functional/**/*.js"],
+      files: ['test/functional/**/*.js'],
       // Settings for functional tests. These scripts are node scripts.
       // Turn off "no-undef" to allow any global variable
-      "env": {
-        "browser": false,
-        "node": true,
-        "es6": false
+      env: {
+        browser: false,
+        node: true,
+        es6: false,
       },
-      "rules": {
-        "no-undef": "off",
-      }
+      rules: {
+        'no-undef': 'off',
+      },
     },
     {
-      "files": ["*_html.js", "gr-icons.js", "*-theme.js", "*-styles.js"],
-      "rules": {
-        "max-len": "off"
-      }
+      files: ['*_html.js', 'gr-icons.js', '*-theme.js', '*-styles.js'],
+      rules: {
+        'max-len': 'off',
+      },
     },
     {
-      "files": ["*_html.js"],
-      "rules": {
-        "prettier/prettier": ["error", {
-          "bracketSpacing": false,
-          "singleQuote": true,
-        }]
-      }
-    }
+      files: ['*_html.js'],
+      rules: {
+        'prettier/prettier': ['error', {
+          bracketSpacing: false,
+          singleQuote: true,
+        }],
+      },
+    },
   ],
-  "plugins": [
-    "html",
-    "jsdoc",
-    "import",
-    "prettier"
+  plugins: [
+    'html',
+    'jsdoc',
+    'import',
+    'prettier',
   ],
-  "settings": {
-    "html/report-bad-indent": "error",
-    "import/resolver": {
-      "node": {},
+  settings: {
+    'html/report-bad-indent': 'error',
+    'import/resolver': {
+      node: {},
       [path.resolve(__dirname, './.eslint-ts-resolver.js')]: {},
     },
   },
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index c29663c..47521f6 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -6,6 +6,7 @@
 # This list must be in sync with the "include" list in the follwoing files:
 # tsconfig.json, tsconfig_bazel.json, tsconfig_bazel_test.json
 src_dirs = [
+    "api",
     "constants",
     "elements",
     "embed",
@@ -129,6 +130,7 @@
     ],
     ignore = ".eslintignore",
     plugins = [
+        "@npm//@typescript-eslint/eslint-plugin",
         "@npm//eslint-config-google",
         "@npm//eslint-plugin-html",
         "@npm//eslint-plugin-import",
diff --git a/polygerrit-ui/app/api/README.md b/polygerrit-ui/app/api/README.md
new file mode 100644
index 0000000..550063f
--- /dev/null
+++ b/polygerrit-ui/app/api/README.md
@@ -0,0 +1,23 @@
+# API
+
+In this folder, we declare the API of various parts of the Gerrit webclient.
+There are two primary use cases for this:
+
+* apps that embed our diff viewer, gr-diff
+* Gerrit plugins that need to access some part of Gerrit to extend it
+
+Both may be built as a separate bundle, but would like to type check against
+the same types the Gerrit/gr-diff bundle uses. For this reason, this folder
+should contain only types, with the exception of enums, where having the
+value side is deemed an acceptable duplication.
+
+All types in here should use the `declare` keyword to prevent bundlers from
+renaming fields, which would break communication across separately built
+bundles. Again enums are the exception, because their keys are not referenced
+across bundles, and values will not be renamed by bundlers as they are strings.
+
+This API is used by other apps embedding gr-diff and any breaking changes
+should be discussed with the Gerrit core team and properly versioned.
+
+Gerrit types should either directly use or extend these types, so that
+breaking changes to the implementation require changes to these files.
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/api/admin.ts
similarity index 70%
rename from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
rename to polygerrit-ui/app/api/admin.ts
index ac59f4f..a7b549d 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/api/admin.ts
@@ -14,8 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+/** Interface for menu link */
+export interface MenuLink {
+  text: string;
+  url: string;
+  capability: string | null;
+}
+
+export interface AdminPluginApi {
+  addMenuLink(text: string, url: string, capability?: string): void;
+
+  getMenuLinks(): MenuLink[];
+}
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
new file mode 100644
index 0000000..bd4f399
--- /dev/null
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * 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.
+ */
+import {CoverageRange, Side} from './diff';
+
+/**
+ * This is the callback object that Gerrit calls once for each diff. Gerrit
+ * is then responsible for styling the diff according the returned array of
+ * CoverageRanges.
+ */
+export type CoverageProvider = (
+  changeNum: number,
+  path: string,
+  basePatchNum?: number,
+  patchNum?: number,
+  /**
+   * This is a ChangeInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
+   * At the moment we neither want to repeat it nor add a dependency on it here.
+   * TODO: Create a dedicated smaller object for exposing a change in the plugin
+   * API. Or allow the plugin API to depend on the entire rest API.
+   */
+  change?: unknown
+) => Promise<Array<CoverageRange>>;
+
+export interface AnnotationPluginApi {
+  /**
+   * The specified function will be called when a gr-diff component is built,
+   * and feeds the returned coverage data into the diff. Optional.
+   *
+   * Be sure to call this only once and only from one plugin. Multiple coverage
+   * providers are not supported. A second call will just overwrite the
+   * provider of the first call.
+   */
+  setCoverageProvider(coverageProvider: CoverageProvider): AnnotationPluginApi;
+
+  /**
+   * For plugins notifying Gerrit about new annotations being ready to be
+   * applied for a certain range. Gerrit will then re-render the relevant lines
+   * of the diff and call back to the layer annotation function that was
+   * registered in addLayer().
+   *
+   * @param path The file path whose listeners should be notified.
+   * @param start The line where the update starts.
+   * @param end The line where the update ends.
+   * @param side The side of the update ('left' or 'right').
+   */
+  notify(path: string, start: number, end: number, side: Side): void;
+}
diff --git a/polygerrit-ui/app/api/attribute-helper.ts b/polygerrit-ui/app/api/attribute-helper.ts
new file mode 100644
index 0000000..cd52259
--- /dev/null
+++ b/polygerrit-ui/app/api/attribute-helper.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * 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.
+ */
+
+export interface AttributeHelperPluginApi {
+  /**
+   * Binds callback to property updates.
+   *
+   * @param name Property name.
+   * @return Unbind function.
+   */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  bind(name: string, callback: (value: any) => void): () => any;
+
+  /**
+   * Get value of the property from wrapped object. Waits for the property
+   * to be initialized if it isn't defined.
+   */
+  get(name: string): Promise<unknown>;
+
+  /**
+   * Sets value and dispatches event to force notify.
+   */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  set(name: string, value: any): void;
+}
diff --git a/polygerrit-ui/app/api/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts
new file mode 100644
index 0000000..792f31e
--- /dev/null
+++ b/polygerrit-ui/app/api/change-actions.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * 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.
+ */
+import {HttpMethod} from './rest';
+
+export interface ActionInfo {
+  method?: HttpMethod;
+  label?: string;
+  title?: string;
+  enabled?: boolean;
+}
+
+export enum ActionType {
+  CHANGE = 'change',
+  REVISION = 'revision',
+}
+
+export enum ActionPriority {
+  CHANGE = 2,
+  DEFAULT = 0,
+  PRIMARY = 3,
+  REVIEW = -3,
+  REVISION = 1,
+}
+
+export enum ChangeActions {
+  ABANDON = 'abandon',
+  DELETE = '/',
+  DELETE_EDIT = 'deleteEdit',
+  EDIT = 'edit',
+  FOLLOW_UP = 'followup',
+  IGNORE = 'ignore',
+  MOVE = 'move',
+  PRIVATE = 'private',
+  PRIVATE_DELETE = 'private.delete',
+  PUBLISH_EDIT = 'publishEdit',
+  REBASE = 'rebase',
+  REBASE_EDIT = 'rebaseEdit',
+  READY = 'ready',
+  RESTORE = 'restore',
+  REVERT = 'revert',
+  REVERT_SUBMISSION = 'revert_submission',
+  REVIEWED = 'reviewed',
+  STOP_EDIT = 'stopEdit',
+  SUBMIT = 'submit',
+  UNIGNORE = 'unignore',
+  UNREVIEWED = 'unreviewed',
+  WIP = 'wip',
+}
+
+export enum RevisionActions {
+  CHERRYPICK = 'cherrypick',
+  REBASE = 'rebase',
+  SUBMIT = 'submit',
+  DOWNLOAD = 'download',
+}
+
+export type PrimaryActionKey = ChangeActions | RevisionActions;
+
+export interface ChangeActionsPluginApi {
+  addPrimaryActionKey(key: PrimaryActionKey): void;
+
+  removePrimaryActionKey(key: string): void;
+
+  hideQuickApproveAction(): void;
+
+  setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
+
+  setActionPriority(
+    type: ActionType,
+    key: string,
+    priority: ActionPriority
+  ): void;
+
+  setActionHidden(type: ActionType, key: string, hidden: boolean): void;
+
+  add(type: ActionType, label: string): string;
+
+  remove(key: string): void;
+
+  addTapListener(
+    key: string,
+    handler: EventListenerOrEventListenerObject
+  ): void;
+
+  removeTapListener(
+    key: string,
+    handler: EventListenerOrEventListenerObject
+  ): void;
+
+  setLabel(key: string, text: string): void;
+
+  setTitle(key: string, text: string): void;
+
+  setEnabled(key: string, enabled: boolean): void;
+
+  setIcon(key: string, icon: string): void;
+
+  getActionDetails(action: string): ActionInfo | undefined;
+}
diff --git a/polygerrit-ui/app/api/change-reply.ts b/polygerrit-ui/app/api/change-reply.ts
new file mode 100644
index 0000000..6016004
--- /dev/null
+++ b/polygerrit-ui/app/api/change-reply.ts
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * 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.
+ */
+export interface LabelsChangedDetail {
+  name: string;
+  value: string;
+}
+export interface ValueChangedDetail {
+  value: string;
+}
+export type ReplyChangedCallback = (text: string) => void;
+export type LabelsChangedCallback = (detail: LabelsChangedDetail) => void;
+
+export interface ChangeReplyPluginApi {
+  getLabelValue(label: string): string;
+
+  setLabelValue(label: string, value: string): void;
+
+  addReplyTextChangedCallback(handler: ReplyChangedCallback): void;
+
+  addLabelValuesChangedCallback(handler: LabelsChangedCallback): void;
+
+  showMessage(message: string): void;
+}
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
new file mode 100644
index 0000000..ee4ea9e
--- /dev/null
+++ b/polygerrit-ui/app/api/checks.ts
@@ -0,0 +1,430 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Settings
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface ChecksPluginApi {
+  /**
+   * Must only be called once. You cannot register twice. You cannot unregister.
+   */
+  register(provider: ChecksProvider, config?: ChecksApiConfig): void;
+
+  /**
+   * Forces Gerrit to call fetch() on the registered provider. Can be called
+   * when the provider has gotten an update and does not want wait for the next
+   * polling interval to pass.
+   */
+  announceUpdate(): void;
+}
+
+export interface ChecksApiConfig {
+  /**
+   * How often should the provider be called for new CheckData while the user
+   * navigates change related pages and the browser tab remains visible?
+   * Set to 0 to disable polling. Default is 60 seconds.
+   */
+  fetchPollingIntervalSeconds: number;
+}
+
+export interface ChangeData {
+  changeNumber: number;
+  patchsetNumber: number;
+  patchsetSha: string;
+  repo: string;
+  commmitMessage?: string;
+  /* TODO(brohlfs): Add dep to Rest API types and replace type by ChangeInfo. */
+  changeInfo: unknown;
+}
+
+export interface ChecksProvider {
+  /**
+   * Gerrit calls this method when ...
+   * - ... the change or diff page is loaded.
+   * - ... the user switches back to a Gerrit tab with a change or diff page.
+   * - ... while the tab is visible in a regular polling interval, see
+   *       ChecksApiConfig.
+   */
+  fetch(change: ChangeData): Promise<FetchResponse>;
+}
+
+export interface FetchResponse {
+  responseCode: ResponseCode;
+
+  /** Only relevant when the responseCode is ERROR. */
+  errorMessage?: string;
+
+  /**
+   * Only relevant when the responseCode is NOT_LOGGED_IN.
+   * Gerrit displays a "Login" button and calls this callback when the user
+   * clicks on the button.
+   */
+  loginCallback?: () => void;
+
+  /**
+   * Top-level actions that are not associated with a specific run or result.
+   * Will be shown as buttons in the header of the Checks tab.
+   */
+  actions?: Action[];
+
+  /**
+   * Top-level links that are not associated with a specific run or result.
+   * Will be shown as icons in the header of the Checks tab.
+   */
+  links?: Link[];
+
+  runs?: CheckRun[];
+}
+
+export enum ResponseCode {
+  OK = 'OK',
+  ERROR = 'ERROR',
+  NOT_LOGGED_IN = 'NOT_LOGGED_IN',
+}
+
+/**
+ * A CheckRun models an entity that has start/end timestamps and can be in
+ * either of the states RUNNABLE, RUNNING, COMPLETED. By itself it cannot model
+ * an error, neither can it be failed or successful by itself. A run can be
+ * associated with 0 to n results (see below). So until runs are completed the
+ * runs are more interesting for the user: What is going on at the moment? When
+ * runs are completed the users' interest shifts to results: What do I have to
+ * fix? The only actions that can be associated with runs are RUN and CANCEL.
+ */
+export interface CheckRun {
+  /**
+   * Gerrit requests check runs and results from the plugin by change number and
+   * patchset number. So these two properties can as well be left empty when
+   * returning results to the Gerrit UI and are thus optional.
+   */
+  change?: number;
+  /**
+   * Typically only runs for the latest patchset are requested and presented.
+   * Older runs and their results are only available on request, e.g. by
+   * switching to another patchset in a dropdown
+   *
+   * TBD: Check data providers may decide that runs and results are applicable
+   * to a newer patchset, even if they were produced for an older, e.g. because
+   * only the commit message was changed. Maybe that warrants the addition of
+   * another optional field, e.g. `original_patchset`.
+   */
+  patchset?: number;
+  /**
+   * The UI will focus on just the latest attempt per run. Former attempts are
+   * accessible, but initially collapsed/hidden. Lower number means older
+   * attempt. Every run has its own attempt numbering, so attempt 3 of run A is
+   * not directly related to attempt 3 of run B.
+   *
+   * RUNNABLE runs must use `undefined` as attempt.
+   * COMPLETED and RUNNING runs must use an attempt number >=0.
+   *
+   * TBD: Optionally providing aggregate information about former attempts will
+   * probably be a useful feature, but we are deferring the exact data modeling
+   * of that to later.
+   */
+  attempt?: number;
+
+  /**
+   * An optional opaque identifier not used by Gerrit directly, but might be
+   * used by plugin extensions and callbacks.
+   */
+  externalId?: string;
+
+  // The following 3 properties are independent of this run *instance*. They
+  // just describe what the check is about and will be identical for other
+  // attempts or patchsets or changes.
+
+  /**
+   * The unique name of the check. There can’t be two runs with the same
+   * change/patchset/attempt/checkName combination.
+   * Multiple attempts of the same run must have the same checkName.
+   * It should be expected that this string is cut off at ~30 chars in the UI.
+   * The full name will then be shown in a tooltip.
+   */
+  checkName: string;
+  /**
+   * Optional description of the check. Only shown as a tooltip or in a
+   * hovercard.
+   */
+  checkDescription?: string;
+  /**
+   * Optional http link to an external page with more detailed information about
+   * this run. Must begin with 'http'.
+   */
+  checkLink?: string;
+
+  /**
+   * RUNNABLE:  Not run (yet). Mostly useful for runs that the user can trigger
+   *            (see actions). Cannot contain results.
+   * RUNNING:   Subsumes "scheduled".
+   * COMPLETED: The attempt of the run has finished. Does not indicate at all
+   *            whether the run was successful or not. Outcomes can and should
+   *            be modeled using the CheckResult entity.
+   */
+  status: RunStatus;
+  /**
+   * Optional short description of the run status. This is a plain string
+   * without styling or formatting options. It will only be shown as a tooltip
+   * or in a hovercard.
+   *
+   * Examples:
+   * "40 tests running, 30 completed: 0 failing so far",
+   * "Scheduled 5 minutes ago, not running yet".
+   */
+  statusDescription?: string;
+  /**
+   * Optional http link to an external page with more detailed information about
+   * the run status. Must begin with 'http'.
+   */
+  statusLink?: string;
+
+  /**
+   * Optional reference to a Gerrit label (e.g. "Verified") that this result
+   * influences. Allows the user to understand and navigate the relationship
+   * between check runs/results and submit requirements,
+   * see also https://gerrit-review.googlesource.com/c/homepage/+/279176.
+   */
+  labelName?: string;
+
+  /**
+   * Optional callbacks to the plugin. Must be implemented individually by
+   * each plugin. The most important actions (which get special UI treatment)
+   * are:
+   * "Run" for RUNNABLE and COMPLETED runs.
+   * "Cancel" for RUNNING runs.
+   */
+  actions?: Action[];
+
+  scheduledTimestamp?: Date;
+  startedTimestamp?: Date;
+  finishedTimestamp?: Date;
+
+  /**
+   * List of results produced by this run.
+   * RUNNABLE runs must not have results.
+   * RUNNING runs can contain (intermediate) results.
+   * Nesting the results in runs enforces that:
+   * - A run can have 0-n results.
+   * - A result is associated with exactly one run.
+   */
+  results?: CheckResult[];
+}
+
+export interface Action {
+  name: string;
+  tooltip?: string;
+  /**
+   * TODO: Maybe drop this property? Do we really need it?
+   *
+   * Primary actions will get a more prominent treatment in the UI. For example
+   * primary actions might be rendered as buttons versus just menu entries in
+   * an overflow menu.
+   */
+  primary: boolean;
+  callback: ActionCallback;
+}
+
+export type ActionCallback = (
+  change: number,
+  patchset: number,
+  /**
+   * Identical to 'attempt' property of CheckRun. Not set for top-level
+   * actions.
+   */
+  attempt: number | undefined,
+  /**
+   * Identical to 'externalId' property of CheckRun. Not set for top-level
+   * actions.
+   */
+  externalId: string | undefined,
+  /**
+   * Identical to 'checkName' property of CheckRun. Not set for top-level
+   * actions.
+   */
+  checkName: string | undefined,
+  /** Identical to 'name' property of Action entity. */
+  actionName: string
+  /**
+   * If the callback does not return a promise, then the user will get no
+   * feedback from the Gerrit UI. This is adequate when the plugin opens a
+   * dialog for example. If a Promise<ActionResult> is returned, then Gerrit
+   * will show toasts for user feedback, see ActionResult below.
+   */
+) => Promise<ActionResult> | undefined;
+
+/**
+ * Until the Promise<ActionResult> resolves (max. 5 seconds) Gerrit will show a
+ * toast with the message `Triggering action '${action.name}' ...`.
+ *
+ * When the promise resolves (within 5 seconds) then Gerrit will replace the
+ * toast with a new one with the message `${message}` and show it for 5 seconds.
+ * If `message` is empty or undefined, then the `Triggering ...` toast will just
+ * be hidden and no further toast will be shown.
+ */
+export interface ActionResult {
+  /** An empty errorMessage means success. */
+  message?: string;
+  /**
+   * If true, then ChecksProvider.fetch() is called. Has the same affect as if
+   * the plugin would call announceUpdate(). So just for convenience.
+   */
+  shouldReload?: boolean;
+  /** DEPRECATED: Use `message` instead. */
+  errorMessage?: string;
+}
+
+export enum RunStatus {
+  RUNNABLE = 'RUNNABLE',
+  RUNNING = 'RUNNING',
+  COMPLETED = 'COMPLETED',
+}
+
+export interface CheckResult {
+  /**
+   * An optional opaque identifier not used by Gerrit directly, but might be
+   * used by plugin extensions and callbacks.
+   */
+  externalId?: string;
+
+  /**
+   * INFO:    The user will typically not bother to look into this category,
+   *          only for looking up something that they are searching for. Can be
+   *          used for reporting secondary metrics and analysis, or a wider
+   *          range of artifacts produced by the checks system.
+   * WARNING: A warning is something that should be read before submitting the
+   *          change. The user should not ignore it, but it is also not blocking
+   *          submit. It has a similar level of importance as an unresolved
+   *          comment.
+   * ERROR:   An error indicates that the change must not or cannot be submitted
+   *          without fixing the problem. Errors will be visualized very
+   *          prominently to the user.
+   *
+   * The ‘tags’ field below can be used for further categorization, e.g. for
+   * distinguishing FAILED vs TIMED_OUT.
+   */
+  category: Category;
+
+  /**
+   * Short description of the check result.
+   *
+   * It should be expected that this string might be cut off at ~80 chars in the
+   * UI. The full description will then be shown in a tooltip.
+   * This is a plain string without styling or formatting options.
+   *
+   * Examples:
+   * MessageConverterTest failed with: 'kermit' expected, but got 'ernie'.
+   * Binary size of javascript bundle has increased by 27%.
+   */
+  summary: string;
+
+  /**
+   * Exhaustive optional message describing the check result.
+   * Will be initially collapsed. Might potentially be very long, e.g. a log of
+   * MB size. The UI is not limiting this. Data providing plugins are
+   * responsible for not killing the browser. :-)
+   *
+   * For now this is just a plain unformatted string. The only formatting
+   * applied is the one that Gerrit also applies to human comments. TBD: Both
+   * human comments and check result messages should get richer formatting
+   * options.
+   */
+  message?: string;
+
+  /**
+   * Tags allow a plugins to further categorize a result, e.g. making a list
+   * of results filterable by the end-user.
+   * The name is free-form, but there is a predefined set of TagColors to
+   * choose from with a recommendation of color for common tags, see below.
+   *
+   * Examples:
+   * PASS, FAIL, SCHEDULED, OBSOLETE, SKIPPED, TIMED_OUT, INFRA_ERROR, FLAKY
+   * WIN, MAC, LINUX
+   * BUILD, TEST, LINT
+   * INTEGRATION, E2E, SCREENSHOT
+   */
+  tags?: Tag[];
+
+  /**
+   * Links provide an opportunity for the end-user to easily access details and
+   * artifacts. Links are displayed by an icon+tooltip only. They don’t have a
+   * name, making them clearly distinguishable from tags and actions.
+   *
+   * There is a fixed set of LinkIcons to choose from, see below.
+   *
+   * Examples:
+   * Link to test log.
+   * Link to result artifacts such as images and screenshots.
+   * Link to downloadable artifacts such as ZIP or APK files.
+   */
+  links?: Link[];
+
+  /**
+   * Callbacks to the plugin. Must be implemented individually by each
+   * plugin. Actions are rendered as buttons. If there are more than two actions
+   * per result, then further actions are put into an overflow menu. Sort order
+   * is defined by the data provider.
+   *
+   * Examples:
+   * Acknowledge/Dismiss, Delete, Report a bug, Report as not useful,
+   * Make blocking, Downgrade severity.
+   */
+  actions?: Action[];
+}
+
+export enum Category {
+  INFO = 'INFO',
+  WARNING = 'WARNING',
+  ERROR = 'ERROR',
+}
+
+export interface Tag {
+  name: string;
+  tooltip?: string;
+  color?: TagColor;
+}
+
+// TBD: Clarify standard colors for common tags.
+export enum TagColor {
+  GRAY = 'gray',
+  YELLOW = 'yellow',
+  PINK = 'pink',
+  PURPLE = 'purple',
+  CYAN = 'cyan',
+  BROWN = 'brown',
+}
+
+export interface Link {
+  /** Must begin with 'http'. */
+  url: string;
+  tooltip?: string;
+  /**
+   * TODO: Maybe drop this property? Do we really need it?
+   *
+   * Primary links will get a more prominent treatment in the UI, e.g. being
+   * always visible in the results table or also showing up in the change page
+   * summary of checks.
+   */
+  primary: boolean;
+  icon: LinkIcon;
+}
+
+export enum LinkIcon {
+  EXTERNAL = 'external',
+  IMAGE = 'image',
+  HISTORY = 'history',
+  DOWNLOAD = 'download',
+  DOWNLOAD_MOBILE = 'download_mobile',
+  HELP_PAGE = 'help_page',
+  REPORT_BUG = 'report_bug',
+}
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
new file mode 100644
index 0000000..5820139
--- /dev/null
+++ b/polygerrit-ui/app/api/core.ts
@@ -0,0 +1,47 @@
+/**
+ * @fileoverview Core API types for Gerrit.
+ *
+ * Core types are types used in many places in Gerrit, such as the Side enum.
+ *
+ * @license
+ * 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.
+ */
+
+/**
+ * The CommentRange entity describes the range of an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
+ *
+ * The range includes all characters from the start position, specified by
+ * start_line and start_character, to the end position, specified by end_line
+ * and end_character. The start position is inclusive and the end position is
+ * exclusive.
+ *
+ * So, a range over part of a line will have start_line equal to end_line;
+ * however a range with end_line set to 5 and end_character equal to 0 will not
+ * include any characters on line 5.
+ */
+export declare interface CommentRange {
+  /** The start line number of the range. (1-based) */
+  start_line: number;
+
+  /** The character position in the start line. (0-based) */
+  start_character: number;
+
+  /** The end line number of the range. (1-based) */
+  end_line: number;
+
+  /** The character position in the end line. (0-based) */
+  end_character: number;
+}
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
new file mode 100644
index 0000000..9cd8cb3
--- /dev/null
+++ b/polygerrit-ui/app/api/diff.ts
@@ -0,0 +1,290 @@
+/**
+ * @fileoverview The API of Gerrit's diff viewer, gr-diff.
+ *
+ * This includes some types which are also defined as part of Gerrit's JSON API
+ * which are used as inputs to gr-diff.
+ *
+ * @license
+ * 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.
+ */
+
+import {CommentRange} from './core';
+
+/**
+ * Diff type in preferences
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum DiffViewMode {
+  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
+  UNIFIED = 'UNIFIED_DIFF',
+}
+
+/**
+ * The DiffInfo entity contains information about the diff of a file in a
+ * revision.
+ *
+ * If the weblinks-only parameter is specified, only the web_links field is set.
+ */
+export declare interface DiffInfo {
+  /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
+  meta_a: DiffFileMetaInfo;
+  /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
+  meta_b: DiffFileMetaInfo;
+  /** The type of change (ADDED, MODIFIED, DELETED, RENAMED COPIED, REWRITE). */
+  change_type: ChangeType;
+  /** Intraline status (OK, ERROR, TIMEOUT). */
+  intraline_status: 'OK' | 'Error' | 'Timeout';
+  /** The content differences in the file as a list of DiffContent entities. */
+  content: DiffContent[];
+  /** Whether the file is binary. */
+  binary?: boolean;
+}
+
+/**
+ * The DiffFileMetaInfo entity contains meta information about a file diff.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-file-meta-info
+ */
+export declare interface DiffFileMetaInfo {
+  /** The name of the file. */
+  name: string;
+  /** The content type of the file. */
+  content_type: string;
+  /** The total number of lines in the file. */
+  lines: number;
+  // TODO: Not documented.
+  language?: string;
+}
+
+export declare type ChangeType =
+  | 'ADDED'
+  | 'MODIFIED'
+  | 'DELETED'
+  | 'RENAMED'
+  | 'COPIED'
+  | 'REWRITE';
+
+/**
+ * The DiffContent entity contains information about the content differences in
+ * a file.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-content
+ */
+export declare interface DiffContent {
+  /** Content only in the file on side A (deleted in B). */
+  a?: string[];
+  /** Content only in the file on side B (added in B). */
+  b?: string[];
+  /** Content in the file on both sides (unchanged). */
+  ab?: string[];
+  /**
+   * Text sections deleted from side A as a DiffIntralineInfo entity.
+   *
+   * Only present during a replace, i.e. both a and b are present.
+   */
+  edit_a?: DiffIntralineInfo[];
+  /**
+   * Text sections inserted in side B as a DiffIntralineInfo entity.
+   *
+   * Only present during a replace, i.e. both a and b are present.
+   */
+  edit_b?: DiffIntralineInfo[];
+  /** Indicates whether this entry was introduced by a rebase. */
+  due_to_rebase?: boolean;
+
+  /**
+   * Provides info about a move operation the chunk.
+   * It's presence indicates the current chunk exists due to a move.
+   */
+  move_details?: MoveDetails;
+  /**
+   * Count of lines skipped on both sides when the file is too large to include
+   * all common lines.
+   */
+  skip?: number;
+  /**
+   * Set to true if the region is common according to the requested
+   * ignore-whitespace parameter, but a and b contain differing amounts of
+   * whitespace. When present and true a and b are used instead of ab.
+   */
+  common?: boolean;
+}
+
+/**
+ * Details about move operation related to a specific chunk.
+ */
+export declare interface MoveDetails {
+  /** Indicates whether the content of the chunk changes while moving code */
+  changed: boolean;
+  /**
+   * Indicates the range (line numbers) on the other side of the comparison
+   * where the code related to the current chunk came from/went to.
+   */
+  range: {
+    start: number;
+    end: number;
+  };
+}
+
+/**
+ * The DiffIntralineInfo entity contains information about intraline edits in a
+ * file.
+ *
+ * The information consists of a list of <skip length, mark length> pairs, where
+ * the skip length is the number of characters between the end of the previous
+ * edit and the start of this edit, and the mark length is the number of edited
+ * characters following the skip. The start of the edits is from the beginning
+ * of the related diff content lines.
+ *
+ * Note that the implied newline character at the end of each line is included
+ * in the length calculation, and thus it is possible for the edits to span
+ * newlines.
+ */
+export declare type SkipLength = number;
+export declare type MarkLength = number;
+export declare type DiffIntralineInfo = [SkipLength, MarkLength];
+
+/**
+ * The DiffPreferencesInfo entity contains information about the diff
+ * preferences of a user.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-info
+ */
+export declare interface DiffPreferencesInfo {
+  context: number;
+  ignore_whitespace: IgnoreWhitespaceType;
+  line_length: number;
+  show_line_endings?: boolean;
+  show_tabs?: boolean;
+  show_whitespace_errors?: boolean;
+  syntax_highlighting?: boolean;
+  tab_size: number;
+  font_size: number;
+  // TODO: Missing documentation
+  show_file_comment_button?: boolean;
+}
+
+export declare interface RenderPreferences {
+  hide_left_side?: boolean;
+  disable_context_control_buttons?: boolean;
+  show_file_comment_button?: boolean;
+  hide_line_length_indicator?: boolean;
+}
+
+/**
+ * Whether whitespace changes should be ignored and if yes, which whitespace
+ * changes should be ignored
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-input
+ */
+export declare type IgnoreWhitespaceType =
+  | 'IGNORE_NONE'
+  | 'IGNORE_TRAILING'
+  | 'IGNORE_LEADING_AND_TRAILING'
+  | 'IGNORE_ALL';
+
+export enum Side {
+  LEFT = 'left',
+  RIGHT = 'right',
+}
+
+export enum CoverageType {
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  COVERED = 'COVERED',
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  NOT_COVERED = 'NOT_COVERED',
+  PARTIALLY_COVERED = 'PARTIALLY_COVERED',
+  /**
+   * You don't have to use this. If there is no coverage information for a
+   * range, then it implicitly means NOT_INSTRUMENTED. start_character and
+   * end_character of the range will be ignored for this type.
+   */
+  NOT_INSTRUMENTED = 'NOT_INSTRUMENTED',
+}
+
+export declare interface LineRange {
+  start_line: number;
+  end_line: number;
+}
+
+export declare interface CoverageRange {
+  type: CoverageType;
+  side: Side;
+  code_range: LineRange;
+}
+
+/** LOST LineNumber is for ported comments without a range, they have their own
+ *  line number and are added on top of the FILE row in gr-diff
+ */
+export declare type LineNumber = number | 'FILE' | 'LOST';
+
+/** The detail of the 'create-comment' event dispatched by gr-diff. */
+export declare interface CreateCommentEventDetail {
+  side: Side;
+  lineNum: LineNumber;
+  range: CommentRange | undefined;
+}
+
+export declare interface ContentLoadNeededEventDetail {
+  lineRange: {
+    left: LineRange;
+    right: LineRange;
+  };
+}
+
+export declare interface MovedLinkClickedEventDetail {
+  side: Side;
+  lineNum: LineNumber;
+}
+
+export enum GrDiffLineType {
+  ADD = 'add',
+  BOTH = 'both',
+  BLANK = 'blank',
+  REMOVE = 'remove',
+}
+
+/** Describes a line to be rendered in a diff. */
+export declare interface GrDiffLine {
+  readonly type: GrDiffLineType;
+  /** The line number on the left side of the diff - 0 means none.  */
+  beforeNumber: LineNumber;
+  /** The line number on the right side of the diff - 0 means none.  */
+  afterNumber: LineNumber;
+}
+
+/**
+ * Interface to implemented to define a new layer in the diff.
+ *
+ * Layers can affect how the text of the diff or its line numbers
+ * are rendered.
+ */
+export declare interface DiffLayer {
+  /**
+   * Called during rendering and allows annotating the diff text or line number
+   * by mutating those elements.
+   *
+   * @param textElement The rendered text of one side of the diff.
+   * @param lineNumberElement The rendered line number of one side of the diff.
+   * @param line Describes the line that should be annotated.
+   */
+  annotate(
+    textElement: HTMLElement,
+    lineNumberElement: HTMLElement,
+    line: GrDiffLine
+  ): void;
+}
diff --git a/polygerrit-ui/app/styles/themes/dark-theme_test.js b/polygerrit-ui/app/api/event-helper.ts
similarity index 61%
copy from polygerrit-ui/app/styles/themes/dark-theme_test.js
copy to polygerrit-ui/app/api/event-helper.ts
index 4f6466f..16c327d 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme_test.js
+++ b/polygerrit-ui/app/api/event-helper.ts
@@ -14,15 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+export type UnsubscribeCallback = () => void;
 
-import '../../test/common-test-setup-karma.js';
-import {applyTheme, removeTheme} from './dark-theme.js';
+export interface EventHelperPluginApi {
+  /**
+   * Alias for @see onClick
+   */
+  onTap(callback: (event: Event) => boolean): UnsubscribeCallback;
 
-suite('dark-theme_test.js', () => {
-  test('apply and remove theme', () => {
-    applyTheme();
-    assert.equal(document.head.querySelectorAll('#dark-theme').length, 1);
-    removeTheme();
-    assert.equal(document.head.querySelectorAll('#dark-theme').length, 0);
-  });
-});
+  /**
+   * Add a callback to element click or touch.
+   * The callback may return false to prevent event bubbling.
+   */
+  onClick(callback: (event: Event) => boolean): UnsubscribeCallback;
+}
diff --git a/polygerrit-ui/app/api/hook.ts b/polygerrit-ui/app/api/hook.ts
new file mode 100644
index 0000000..179b967
--- /dev/null
+++ b/polygerrit-ui/app/api/hook.ts
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * 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.
+ */
+interface GerritElementExtensions {
+  content?: HTMLElement & {hidden?: boolean};
+  change?: unknown;
+  revision?: unknown;
+  token?: string;
+  repoName?: string;
+  /**
+   * This is a ConfigInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
+   * We neither want to repeat it nor add a dependency on it here.
+   */
+  config?: unknown;
+}
+
+export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
+
+export interface RegisterOptions {
+  slot?: string;
+  replace: unknown;
+}
+
+export interface HookApi {
+  onAttached(callback: HookCallback): HookApi;
+
+  onDetached(callback: HookCallback): HookApi;
+
+  getAllAttached(): HTMLElement[];
+
+  getLastAttached(): Promise<HTMLElement>;
+
+  getModuleName(): string;
+
+  handleInstanceDetached(instance: HTMLElement): void;
+
+  handleInstanceAttached(instance: HTMLElement): void;
+}
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
new file mode 100644
index 0000000..0aadb38
--- /dev/null
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * 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.
+ */
+import {AdminPluginApi} from './admin';
+import {AnnotationPluginApi} from './annotation';
+import {AttributeHelperPluginApi} from './attribute-helper';
+import {ChangeReplyPluginApi} from './change-reply';
+import {ChecksPluginApi} from './checks';
+import {EventHelperPluginApi} from './event-helper';
+import {PopupPluginApi} from './popup';
+import {ReportingPluginApi} from './reporting';
+import {ChangeActionsPluginApi} from './change-actions';
+import {RestPluginApi} from './rest';
+import {HookApi, RegisterOptions} from './hook';
+
+export enum TargetElement {
+  CHANGE_ACTIONS = 'changeactions',
+  REPLY_DIALOG = 'replydialog',
+}
+
+// Note: for new events, naming convention should be: `a-b`
+export enum EventType {
+  HISTORY = 'history',
+  LABEL_CHANGE = 'labelchange',
+  SHOW_CHANGE = 'showchange',
+  SUBMIT_CHANGE = 'submitchange',
+  SHOW_REVISION_ACTIONS = 'show-revision-actions',
+  COMMIT_MSG_EDIT = 'commitmsgedit',
+  COMMENT = 'comment',
+  REVERT = 'revert',
+  REVERT_SUBMISSION = 'revert_submission',
+  POST_REVERT = 'postrevert',
+  ANNOTATE_DIFF = 'annotatediff',
+  ADMIN_MENU_LINKS = 'admin-menu-links',
+  HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
+}
+
+export interface PluginApi {
+  _url?: URL;
+  admin(): AdminPluginApi;
+  annotationApi(): AnnotationPluginApi;
+  attributeHelper(element: Element): AttributeHelperPluginApi;
+  changeActions(): ChangeActionsPluginApi;
+  changeReply(): ChangeReplyPluginApi;
+  checks(): ChecksPluginApi;
+  eventHelper(element: Node): EventHelperPluginApi;
+  getPluginName(): string;
+  hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  on(eventName: EventType, target: any): void;
+  popup(): Promise<PopupPluginApi>;
+  popup(moduleName: string): Promise<PopupPluginApi>;
+  popup(moduleName?: string): Promise<PopupPluginApi | null>;
+  registerCustomComponent(
+    endpointName: string,
+    moduleName?: string,
+    options?: RegisterOptions
+  ): HookApi;
+  registerDynamicCustomComponent(
+    endpointName: string,
+    moduleName?: string,
+    options?: RegisterOptions
+  ): HookApi;
+  registerStyleModule(endpoint: string, moduleName: string): void;
+  reporting(): ReportingPluginApi;
+  restApi(): RestPluginApi;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  screen(screenName: string, moduleName?: string): any;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/api/popup.ts
similarity index 65%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/api/popup.ts
index ac59f4f..60772cc 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/api/popup.ts
@@ -14,8 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+export interface PopupPluginApi {
+  /**
+   * Opens the popup, inserts it into DOM over current UI.
+   * Creates the popup if not previously created. Creates popup content element,
+   * if it was provided with constructor.
+   */
+  open(): Promise<PopupPluginApi>;
+
+  /**
+   * Hides the popup.
+   */
+  close(): void;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/api/reporting.ts
similarity index 69%
copy from polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
copy to polygerrit-ui/app/api/reporting.ts
index ac59f4f..65bdc3f 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
+++ b/polygerrit-ui/app/api/reporting.ts
@@ -14,8 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type EventDetails = any;
+
+export interface ReportingPluginApi {
+  reportInteraction(eventName: string, details?: EventDetails): void;
+
+  reportLifeCycle(eventName: string, details?: EventDetails): void;
+}
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
new file mode 100644
index 0000000..fd9cada
--- /dev/null
+++ b/polygerrit-ui/app/api/rest.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * 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.
+ */
+export type RequestPayload = string | object;
+
+export enum HttpMethod {
+  HEAD = 'HEAD',
+  POST = 'POST',
+  GET = 'GET',
+  DELETE = 'DELETE',
+  PUT = 'PUT',
+}
+
+export type ErrorCallback = (response?: Response | null, err?: Error) => void;
+
+export interface RestPluginApi {
+  getLoggedIn(): Promise<boolean>;
+
+  getVersion(): Promise<string | undefined>;
+
+  /**
+   * Returns a ServerInfo object as defined here:
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#server-info
+   * We neither want to repeat it nor add a dependency on it here.
+   */
+  getConfig(): Promise<unknown>;
+
+  invalidateReposCache(): void;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: undefined,
+    contentType?: string
+  ): Promise<Response>;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload: RequestPayload | undefined,
+    errFn: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload: RequestPayload | undefined,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  /**
+   * Fetch and return native browser REST API Response.
+   */
+  fetch(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<Response | void>;
+
+  /**
+   * Fetch and parse REST API response, if request succeeds.
+   */
+  send(
+    method: HttpMethod,
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<unknown>;
+
+  get(url: string): Promise<unknown>;
+
+  post(
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<unknown>;
+
+  put(
+    url: string,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string
+  ): Promise<unknown>;
+
+  delete(url: string): Promise<Response>;
+}
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index b64a7d1..35a746f 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -18,13 +18,18 @@
 /**
  * @desc Tab names for primary tabs on change view page.
  */
+import {DiffViewMode} from '../api/diff';
+import {DiffPreferencesInfo} from '../types/diff';
+import {EditPreferencesInfo, PreferencesInfo} from '../types/common';
+
 export enum PrimaryTab {
   FILES = 'files',
   /**
-   * When renaming this, the links in UrlFormatter must be updated.
+   * When renaming 'comments' or 'findings', UrlFormatter.java must be updated.
    */
   COMMENT_THREADS = 'comments',
   FINDINGS = 'findings',
+  CHECKS = 'checks',
 }
 
 /**
@@ -48,6 +53,7 @@
   TAG_SET_WIP = 'autogenerated:gerrit:setWorkInProgress',
   TAG_SET_ASSIGNEE = 'autogenerated:gerrit:setAssignee',
   TAG_UNSET_ASSIGNEE = 'autogenerated:gerrit:deleteAssignee',
+  TAG_MERGED = 'autogenerated:gerrit:merged',
 }
 
 /**
@@ -156,10 +162,7 @@
   HIDDEN = 'HIDDEN',
 }
 
-export enum Side {
-  LEFT = 'left',
-  RIGHT = 'right',
-}
+export {Side} from '../api/diff';
 
 /**
  * The type in ConfigParameterInfo entity.
@@ -208,7 +211,7 @@
 export enum InheritedBooleanInfoConfiguredValue {
   TRUE = 'TRUE',
   FALSE = 'FALSE',
-  INHERITED = 'INHERITED',
+  INHERIT = 'INHERIT',
 }
 
 export enum AccountTag {
@@ -287,14 +290,7 @@
   HHMM_24 = 'HHMM_24',
 }
 
-/**
- * Diff type in preferences
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
- */
-export enum DiffViewMode {
-  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
-  UNIFIED = 'UNIFIED_DIFF',
-}
+export {DiffViewMode};
 
 /**
  * The type of email strategy to use.
@@ -327,17 +323,6 @@
 }
 
 /**
- * Whether whitespace changes should be ignored and if yes, which whitespace changes should be ignored
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-input
- */
-export enum IgnoreWhitespaceType {
-  IGNORE_NONE = 'IGNORE_NONE',
-  IGNORE_TRAILING = 'IGNORE_TRAILING',
-  IGNORE_LEADING_AND_TRAILING = 'IGNORE_LEADING_AND_TRAILING',
-  IGNORE_ALL = 'IGNORE_ALL',
-}
-
-/**
  * how draft comments are handled
  */
 export enum DraftsAction {
@@ -400,3 +385,57 @@
   REF_UPDATED_AND_CHANGE_REINDEX = 'REF_UPDATED_AND_CHANGE_REINDEX',
   NEVER = 'NEVER',
 }
+
+// TODO(TS): Many properties are omitted here, but they are required.
+// Add default values for missing properties.
+export function createDefaultPreferences() {
+  return {
+    changes_per_page: 25,
+    default_diff_view: DiffViewMode.SIDE_BY_SIDE,
+    diff_view: DiffViewMode.SIDE_BY_SIDE,
+    size_bar_in_change_table: true,
+  } as PreferencesInfo;
+}
+
+// These defaults should match the defaults in
+// java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+// NOTE: There are some settings that don't apply to PolyGerrit
+// (Render mode being at least one of them).
+export function createDefaultDiffPrefs(): DiffPreferencesInfo {
+  return {
+    context: 10,
+    cursor_blink_rate: 0,
+    font_size: 12,
+    ignore_whitespace: 'IGNORE_NONE',
+    line_length: 100,
+    line_wrapping: false,
+    show_line_endings: true,
+    show_tabs: true,
+    show_whitespace_errors: true,
+    syntax_highlighting: true,
+    tab_size: 8,
+  };
+}
+
+// These defaults should match the defaults in
+// java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+export function createDefaultEditPrefs(): EditPreferencesInfo {
+  return {
+    auto_close_brackets: false,
+    cursor_blink_rate: 0,
+    hide_line_numbers: false,
+    hide_top_menu: false,
+    indent_unit: 2,
+    indent_with_tabs: false,
+    key_map_type: 'DEFAULT',
+    line_length: 100,
+    line_wrapping: false,
+    match_brackets: true,
+    show_base: false,
+    show_tabs: true,
+    show_whitespace_errors: true,
+    syntax_highlighting: true,
+    tab_size: 8,
+    theme: 'DEFAULT',
+  };
+}
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
new file mode 100644
index 0000000..0738825
--- /dev/null
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * 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.
+ */
+
+export enum LifeCycle {
+  PLUGIN_LIFE_CYCLE = 'Plugin life cycle',
+  STARTED_AS_USER = 'Started as user',
+  STARTED_AS_GUEST = 'Started as guest',
+  VISIBILILITY_HIDDEN = 'Visibility changed to hidden',
+  VISIBILILITY_VISIBLE = 'Visibility changed to visible',
+  EXTENSION_DETECTED = 'Extension detected',
+  PLUGINS_INSTALLED = 'Plugins installed',
+  USER_REFERRED_FROM = 'User referred from',
+}
+
+export enum Execution {
+  PLUGIN_API = 'plugin-api',
+  REACHABLE_CODE = 'reachable code',
+  METHOD_USED = 'method used',
+}
+
+export enum Timing {
+  // Time between the navigationStart timing and the ready call of gr-app.
+  APP_STARTED = 'App Started',
+  // Time from navigation to showing first content of change view.
+  CHANGE_DISPLAYED = 'ChangeDisplayed',
+  // Time from navigation to having loaded and presented all change data.
+  CHANGE_LOAD_FULL = 'ChangeFullyLoaded',
+  // Time from navigation to showing content of dashboard.
+  DASHBOARD_DISPLAYED = 'DashboardDisplayed',
+  // Time from navigation to showing full content of diff without highlighting layer
+  DIFF_VIEW_CONTENT_DISPLAYED = 'DiffViewOnlyContent',
+  // Time from navigation to showing viewport (> 120 lines) of diff with highlighting layer.
+  DIFF_VIEW_DISPLAYED = 'DiffViewDisplayed',
+  // Time from navigation to showing full content of diff
+  DIFF_VIEW_LOAD_FULL = 'DiffViewFullyLoaded',
+  // Time from navigation to showing initial content of the file list.
+  FILE_LIST_DISPLAYED = 'FileListDisplayed',
+  // Time from startup to having loaded all plugins.
+  PLUGINS_LOADED = 'PluginsLoaded',
+  // Time from startup to having loaded metrics plugin
+  METRICS_PLUGIN_LOADED = 'MetricsPluginLoaded',
+  // Time from startup to showing first content of change view.
+  STARTUP_CHANGE_DISPLAYED = 'StartupChangeDisplayed',
+  // Time from startup to having loaded and presented all change data.
+  STARTUP_CHANGE_LOAD_FULL = 'StartupChangeFullyLoaded',
+  // Time from startup to showing content of the dashboard.
+  STARTUP_DASHBOARD_DISPLAYED = 'StartupDashboardDisplayed',
+  // Time from startup to showing full content of diff without highlighting layer
+  STARTUP_DIFF_VIEW_CONTENT_DISPLAYED = 'StartupDiffViewOnlyContent',
+  // Time from startup to showing viewport (> 120 lines) of diff with highlighting layer.
+  STARTUP_DIFF_VIEW_DISPLAYED = 'StartupDiffViewDisplayed',
+  // Time from startup to showing full content of diff view.
+  STARTUP_DIFF_VIEW_LOAD_FULL = 'StartupDiffViewFullyLoaded',
+  // Time from startup to showing initial content of the file list.
+  STARTUP_FILE_LIST_DISPLAYED = 'StartupFileListDisplayed',
+  // Time from startup to when the webcomponentsready event is fired. If the event is fired from the webcomponents-lite polyfill, this may be arbitrarily long after the app has started.
+  WEB_COMPONENTS_READY = 'WebComponentsReady',
+  // Time to received all data for change view
+  CHANGE_DATA = 'ChangeDataLoaded',
+  // Time to compute and render first content of change view
+  CHANGE_RELOAD = 'ChangeReloaded',
+  // Time from clicking the [Send] button of the Reply Dialog to the time that the change has reloaded (core data)
+  SEND_REPLY = 'SendReply',
+  // The overall time to render a diff (excluding loading of data).
+  DIFF_TOTAL = 'Diff Total Render',
+  // The time to render the content off a diff (excluding loading of data or syntax highlighting).
+  DIFF_CONTENT = 'Diff Content Render',
+  // Time to compute and render the syntax highlighting of a diff.
+  DIFF_SYNTAX = 'Diff Syntax Render',
+  // Time to render a batch of rows in the file list. If there are very many files, this may be the first batch of rows that are rendered by default. If there are many files and the user clicks [Show More], this may be the batch of additional files that appear as a result.
+  FILE_RENDER = 'FileListRenderTime',
+  // This measures the same interval as FileListRenderTime, but the result is divided by the number of rows in the batch.
+  FILE_RENDER_AVG = 'FileListRenderTimePerFile',
+  // The time to expand some number of diffs in the file list (i.e. render their diffs, including syntax highlighting).
+  FILE_EXPAND_ALL = 'ExpandAllDiffs',
+  // This measures the same interval as ExpandAllDiffs, but the result is divided by the number of diffs expanded.
+  FILE_EXPAND_ALL_AVG = 'ExpandAllPerDiff',
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index c52a04c..21a0f0d 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -19,11 +19,8 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-permission/gr-permission';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {htmlTemplate} from './gr-access-section_html';
 import {
   AccessPermissions,
@@ -44,6 +41,7 @@
   RepoName,
 } from '../../../types/common';
 import {PolymerDomRepeatEvent} from '../../../types/types';
+import {fireEvent} from '../../../utils/event-util';
 
 /**
  * Fired when the section has been modified or removed.
@@ -72,9 +70,7 @@
 }
 
 @customElement('gr-access-section')
-export class GrAccessSection extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAccessSection extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -115,15 +111,14 @@
   @property({type: Array})
   _permissions?: PermissionArray<EditablePermissionInfo>;
 
-  /** @override */
-  created() {
-    super.created();
+  constructor() {
+    super();
     this.addEventListener('access-saved', () => this._handleAccessSaved());
   }
 
   _updateSection(section: PermissionAccessSection) {
     this._permissions = toSortedPermissionsArray(section.value.permissions);
-    this._originalId = section.id as GitRef;
+    this._originalId = section.id;
   }
 
   _handleAccessSaved() {
@@ -145,9 +140,7 @@
       // For a new section, this is not fired because new permissions and
       // rules have to be added in order to save, modifying the ref is not
       // enough.
-      this.dispatchEvent(
-        new CustomEvent('access-modified', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'access-modified');
     }
     this.section.value.updatedId = this.section.id;
   }
@@ -180,7 +173,9 @@
   _computePermissions(
     name: string,
     capabilities?: CapabilityInfoMap,
-    labels?: LabelNameToLabelTypeInfoMap
+    labels?: LabelNameToLabelTypeInfoMap,
+    // This is just for triggering re-computation. We don't use the value.
+    _?: unknown
   ) {
     let allPermissions;
     const section = this.section;
@@ -241,12 +236,12 @@
   _computePermissionName(
     name: string,
     permission: PermissionArrayItem<EditablePermissionInfo>,
-    capabilities: CapabilityInfoMap
-  ) {
+    capabilities?: CapabilityInfoMap
+  ): string | undefined {
     if (name === GLOBAL_NAME) {
-      return capabilities[permission.id].name;
+      return capabilities?.[permission.id]?.name;
     } else if (AccessPermissions[permission.id]) {
-      return AccessPermissions[permission.id].name;
+      return AccessPermissions[permission.id]?.name;
     } else if (permission.value.label) {
       let behalfOf = '';
       if (permission.id.startsWith('labelAs-')) {
@@ -280,18 +275,11 @@
       return;
     }
     if (this.section.value.added) {
-      this.dispatchEvent(
-        new CustomEvent('added-section-removed', {
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireEvent(this, 'added-section-removed');
     }
     this._deleted = true;
     this.section.value.deleted = true;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _handleUndoRemove() {
@@ -334,7 +322,7 @@
     if (
       editing &&
       this.section &&
-      this._isEditEnabled(canUpload, ownerOf, this.section.id as GitRef)
+      this._isEditEnabled(canUpload, ownerOf, this.section.id)
     ) {
       classList.push('editing');
     }
@@ -352,7 +340,7 @@
   }
 
   _handleAddPermission() {
-    const value = this.$.permissionSelect.value;
+    const value = this.$.permissionSelect.value as GitRef;
     const permission: PermissionArrayItem<EditablePermissionInfo> = {
       id: value,
       value: {rules: {}, added: true},
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
index 4f5a06e..2821632 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
@@ -154,5 +154,4 @@
     </div>
     <!-- end deletedContainer -->
   </fieldset>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
index 97a7fa3..2387e79 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
@@ -176,6 +176,13 @@
           element.capabilities),
       element.capabilities[permission.id].name);
 
+      permission = {
+        id: 'non-existent',
+        value: {},
+      };
+      assert.isUndefined(element._computePermissionName(name, permission,
+          element.capabilities));
+
       name = 'refs/for/*';
       permission = {
         id: 'abandon',
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index 9d40e28..37a2f3e 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -20,10 +20,7 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-group-dialog/gr-create-group-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-admin-group-list_html';
 import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
@@ -32,8 +29,9 @@
 import {AppElementAdminParams} from '../../gr-app-types';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
+import {fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -45,14 +43,11 @@
   $: {
     createOverlay: GrOverlay;
     createNewModal: GrCreateGroupDialog;
-    restAPI: RestApiService & Element;
   };
 }
 
 @customElement('gr-admin-group-list')
-export class GrAdminGroupList extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrAdminGroupList extends ListViewMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -96,17 +91,13 @@
   @property({type: String})
   _filter = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getCreateGroupCapability();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Groups'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Groups');
     this._maybeOpenCreateOverlay(this.params);
   }
 
@@ -136,11 +127,11 @@
   }
 
   _getCreateGroupCapability() {
-    return this.$.restAPI.getAccount().then(account => {
+    return this.restApiService.getAccount().then(account => {
       if (!account) {
         return;
       }
-      return this.$.restAPI
+      return this.restApiService
         .getAccountCapabilities(['createGroup'])
         .then(capabilities => {
           if (capabilities?.createGroup) {
@@ -152,7 +143,7 @@
 
   _getGroups(filter: string, groupsPerPage: number, offset?: number) {
     this._groups = [];
-    return this.$.restAPI
+    return this.restApiService
       .getGroups(filter, groupsPerPage, offset)
       .then(groups => {
         if (!groups) {
@@ -168,7 +159,7 @@
   }
 
   _refreshGroupsList() {
-    this.$.restAPI.invalidateGroupsCache();
+    this.restApiService.invalidateGroupsCache();
     return this._getGroups(this._filter, this._groupsPerPage, this._offset);
   }
 
@@ -183,7 +174,9 @@
   }
 
   _handleCreateClicked() {
-    this.$.createOverlay.open();
+    this.$.createOverlay.open().then(() => {
+      this.$.createNewModal.focus();
+    });
   }
 
   _visibleToAll(item: GroupInfo) {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
index 93de8b4..91863a9 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
@@ -67,9 +67,7 @@
       on-confirm="_handleCreateGroup"
       on-cancel="_handleCloseCreate"
     >
-      <div class="header" slot="header">
-        Create Group
-      </div>
+      <div class="header" slot="header">Create Group</div>
       <div class="main" slot="main">
         <gr-create-group-dialog
           has-new-group-name="{{_hasNewGroupName}}"
@@ -78,5 +76,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
index 93d41c3..2df1ac6 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
@@ -19,6 +19,7 @@
 import './gr-admin-group-list.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import 'lodash/lodash.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-admin-group-list');
 
@@ -73,24 +74,16 @@
   });
 
   suite('list with groups', () => {
-    setup(done => {
+    setup(async () => {
       groups = _.times(26, groupGenerator);
-
-      stub('gr-rest-api-interface', {
-        getGroups(num, offset) {
-          return Promise.resolve(groups);
-        },
-      });
-
-      element._paramsChanged(value).then(() => { flush(done); });
+      stubRestApi('getGroups').returns(Promise.resolve(groups));
+      element._paramsChanged(value);
+      await flush();
     });
 
-    test('test for test group in the list', done => {
-      flush(() => {
-        assert.equal(element._groups[1].name, '1');
-        assert.equal(element._groups[1].options.visible_to_all, false);
-        done();
-      });
+    test('test for test group in the list', () => {
+      assert.equal(element._groups[1].name, '1');
+      assert.equal(element._groups[1].options.visible_to_all, false);
     });
 
     test('_shownGroups', () => {
@@ -113,13 +106,7 @@
   suite('test with less then 25 groups', () => {
     setup(done => {
       groups = _.times(25, groupGenerator);
-
-      stub('gr-rest-api-interface', {
-        getGroups(num, offset) {
-          return Promise.resolve(groups);
-        },
-      });
-
+      stubRestApi('getGroups').returns(Promise.resolve(groups));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -129,20 +116,15 @@
   });
 
   suite('filter', () => {
-    test('_paramsChanged', done => {
-      sinon.stub(
-          element.$.restAPI,
-          'getGroups')
-          .callsFake(() => Promise.resolve(groups));
+    test('_paramsChanged', async () => {
+      const getGroupsStub = stubRestApi('getGroups');
+      getGroupsStub.returns(Promise.resolve(groups));
       const value = {
         filter: 'test',
         offset: 25,
       };
-      element._paramsChanged(value).then(() => {
-        assert.isTrue(element.$.restAPI.getGroups.lastCall
-            .calledWithExactly('test', 25, 25));
-        done();
-      });
+      await element._paramsChanged(value);
+      assert.isTrue(getGroupsStub.lastCall.calledWithExactly('test', 25, 25));
     });
   });
 
@@ -173,7 +155,8 @@
     });
 
     test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open');
+      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
+          Promise.resolve());
       element._handleCreateClicked();
       assert.isTrue(openStub.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 7fb713f..f8ab519 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -19,9 +19,7 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-js-api-interface/gr-js-api-interface';
 import '../../shared/gr-page-nav/gr-page-nav';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-admin-group-list/gr-admin-group-list';
 import '../gr-group/gr-group';
 import '../gr-group-audit-log/gr-group-audit-log';
@@ -33,14 +31,11 @@
 import '../gr-repo-dashboards/gr-repo-dashboards';
 import '../gr-repo-detail-list/gr-repo-detail-list';
 import '../gr-repo-list/gr-repo-list';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-admin-view_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {
   GerritNav,
-  GerritView,
   GroupDetailView,
   RepoDetailView,
 } from '../../core/gr-navigation/gr-navigation';
@@ -52,7 +47,6 @@
   SubsectionInterface,
 } from '../../../utils/admin-nav-util';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   AppElementAdminParams,
   AppElementGroupParams,
@@ -66,17 +60,11 @@
 } from '../../../types/common';
 import {GroupNameChangedDetail} from '../gr-group/gr-group';
 import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
-import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {appContext} from '../../../services/app-context';
+import {GerritView} from '../../../services/router/router-model';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
-export interface GrAdminView {
-  $: {
-    restAPI: RestApiService & Element;
-    jsAPI: GrJsApiInterface;
-  };
-}
-
 interface AdminSubsectionLink {
   text: string;
   value: string;
@@ -102,14 +90,12 @@
 }
 
 @customElement('gr-admin-view')
-export class GrAdminView extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrAdminView extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
 
-  private _account?: AccountDetailInfo;
+  private account?: AccountDetailInfo;
 
   @property({type: Object})
   params?: AdminViewParams;
@@ -183,19 +169,23 @@
   @property({type: Boolean})
   _showPluginList?: boolean;
 
+  private readonly restApiService = appContext.restApiService;
+
+  private readonly jsAPI = appContext.jsApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this.reload();
   }
 
   reload() {
     const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] = [
-      this.$.restAPI.getAccount(),
+      this.restApiService.getAccount(),
       getPluginLoader().awaitPluginsLoaded(),
     ];
     return Promise.all(promises).then(result => {
-      this._account = result[0];
+      this.account = result[0];
       let options: AdminNavLinksOption | undefined = undefined;
       if (this._repoName) {
         options = {repoName: this._repoName};
@@ -210,15 +200,15 @@
       }
 
       return getAdminLinks(
-        this._account,
+        this.account,
         () =>
-          this.$.restAPI.getAccountCapabilities().then(capabilities => {
+          this.restApiService.getAccountCapabilities().then(capabilities => {
             if (!capabilities) {
               throw new Error('getAccountCapabilities returns undefined');
             }
             return capabilities;
           }),
-        () => this.$.jsAPI.getAdminMenuLinks(),
+        () => this.jsAPI.getAdminMenuLinks(),
         options
       ).then(res => {
         this._filteredLinks = res.links;
@@ -406,7 +396,7 @@
       }
       return '';
     }
-    // TODO(TS): The following condtion seems always false, because params
+    // TODO(TS): The following condition seems always false, because params
     // never has detailType property. Remove it.
     if (
       ((params as unknown) as AdminSubsectionLink).detailType &&
@@ -423,7 +413,7 @@
     if (!groupId) return;
 
     const promises: Array<Promise<void>> = [];
-    this.$.restAPI.getGroupConfig(groupId).then(group => {
+    this.restApiService.getGroupConfig(groupId).then(group => {
       if (!group || !group.name) {
         return;
       }
@@ -433,13 +423,13 @@
       this.reload();
 
       promises.push(
-        this.$.restAPI.getIsAdmin().then(isAdmin => {
+        this.restApiService.getIsAdmin().then(isAdmin => {
           this._isAdmin = !!isAdmin;
         })
       );
 
       promises.push(
-        this.$.restAPI.getIsGroupOwner(group.name).then(isOwner => {
+        this.restApiService.getIsGroupOwner(group.name).then(isOwner => {
           this._groupOwner = isOwner;
         })
       );
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
index 5e85a93..f073a9f 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
@@ -51,7 +51,7 @@
     .selectText.show {
       display: inline-block;
     }
-    main.breadcrumbs:not(.table) {
+    .main.breadcrumbs:not(.table) {
       margin-top: var(--spacing-l);
     }
   </style>
@@ -114,70 +114,68 @@
     </section>
   </template>
   <template is="dom-if" if="[[_showRepoList]]" restamp="true">
-    <main class="table">
+    <div class="main table">
       <gr-repo-list class="table" params="[[params]]"></gr-repo-list>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showGroupList]]" restamp="true">
-    <main class="table">
+    <div class="main table">
       <gr-admin-group-list class="table" params="[[params]]">
       </gr-admin-group-list>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showPluginList]]" restamp="true">
-    <main class="table">
+    <div class="main table">
       <gr-plugin-list class="table" params="[[params]]"></gr-plugin-list>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoMain]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-repo repo="[[params.repo]]"></gr-repo>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showGroup]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-group
         group-id="[[params.groupId]]"
         on-name-changed="_updateGroupName"
       ></gr-group>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-group-members group-id="[[params.groupId]]"></gr-group-members>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoDetailList]]" restamp="true">
-    <main class="table breadcrumbs">
+    <div class="main table breadcrumbs">
       <gr-repo-detail-list
         params="[[params]]"
         class="table"
       ></gr-repo-detail-list>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
-    <main class="table breadcrumbs">
+    <div class="main table breadcrumbs">
       <gr-group-audit-log
         group-id="[[params.groupId]]"
         class="table"
       ></gr-group-audit-log>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoCommands]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-repo-commands repo="[[params.repo]]"></gr-repo-commands>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoAccess]]" restamp="true">
-    <main class="breadcrumbs">
+    <div class="main breadcrumbs">
       <gr-repo-access path="[[path]]" repo="[[params.repo]]"></gr-repo-access>
-    </main>
+    </div>
   </template>
   <template is="dom-if" if="[[_showRepoDashboards]]" restamp="true">
-    <main class="table breadcrumbs">
+    <div class="main table breadcrumbs">
       <gr-repo-dashboards repo="[[params.repo]]"></gr-repo-dashboards>
-    </main>
+    </div>
   </template>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index 44fd4d6..69c218c 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -18,22 +18,27 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-admin-view.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
+import {stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
+import {GerritView} from '../../../services/router/router-model.js';
 
 const basicFixture = fixtureFromElement('gr-admin-view');
 
+function createAdminCapabilities() {
+  return {
+    createGroup: true,
+    createProject: true,
+    viewPlugins: true,
+  };
+}
+
 suite('gr-admin-view tests', () => {
   let element;
 
   setup(done => {
     element = basicFixture.instantiate();
-    stub('gr-rest-api-interface', {
-      getProjectConfig() {
-        return Promise.resolve({});
-      },
-    });
+    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
     const pluginsLoaded = Promise.resolve();
     sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
     pluginsLoaded.then(() => flush(done));
@@ -83,18 +88,11 @@
   });
 
   test('_filteredLinks admin', done => {
-    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+    stubRestApi('getAccount').returns(Promise.resolve({
       name: 'test-user',
     }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        })
-        );
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
     element.reload().then(() => {
       assert.equal(element._filteredLinks.length, 3);
 
@@ -110,39 +108,26 @@
     });
   });
 
-  test('_filteredLinks non admin authenticated', done => {
-    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({})
-        );
-    element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 2);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-
-      // Groups
-      assert.isNotOk(element._filteredLinks[0].subsection);
-      done();
-    });
+  test('_filteredLinks non admin authenticated', async () => {
+    await element.reload();
+    assert.equal(element._filteredLinks.length, 2);
+    // Repos
+    assert.isNotOk(element._filteredLinks[0].subsection);
+    // Groups
+    assert.isNotOk(element._filteredLinks[0].subsection);
   });
 
-  test('_filteredLinks non admin unathenticated', done => {
-    element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 1);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-      done();
-    });
+  test('_filteredLinks non admin unathenticated', async () => {
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    await element.reload();
+    assert.equal(element._filteredLinks.length, 1);
+    // Repos
+    assert.isNotOk(element._filteredLinks[0].subsection);
   });
 
   test('_filteredLinks from plugin', () => {
-    sinon.stub(element.$.jsAPI, 'getAdminMenuLinks').returns([
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    sinon.stub(element.jsAPI, 'getAdminMenuLinks').returns([
       {text: 'internal link text', url: '/internal/link/url'},
       {text: 'external link text', url: 'http://external/link/url'},
     ]);
@@ -171,17 +156,11 @@
 
   test('Repo shows up in nav', done => {
     element._repoName = 'Test Repo';
-    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+    stubRestApi('getAccount').returns(Promise.resolve({
       name: 'test-user',
     }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
     element.reload().then(() => {
       flush();
       assert.equal(dom(element.root)
@@ -196,53 +175,31 @@
     });
   });
 
-  test('Group shows up in nav', done => {
+  test('Group shows up in nav', async () => {
     element._groupId = 'a15262';
     element._groupName = 'my-group';
     element._groupIsInternal = true;
     element._isAdmin = true;
     element._groupOwner = false;
-    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    element.reload().then(() => {
-      flush();
-      assert.equal(element._filteredLinks.length, 3);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-
-      // Groups
-      assert.equal(element._filteredLinks[1].subsection.children.length, 2);
-      assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
-
-      // Plugins
-      assert.isNotOk(element._filteredLinks[2].subsection);
-      done();
-    });
+    stubRestApi('getAccount').returns(Promise.resolve({name: 'test-user'}));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
+    await element.reload();
+    await flush();
+    assert.equal(element._filteredLinks.length, 3);
+    // Repos
+    assert.isNotOk(element._filteredLinks[0].subsection);
+    // Groups
+    assert.equal(element._filteredLinks[1].subsection.children.length, 2);
+    assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
+    // Plugins
+    assert.isNotOk(element._filteredLinks[2].subsection);
   });
 
   test('Nav is reloaded when repo changes', () => {
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccount')
-        .callsFake(() => Promise.resolve({_id: 1}));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
+    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
     sinon.stub(element, 'reload');
     element.params = {repo: 'Test Repo', view: GerritView.REPO};
     assert.equal(element.reload.callCount, 1);
@@ -253,18 +210,9 @@
 
   test('Nav is reloaded when group changes', () => {
     sinon.stub(element, '_computeGroupName');
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccount')
-        .callsFake(() => Promise.resolve({_id: 1}));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
+    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
     sinon.stub(element, 'reload');
     element.params = {groupId: '1', view: GerritView.GROUP};
     assert.equal(element.reload.callCount, 1);
@@ -320,18 +268,9 @@
       view: GerritNav.View.REPO,
       detail: GerritNav.RepoDetailView.ACCESS,
     };
-    sinon.stub(
-        element.$.restAPI,
-        'getAccountCapabilities')
-        .callsFake(() => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    sinon.stub(
-        element.$.restAPI,
-        'getAccount')
-        .callsFake(() => Promise.resolve({_id: 1}));
+    stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities()));
+    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
     flush();
     const expectedFilteredLinks = [
       {
@@ -483,27 +422,15 @@
 
   suite('_computeSelectedClass', () => {
     setup(() => {
-      sinon.stub(
-          element.$.restAPI,
-          'getAccountCapabilities')
-          .callsFake(() => Promise.resolve({
-            createGroup: true,
-            createProject: true,
-            viewPlugins: true,
-          }));
-      sinon.stub(
-          element.$.restAPI,
-          'getAccount')
-          .callsFake(() => Promise.resolve({_id: 1}));
-
+      stubRestApi('getAccountCapabilities').returns(
+          Promise.resolve(createAdminCapabilities()));
+      stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
       return element.reload();
     });
 
     suite('repos', () => {
       setup(() => {
-        stub('gr-repo-access', {
-          _repoChanged: () => {},
-        });
+        stub('gr-repo-access', '_repoChanged').callsFake(() => {});
       });
 
       test('repo list', () => {
@@ -568,20 +495,17 @@
     });
 
     suite('groups', () => {
+      let getGroupConfigStub;
       setup(() => {
-        stub('gr-group', {
-          _loadGroup: () => Promise.resolve({}),
-        });
-        stub('gr-group-members', {
-          _loadGroupDetails: () => {},
-        });
+        stub('gr-group', '_loadGroup').callsFake(() => Promise.resolve({}));
+        stub('gr-group-members', '_loadGroupDetails').callsFake(() => {});
 
-        sinon.stub(element.$.restAPI, 'getGroupConfig')
-            .returns(Promise.resolve({
-              name: 'foo',
-              id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
-            }));
-        sinon.stub(element.$.restAPI, 'getIsGroupOwner')
+        getGroupConfigStub = stubRestApi('getGroupConfig');
+        getGroupConfigStub.returns(Promise.resolve({
+          name: 'foo',
+          id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
+        }));
+        stubRestApi('getIsGroupOwner')
             .returns(Promise.resolve(true));
         return element.reload();
       });
@@ -619,12 +543,10 @@
       });
 
       test('external group', () => {
-        element.$.restAPI.getGroupConfig.restore();
-        sinon.stub(element.$.restAPI, 'getGroupConfig')
-            .returns(Promise.resolve({
-              name: 'foo',
-              id: 'external-id',
-            }));
+        getGroupConfigStub.returns(Promise.resolve({
+          name: 'foo',
+          id: 'external-id',
+        }));
         element.params = {
           view: GerritNav.View.GROUP,
           groupId: 1234,
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
index 79a3e95..d545b4c 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
@@ -16,8 +16,6 @@
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../../styles/shared-styles';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-delete-item-dialog_html';
 import {customElement, property} from '@polymer/decorators';
@@ -36,9 +34,7 @@
 }
 
 @customElement('gr-confirm-delete-item-dialog')
-export class GrConfirmDeleteItemDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrConfirmDeleteItemDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts
index ce9ac9c..890d345 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts
@@ -37,9 +37,7 @@
         Do you really want to delete the following
         [[_computeItemName(itemType)]]?
       </label>
-      <div>
-        [[item]]
-      </div>
+      <div>[[item]]</div>
     </div>
   </gr-dialog>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 2a6c7a8..a47349d 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -19,10 +19,7 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-change-dialog_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -35,17 +32,15 @@
   InheritedBooleanInfo,
 } from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {appContext} from '../../../services/app-context';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
 
 export interface GrCreateChangeDialog {
   $: {
-    restAPI: RestApiService & Element;
     privateChangeCheckBox: HTMLInputElement;
     branchInput: GrAutocomplete;
     tagNameInput: HTMLInputElement;
@@ -53,9 +48,7 @@
   };
 }
 @customElement('gr-create-change-dialog')
-export class GrCreateChangeDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreateChangeDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -93,14 +86,16 @@
   @property({type: Boolean})
   _privateChangesEnabled?: boolean;
 
+  restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = (input: string) => this._getRepoBranchesSuggestions(input);
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     if (!this.repoName) {
       return Promise.resolve();
     }
@@ -108,14 +103,14 @@
     const promises = [];
 
     promises.push(
-      this.$.restAPI.getProjectConfig(this.repoName).then(config => {
+      this.restApiService.getProjectConfig(this.repoName).then(config => {
         if (!config) return;
         this.privateByDefault = config.private_by_default;
       })
     );
 
     promises.push(
-      this.$.restAPI.getConfig().then(config => {
+      this.restApiService.getConfig().then(config => {
         if (!config) {
           return;
         }
@@ -143,7 +138,7 @@
     }
     const isPrivate = this.$.privateChangeCheckBox.checked;
     const isWip = true;
-    return this.$.restAPI
+    return this.restApiService
       .createChange(
         this.repoName,
         this.branch,
@@ -169,24 +164,17 @@
     if (input.startsWith(REF_PREFIX)) {
       input = input.substring(REF_PREFIX.length);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getRepoBranches(input, this.repoName, SUGGESTIONS_LIMIT)
       .then(response => {
         if (!response) return [];
         const branches = [];
-        let branch;
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
+        for (const branchInfo of response) {
+          let name: string = branchInfo.ref;
+          if (name.startsWith('refs/heads/')) {
+            name = name.substring('refs/heads/'.length);
           }
-          if (response[key].ref.startsWith('refs/heads/')) {
-            branch = response[key].ref.substring('refs/heads/'.length);
-          } else {
-            branch = response[key].ref;
-          }
-          branches.push({
-            name: branch,
-          });
+          branches.push({name});
         }
         return branches;
       });
@@ -205,7 +193,7 @@
       return false;
     } else if (
       config &&
-      config.configured_value === InheritedBooleanInfoConfiguredValue.INHERITED
+      config.configured_value === InheritedBooleanInfoConfiguredValue.INHERIT
     ) {
       return !!(config && config.inherited_value);
     } else {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
index 77e2c3b..47f3818 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
@@ -113,5 +113,4 @@
       </span>
     </section>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
index e529730..8a4cbbe 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
@@ -15,12 +15,13 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-create-change-dialog.js';
+import '../../../test/common-test-setup-karma';
+import './gr-create-change-dialog';
 import {GrCreateChangeDialog} from './gr-create-change-dialog';
 import {BranchName, GitRef, RepoName} from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
 import {createChange, createConfig} from '../../../test/test-data-generators';
+import {stubRestApi} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-create-change-dialog');
 
@@ -28,23 +29,18 @@
   let element: GrCreateChangeDialog;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() {
-        return Promise.resolve(true);
-      },
-      getRepoBranches(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              ref: 'refs/heads/test-branch' as GitRef,
-              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-              can_delete: true,
-            },
-          ]);
-        } else {
-          return Promise.resolve([]);
-        }
-      },
+    stubRestApi('getRepoBranches').callsFake((input: string) => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            ref: 'refs/heads/test-branch' as GitRef,
+            revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+            can_delete: true,
+          },
+        ]);
+      } else {
+        return Promise.resolve([]);
+      }
     });
     element = basicFixture.instantiate();
     element.repoName = 'test-repo' as RepoName;
@@ -67,9 +63,9 @@
       work_in_progress: true,
     };
 
-    const saveStub = sinon
-      .stub(element.$.restAPI, 'createChange')
-      .callsFake(() => Promise.resolve(createChange()));
+    const saveStub = stubRestApi('createChange').returns(
+      Promise.resolve(createChange())
+    );
 
     element.branch = 'test-branch' as BranchName;
     element.topic = 'test-topic';
@@ -103,9 +99,9 @@
       work_in_progress: true,
     };
 
-    const saveStub = sinon
-      .stub(element.$.restAPI, 'createChange')
-      .callsFake(() => Promise.resolve(createChange()));
+    const saveStub = stubRestApi('createChange').returns(
+      Promise.resolve(createChange())
+    );
 
     element.branch = 'test-branch' as BranchName;
     element.topic = 'test-topic';
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index 1d8b7db..b4f07cb 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -17,27 +17,16 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-group-dialog_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GroupName} from '../../../types/common';
-
-export interface GrCreateGroupDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
+import {appContext} from '../../../services/app-context';
 
 @customElement('gr-create-group-dialog')
-export class GrCreateGroupDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreateGroupDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -51,6 +40,8 @@
   @property({type: Boolean})
   _groupCreated = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   _computeGroupUrl(groupId: string) {
     return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
   }
@@ -60,14 +51,18 @@
     this.hasNewGroupName = !!name;
   }
 
+  focus() {
+    this.shadowRoot?.querySelector('input')?.focus();
+  }
+
   handleCreateGroup() {
     const name = this._name as GroupName;
-    return this.$.restAPI.createGroup({name}).then(groupRegistered => {
+    return this.restApiService.createGroup({name}).then(groupRegistered => {
       if (groupRegistered.status !== 201) {
         return;
       }
       this._groupCreated = true;
-      return this.$.restAPI.getGroupConfig(name).then(group => {
+      return this.restApiService.getGroupConfig(name).then(group => {
         // TODO(TS): should group always defined ?
         page.show(this._computeGroupUrl(group!.group_id!));
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
index d4ecc5d..daf8780 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
@@ -38,5 +38,4 @@
       </section>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
index d32ff30..af33691 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-create-group-dialog.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-create-group-dialog');
 
@@ -27,9 +28,6 @@
   const GROUP_NAME = 'test-group';
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
     element = basicFixture.instantiate();
   });
 
@@ -47,11 +45,8 @@
   });
 
   test('test for redirecting to group on successful creation', done => {
-    sinon.stub(element.$.restAPI, 'createGroup')
-        .returns(Promise.resolve({status: 201}));
-
-    sinon.stub(element.$.restAPI, 'getGroupConfig')
-        .returns(Promise.resolve({group_id: 551}));
+    stubRestApi('createGroup').returns(Promise.resolve({status: 201}));
+    stubRestApi('getGroupConfig').returns(Promise.resolve({group_id: 551}));
 
     const showStub = sinon.stub(page, 'show');
     element.handleCreateGroup()
@@ -62,11 +57,8 @@
   });
 
   test('test for unsuccessful group creation', done => {
-    sinon.stub(element.$.restAPI, 'createGroup')
-        .returns(Promise.resolve({status: 409}));
-
-    sinon.stub(element.$.restAPI, 'getGroupConfig')
-        .returns(Promise.resolve({group_id: 551}));
+    stubRestApi('createGroup').returns(Promise.resolve({status: 409}));
+    stubRestApi('getGroupConfig').returns(Promise.resolve({group_id: 551}));
 
     const showStub = sinon.stub(page, 'show');
     element.handleCreateGroup()
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index e0a5042..8875ad4 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -18,33 +18,22 @@
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-pointer-dialog_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, property, observe} from '@polymer/decorators';
 import {BranchName, RepoName} from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {appContext} from '../../../services/app-context';
 
 enum DetailType {
   branches = 'branches',
   tags = 'tags',
 }
 
-export interface GrCreatePointerDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-create-pointer-dialog')
-export class GrCreatePointerDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreatePointerDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -75,6 +64,8 @@
     this.hasNewItemName = !!name;
   }
 
+  private readonly restApiService = appContext.restApiService;
+
   handleCreateItem() {
     if (!this.repoName) {
       throw new Error('repoName name is not set');
@@ -85,7 +76,7 @@
     const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
     const url = `${getBaseUrl()}/admin/repos/${encodeURL(this.repoName, true)}`;
     if (this.itemDetail === DetailType.branches) {
-      return this.$.restAPI
+      return this.restApiService
         .createRepoBranch(this.repoName, this._itemName, {revision: USE_HEAD})
         .then(itemRegistered => {
           if (itemRegistered.status === 201) {
@@ -93,7 +84,7 @@
           }
         });
     } else if (this.itemDetail === DetailType.tags) {
-      return this.$.restAPI
+      return this.restApiService
         .createRepoTag(this.repoName, this._itemName, {
           revision: USE_HEAD,
           message: this._itemAnnotation || undefined,
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
index 0b3d81ae..452aab7 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
@@ -80,5 +80,4 @@
       </section>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
index 79a18d5..60af4d5 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-create-pointer-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-create-pointer-dialog');
 
@@ -28,17 +29,11 @@
   };
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
     element = basicFixture.instantiate();
   });
 
   test('branch created', done => {
-    sinon.stub(
-        element.$.restAPI,
-        'createRepoBranch')
-        .callsFake(() => Promise.resolve({}));
+    stubRestApi('createRepoBranch').returns(Promise.resolve({}));
 
     assert.isFalse(element.hasNewItemName);
 
@@ -57,10 +52,7 @@
   });
 
   test('tag created', done => {
-    sinon.stub(
-        element.$.restAPI,
-        'createRepoTag')
-        .callsFake(() => Promise.resolve({}));
+    stubRestApi('createRepoTag').returns(Promise.resolve({}));
 
     assert.isFalse(element.hasNewItemName);
 
@@ -79,10 +71,7 @@
   });
 
   test('tag created with annotations', done => {
-    sinon.stub(
-        element.$.restAPI,
-        'createRepoTag')
-        .callsFake(() => Promise.resolve({}));
+    stubRestApi('createRepoTag').returns(() => Promise.resolve({}));
 
     assert.isFalse(element.hasNewItemName);
 
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 6f0ac19..94a4b0a 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
@@ -19,19 +19,20 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-create-repo-dialog_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, observe, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {ProjectInput, RepoName} from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  BranchName,
+  GroupId,
+  ProjectInput,
+  RepoName,
+} from '../../../types/common';
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -39,16 +40,8 @@
   }
 }
 
-export interface GrCreateRepoDialog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 @customElement('gr-create-repo-dialog')
-export class GrCreateRepoDialog extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrCreateRepoDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -61,8 +54,12 @@
     create_empty_commit: true,
     permissions_only: false,
     name: '' as RepoName,
+    branches: [],
   };
 
+  @property({type: String})
+  _defaultBranch?: BranchName;
+
   @property({type: Boolean})
   _repoCreated = false;
 
@@ -70,7 +67,7 @@
   _repoOwner?: string;
 
   @property({type: String})
-  _repoOwnerId?: string;
+  _repoOwnerId?: GroupId;
 
   @property({type: Object})
   _query: AutocompleteQuery;
@@ -78,6 +75,8 @@
   @property({type: Object})
   _queryGroups: AutocompleteQuery;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = (input: string) => this._getRepoSuggestions(input);
@@ -88,56 +87,43 @@
     return getBaseUrl() + '/admin/repos/' + encodeURL(repoName, true);
   }
 
+  focus() {
+    this.shadowRoot?.querySelector('input')?.focus();
+  }
+
   @observe('_repoConfig.name')
   _updateRepoName(name: string) {
     this.hasNewRepoName = !!name;
   }
 
-  @observe('_repoOwnerId')
-  _repoOwnerIdUpdate(id?: string) {
-    if (id) {
-      this.set('_repoConfig.owners', [id]);
-    } else {
-      this.set('_repoConfig.owners', undefined);
-    }
-  }
-
   handleCreateRepo() {
-    return this.$.restAPI.createRepo(this._repoConfig).then(repoRegistered => {
-      if (repoRegistered.status === 201) {
-        this._repoCreated = true;
-        page.show(this._computeRepoUrl(this._repoConfig.name));
-      }
-    });
+    if (this._defaultBranch) this._repoConfig.branches = [this._defaultBranch];
+    if (this._repoOwnerId) this._repoConfig.owners = [this._repoOwnerId];
+    return this.restApiService
+      .createRepo(this._repoConfig)
+      .then(repoRegistered => {
+        if (repoRegistered.status === 201) {
+          this._repoCreated = true;
+          page.show(this._computeRepoUrl(this._repoConfig.name));
+        }
+      });
   }
 
   _getRepoSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedProjects(input).then(response => {
+    return this.restApiService.getSuggestedProjects(input).then(response => {
       const repos = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        repos.push({
-          name: key,
-          value: response[key],
-        });
+      for (const [name, project] of Object.entries(response ?? {})) {
+        repos.push({name, value: project.id});
       }
       return repos;
     });
   }
 
   _getGroupSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedGroups(input).then(response => {
+    return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
index 02aabfe..f529ac6 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
@@ -46,6 +46,17 @@
         </iron-input>
       </section>
       <section>
+        <span class="title">Default Branch</span>
+        <iron-input autocomplete="off" bind-value="{{_defaultBranch}}">
+          <input
+            is="iron-input"
+            id="defaultBranchNameInput"
+            autocomplete="off"
+            bind-value="{{_defaultBranch}}"
+          />
+        </iron-input>
+      </section>
+      <section>
         <span class="title">Rights inherit from</span>
         <span class="value">
           <gr-autocomplete
@@ -99,5 +110,4 @@
       </section>
     </div>
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
index 1e1fb0e..e6f9bbe 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-create-repo-dialog.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-create-repo-dialog');
 
@@ -24,9 +25,6 @@
   let element;
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
     element = basicFixture.instantiate();
   });
 
@@ -41,11 +39,9 @@
       create_empty_commit: true,
       parent: 'All-Project',
       permissions_only: false,
-      owners: ['testId'],
     };
 
-    const saveStub = sinon.stub(element.$.restAPI,
-        'createRepo').callsFake(() => Promise.resolve({}));
+    const saveStub = stubRestApi('createRepo').returns(Promise.resolve({}));
 
     assert.isFalse(element.hasNewRepoName);
 
@@ -58,10 +54,10 @@
 
     element._repoOwner = 'test';
     element._repoOwnerId = 'testId';
+    element._defaultBranch = 'main';
 
     element.$.repoNameInput.bindValue = configInputObj.name;
     element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
-    element.$.ownerInput.text = configInputObj.owners[0];
     element.$.initialCommit.bindValue =
         configInputObj.create_empty_commit;
     element.$.parentRepo.bindValue =
@@ -72,14 +68,15 @@
     assert.deepEqual(element._repoConfig, configInputObj);
 
     element.handleCreateRepo().then(() => {
-      assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
+      assert.isTrue(saveStub.lastCall.calledWithExactly(
+          {
+            ...configInputObj,
+            owners: ['testId'],
+            branches: ['main'],
+          }
+      ));
       done();
     });
   });
-
-  test('testing observer of _repoOwner', () => {
-    element._repoOwnerId = 'test-5';
-    assert.deepEqual(element._repoConfig.owners, ['test-5']);
-  });
 });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index f7cffac..0bf292d 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -18,37 +18,26 @@
 import '../../../styles/gr-table-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-account-link/gr-account-link';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group-audit-log_html';
 import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
 import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
   GroupInfo,
   AccountInfo,
   EncodedGroupId,
   GroupAuditEventInfo,
 } from '../../../types/common';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
 
-export interface GrGroupAuditLog {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-group-audit-log')
-export class GrGroupAuditLog extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrGroupAuditLog extends ListViewMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -62,16 +51,12 @@
   @property({type: Boolean})
   _loading = true;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Audit Log'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+  connectedCallback() {
+    super.connectedCallback();
+    fireTitleChange(this, 'Audit Log');
   }
 
   /** @override */
@@ -86,16 +71,10 @@
     }
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    return this.$.restAPI
+    return this.restApiService
       .getGroupAuditLog(this.groupId, errFn)
       .then(auditLog => {
         if (!auditLog) {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
index 1212685..32db13f 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
@@ -66,5 +66,4 @@
       </template>
     </tbody>
   </table>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
index 1bbfcae..fdd5d15 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-audit-log.js';
+import {stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group-audit-log');
 
@@ -79,13 +80,11 @@
       element.groupId = 1;
 
       const response = {status: 404};
-      sinon.stub(
-          element.$.restAPI, 'getGroupAuditLog')
-          .callsFake((group, errFn) => {
-            errFn(response);
-          });
+      stubRestApi('getGroupAuditLog').callsFake((group, errFn) => {
+        errFn(response);
+      });
 
-      element.addEventListener('page-error', e => {
+      addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
         done();
       });
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 ae10c03..bc793fa 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
@@ -23,18 +23,11 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group-members_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {
-  RestApiService,
-  ErrorCallback,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {
   GroupId,
@@ -43,9 +36,18 @@
   GroupInfo,
   GroupName,
 } from '../../../types/common';
-import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {PolymerDomRepeatEvent} from '../../../types/types';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  fireAlert,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
@@ -55,14 +57,11 @@
 
 export interface GrGroupMembers {
   $: {
-    restAPI: RestApiService & Element;
     overlay: GrOverlay;
   };
 }
 @customElement('gr-group-members')
-export class GrGroupMembers extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrGroupMembers extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -114,6 +113,8 @@
 
   _itemId?: AccountId | GroupId;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._queryMembers = input => this._getAccountSuggestions(input);
@@ -121,17 +122,11 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadGroupDetails();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Members'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Members');
   }
 
   _loadGroupDetails() {
@@ -142,50 +137,48 @@
     const promises: Promise<void>[] = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    return this.$.restAPI.getGroupConfig(this.groupId, errFn).then(config => {
-      if (!config || !config.name) {
-        return Promise.resolve();
-      }
+    return this.restApiService
+      .getGroupConfig(this.groupId, errFn)
+      .then(config => {
+        if (!config || !config.name) {
+          return Promise.resolve();
+        }
 
-      this._groupName = config.name;
+        this._groupName = config.name;
 
-      promises.push(
-        this.$.restAPI.getIsAdmin().then(isAdmin => {
-          this._isAdmin = !!isAdmin;
-        })
-      );
+        promises.push(
+          this.restApiService.getIsAdmin().then(isAdmin => {
+            this._isAdmin = !!isAdmin;
+          })
+        );
 
-      promises.push(
-        this.$.restAPI.getIsGroupOwner(this._groupName).then(isOwner => {
-          this._groupOwner = !!isOwner;
-        })
-      );
+        promises.push(
+          this.restApiService.getIsGroupOwner(this._groupName).then(isOwner => {
+            this._groupOwner = !!isOwner;
+          })
+        );
 
-      promises.push(
-        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
-          this._groupMembers = members;
-        })
-      );
+        promises.push(
+          this.restApiService.getGroupMembers(this._groupName).then(members => {
+            this._groupMembers = members;
+          })
+        );
 
-      promises.push(
-        this.$.restAPI.getIncludedGroup(this._groupName).then(includedGroup => {
-          this._includedGroups = includedGroup;
-        })
-      );
+        promises.push(
+          this.restApiService
+            .getIncludedGroup(this._groupName)
+            .then(includedGroup => {
+              this._includedGroups = includedGroup;
+            })
+        );
 
-      return Promise.all(promises).then(() => {
-        this._loading = false;
+        return Promise.all(promises).then(() => {
+          this._loading = false;
+        });
       });
-    });
   }
 
   _computeLoadingClass(loading: boolean) {
@@ -217,13 +210,13 @@
     if (!this._groupName) {
       return Promise.reject(new Error('group name undefined'));
     }
-    return this.$.restAPI
+    return this.restApiService
       .saveGroupMember(this._groupName, this._groupMemberSearchId as AccountId)
       .then(config => {
         if (!config || !this._groupName) {
           return;
         }
-        this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+        this.restApiService.getGroupMembers(this._groupName).then(members => {
           this._groupMembers = members;
         });
         this._groupMemberSearchName = '';
@@ -237,24 +230,26 @@
     }
     this.$.overlay.close();
     if (this._itemType === 'member') {
-      return this.$.restAPI
+      return this.restApiService
         .deleteGroupMember(this._groupName, this._itemId! as AccountId)
         .then(itemDeleted => {
           if (itemDeleted.status === 204 && this._groupName) {
-            this.$.restAPI.getGroupMembers(this._groupName).then(members => {
-              this._groupMembers = members;
-            });
+            this.restApiService
+              .getGroupMembers(this._groupName)
+              .then(members => {
+                this._groupMembers = members;
+              });
           }
         });
     } else if (this._itemType === 'includedGroup') {
-      return this.$.restAPI
+      return this.restApiService
         .deleteIncludedGroup(this._groupName, this._itemId! as GroupId)
         .then(itemDeleted => {
           if (
             (itemDeleted.status === 204 || itemDeleted.status === 205) &&
             this._groupName
           ) {
-            this.$.restAPI
+            this.restApiService
               .getIncludedGroup(this._groupName)
               .then(includedGroup => {
                 this._includedGroups = includedGroup;
@@ -290,20 +285,14 @@
         new Error('group name or includedGroupSearchId undefined')
       );
     }
-    return this.$.restAPI
+    return this.restApiService
       .saveIncludedGroup(
         this._groupName,
         this._includedGroupSearchId.replace(/\+/g, ' ') as GroupId,
         (errResponse, err) => {
           if (errResponse) {
             if (errResponse.status === 404) {
-              this.dispatchEvent(
-                new CustomEvent('show-alert', {
-                  detail: {message: SAVING_ERROR_TEXT},
-                  bubbles: true,
-                  composed: true,
-                })
-              );
+              fireAlert(this, SAVING_ERROR_TEXT);
               return errResponse;
             }
             throw Error(errResponse.statusText);
@@ -315,9 +304,11 @@
         if (!config || !this._groupName) {
           return;
         }
-        this.$.restAPI.getIncludedGroup(this._groupName).then(includedGroup => {
-          this._includedGroups = includedGroup;
-        });
+        this.restApiService
+          .getIncludedGroup(this._groupName)
+          .then(includedGroup => {
+            this._includedGroups = includedGroup;
+          });
         this._includedGroupSearchName = '';
         this._includedGroupSearchId = '';
       });
@@ -343,26 +334,21 @@
     if (input.length === 0) {
       return Promise.resolve([]);
     }
-    return this.$.restAPI
+    return this.restApiService
       .getSuggestedAccounts(input, SUGGESTIONS_LIMIT)
       .then(accounts => {
+        if (!accounts) return [];
         const accountSuggestions = [];
-        let nameAndEmail;
-        if (!accounts) {
-          return [];
-        }
-        for (const key in accounts) {
-          if (!hasOwnProperty(accounts, key)) {
-            continue;
-          }
-          if (accounts[key].email !== undefined) {
-            nameAndEmail = `${accounts[key].name} <${accounts[key].email}>`;
+        for (const account of accounts) {
+          let nameAndEmail;
+          if (account.email !== undefined) {
+            nameAndEmail = `${account.name} <${account.email}>`;
           } else {
-            nameAndEmail = accounts[key].name;
+            nameAndEmail = account.name;
           }
           accountSuggestions.push({
             name: nameAndEmail,
-            value: accounts[key]._account_id,
+            value: account._account_id?.toString(),
           });
         }
         return accountSuggestions;
@@ -370,16 +356,10 @@
   }
 
   _getGroupSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedGroups(input).then(response => {
-      const groups = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+    return this.restApiService.getSuggestedGroups(input).then(response => {
+      const groups: AutocompleteSuggestion[] = [];
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
index 2d3f8fc..47da237 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
@@ -56,8 +56,8 @@
       display: none;
     }
   </style>
-  <main
-    class$="gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]"
+  <div
+    class$="main gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]"
   >
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
@@ -136,9 +136,7 @@
               <tr class="headerRow">
                 <th class="groupNameHeader">Group Name</th>
                 <th class="descriptionHeader">Description</th>
-                <th class="deleteIncludedHeader">
-                  Delete Group
-                </th>
+                <th class="deleteIncludedHeader">Delete Group</th>
               </tr>
             </tbody>
             <tbody>
@@ -170,7 +168,7 @@
         </fieldset>
       </div>
     </div>
-  </main>
+  </div>
   <gr-overlay id="overlay" with-backdrop="">
     <gr-confirm-delete-item-dialog
       class="confirmDialog"
@@ -180,5 +178,4 @@
       item-type="[[_itemType]]"
     ></gr-confirm-delete-item-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
index b5d2217..672ac07 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-members.js';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
+import {addListenerForTest, stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group-members');
 
@@ -78,70 +78,52 @@
     },
     ];
 
-    stub('gr-rest-api-interface', {
-      getSuggestedAccounts(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              _account_id: 1000096,
-              name: 'test-account',
-              email: 'test.account@example.com',
-              username: 'test123',
-            },
-            {
-              _account_id: 1001439,
-              name: 'test-admin',
-              email: 'test.admin@example.com',
-              username: 'test_admin',
-            },
-            {
-              _account_id: 1001439,
-              name: 'test-git',
-              username: 'test_git',
-            },
-          ]);
-        } else {
-          return Promise.resolve({});
-        }
-      },
-      getSuggestedGroups(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve({
-            'test-admin': {
-              id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
-            },
-            'test/Administrator (admin)': {
-              id: 'test%3Aadmin',
-            },
-          });
-        } else {
-          return Promise.resolve({});
-        }
-      },
-      getLoggedIn() { return Promise.resolve(true); },
-      getConfig() {
-        return Promise.resolve();
-      },
-      getGroupMembers() {
-        return Promise.resolve(groupMembers);
-      },
-      getIsGroupOwner() {
-        return Promise.resolve(true);
-      },
-      getIncludedGroup() {
-        return Promise.resolve(includedGroups);
-      },
-      getAccountCapabilities() {
-        return Promise.resolve();
-      },
+    stubRestApi('getSuggestedAccounts').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            _account_id: 1000096,
+            name: 'test-account',
+            email: 'test.account@example.com',
+            username: 'test123',
+          },
+          {
+            _account_id: 1001439,
+            name: 'test-admin',
+            email: 'test.admin@example.com',
+            username: 'test_admin',
+          },
+          {
+            _account_id: 1001439,
+            name: 'test-git',
+            username: 'test_git',
+          },
+        ]);
+      } else {
+        return Promise.resolve([]);
+      }
     });
+    stubRestApi('getSuggestedGroups').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve({
+          'test-admin': {
+            id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
+          },
+          'test/Administrator (admin)': {
+            id: 'test%3Aadmin',
+          },
+        });
+      } else {
+        return Promise.resolve({});
+      }
+    });
+    stubRestApi('getGroupMembers').returns(Promise.resolve(groupMembers));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    stubRestApi('getIncludedGroup').returns(Promise.resolve(includedGroups));
     element = basicFixture.instantiate();
     stubBaseUrl('https://test/site');
     element.groupId = 1;
-    groupStub = sinon.stub(
-        element.$.restAPI,
-        'getGroupConfig')
-        .callsFake(() => Promise.resolve(groups));
+    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(groups));
     return element._loadGroupDetails();
   });
 
@@ -162,7 +144,7 @@
 
     const memberName = 'test-admin';
 
-    const saveStub = sinon.stub(element.$.restAPI, 'saveGroupMember')
+    const saveStub = stubRestApi('saveGroupMember')
         .callsFake(() => Promise.resolve({}));
 
     const button = element.$.saveGroupMember;
@@ -187,8 +169,7 @@
 
     const includedGroupName = 'testName';
 
-    const saveIncludedGroupStub = sinon.stub(
-        element.$.restAPI, 'saveIncludedGroup')
+    const saveIncludedGroupStub = stubRestApi('saveIncludedGroup')
         .callsFake(() => Promise.resolve({}));
 
     const button = element.$.saveIncludedGroups;
@@ -219,8 +200,14 @@
       status: 404,
       ok: false,
     };
-    sinon.stub(element.$.restAPI._restApiHelper, 'fetch').callsFake(
-        () => Promise.resolve(errorResponse));
+    stubRestApi('saveIncludedGroup').callsFake((
+        groupName,
+        includedGroup,
+        errFn
+    ) => {
+      errFn(errorResponse);
+      return Promise.resolve(undefined);
+    });
 
     element.$.groupMemberSearchInput.text = memberName;
     element.$.groupMemberSearchInput.value = 1234;
@@ -232,13 +219,8 @@
 
   test('add included group network-error throws an exception', async () => {
     element._groupOwner = true;
-
     const memberName = 'bad-name';
-    const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
-    const err = new Error();
-    sinon.stub(element.$.restAPI._restApiHelper, 'fetch').callsFake(
-        () => Promise.reject(err));
+    stubRestApi('saveIncludedGroup').throws(new Error());
 
     element.$.groupMemberSearchInput.text = memberName;
     element.$.groupMemberSearchInput.value = 1234;
@@ -366,12 +348,11 @@
     element.groupId = 1;
 
     const response = {status: 404};
-    sinon.stub(
-        element.$.restAPI, 'getGroupConfig')
+    stubRestApi('getGroupConfig')
         .callsFake((group, errFn) => {
           errFn(response);
         });
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
       done();
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 511bf5c..0bb13ba 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -21,10 +21,7 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group_html';
 import {customElement, property, observe} from '@polymer/decorators';
@@ -34,10 +31,12 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {hasOwnProperty} from '../../../utils/common-util';
+  fireEvent,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -54,7 +53,6 @@
 
 export interface GrGroup {
   $: {
-    restAPI: RestApiService & Element;
     loading: HTMLDivElement;
   };
 }
@@ -71,9 +69,7 @@
 }
 
 @customElement('gr-group')
-export class GrGroup extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrGroup extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -126,14 +122,16 @@
   @property({type: Boolean})
   _isAdmin = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = (input: string) => this._getGroupSuggestions(input);
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadGroup();
   }
 
@@ -145,56 +143,46 @@
     const promises: Promise<unknown>[] = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    return this.$.restAPI.getGroupConfig(this.groupId, errFn).then(config => {
-      if (!config || !config.name) {
-        return Promise.resolve();
-      }
+    return this.restApiService
+      .getGroupConfig(this.groupId, errFn)
+      .then(config => {
+        if (!config || !config.name) {
+          return Promise.resolve();
+        }
 
-      this._groupName = config.name;
-      this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
+        this._groupName = config.name;
+        this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
 
-      promises.push(
-        this.$.restAPI.getIsAdmin().then(isAdmin => {
-          this._isAdmin = !!isAdmin;
-        })
-      );
+        promises.push(
+          this.restApiService.getIsAdmin().then(isAdmin => {
+            this._isAdmin = !!isAdmin;
+          })
+        );
 
-      promises.push(
-        this.$.restAPI.getIsGroupOwner(config.name).then(isOwner => {
-          this._groupOwner = !!isOwner;
-        })
-      );
+        promises.push(
+          this.restApiService.getIsGroupOwner(config.name).then(isOwner => {
+            this._groupOwner = !!isOwner;
+          })
+        );
 
-      // If visible to all is undefined, set to false. If it is defined
-      // as false, setting to false is fine. If any optional values
-      // are added with a default of true, then this would need to be an
-      // undefined check and not a truthy/falsy check.
-      if (config.options && !config.options.visible_to_all) {
-        config.options.visible_to_all = false;
-      }
-      this._groupConfig = config;
+        // If visible to all is undefined, set to false. If it is defined
+        // as false, setting to false is fine. If any optional values
+        // are added with a default of true, then this would need to be an
+        // undefined check and not a truthy/falsy check.
+        if (config.options && !config.options.visible_to_all) {
+          config.options.visible_to_all = false;
+        }
+        this._groupConfig = config;
 
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title: config.name},
-          composed: true,
-          bubbles: true,
-        })
-      );
+        fireTitleChange(this, config.name);
 
-      return Promise.all(promises).then(() => {
-        this._loading = false;
+        return Promise.all(promises).then(() => {
+          this._loading = false;
+        });
       });
-    });
   }
 
   _computeLoadingClass(loading: boolean) {
@@ -211,7 +199,7 @@
       return Promise.reject(new Error('invalid groupId or config name'));
     }
     const groupName = groupConfig.name;
-    return this.$.restAPI
+    return this.restApiService
       .saveGroupName(this.groupId, groupName)
       .then(config => {
         if (config.status === 200) {
@@ -220,6 +208,7 @@
             name: groupName,
             external: !this._groupIsInternal,
           };
+          fireEvent(this, 'name-changed');
           this.dispatchEvent(
             new CustomEvent('name-changed', {
               detail,
@@ -239,7 +228,7 @@
       owner = decodeURIComponent(this._groupConfigOwner);
     }
     if (!owner) return;
-    return this.$.restAPI.saveGroupOwner(this.groupId, owner).then(() => {
+    return this.restApiService.saveGroupOwner(this.groupId, owner).then(() => {
       this._owner = false;
     });
   }
@@ -247,7 +236,7 @@
   _handleSaveDescription() {
     if (!this.groupId || !this._groupConfig || !this._groupConfig.description)
       return;
-    return this.$.restAPI
+    return this.restApiService
       .saveGroupDescription(this.groupId, this._groupConfig.description)
       .then(() => {
         this._description = false;
@@ -261,9 +250,11 @@
 
     const options = {visible_to_all: visible};
 
-    return this.$.restAPI.saveGroupOptions(this.groupId, options).then(() => {
-      this._options = false;
-    });
+    return this.restApiService
+      .saveGroupOptions(this.groupId, options)
+      .then(() => {
+        this._options = false;
+      });
   }
 
   @observe('_groupConfig.name')
@@ -303,16 +294,10 @@
   }
 
   _getGroupSuggestions(input: string) {
-    return this.$.restAPI.getSuggestedGroups(input).then(response => {
+    return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups: AutocompleteSuggestion[] = [];
-      for (const key in response) {
-        if (!hasOwnProperty(response, key)) {
-          continue;
-        }
-        groups.push({
-          name: key,
-          value: decodeURIComponent(response[key].id),
-        });
+      for (const [name, group] of Object.entries(response ?? {})) {
+        groups.push({name, value: decodeURIComponent(group.id)});
       }
       return groups;
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
index aed73bf..ba089f6 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
@@ -32,7 +32,7 @@
   <style include="gr-form-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <main class="gr-form-styles read-only">
+  <div class="main gr-form-styles read-only">
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
     </div>
@@ -164,6 +164,5 @@
         </fieldset>
       </div>
     </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </div>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
index 34f6b6a..0668330 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-group.js';
+import {stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group');
 
@@ -36,14 +37,8 @@
   };
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
     element = basicFixture.instantiate();
-    groupStub = sinon.stub(
-        element.$.restAPI,
-        'getGroupConfig')
-        .callsFake(() => Promise.resolve(group));
+    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(group));
   });
 
   test('loading displays before group config is loaded', () => {
@@ -55,10 +50,7 @@
   });
 
   test('default values are populated with internal group', done => {
-    sinon.stub(
-        element.$.restAPI,
-        'getIsGroupOwner')
-        .callsFake(() => Promise.resolve(true));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
     element.groupId = 1;
     element._loadGroup().then(() => {
       assert.isTrue(element._groupIsInternal);
@@ -71,14 +63,9 @@
     const groupExternal = {...group};
     groupExternal.id = 'external-group-id';
     groupStub.restore();
-    groupStub = sinon.stub(
-        element.$.restAPI,
-        'getGroupConfig')
-        .callsFake(() => Promise.resolve(groupExternal));
-    sinon.stub(
-        element.$.restAPI,
-        'getIsGroupOwner')
-        .callsFake(() => Promise.resolve(true));
+    groupStub = stubRestApi('getGroupConfig').returns(
+        Promise.resolve(groupExternal));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
     element.groupId = 1;
     element._loadGroup().then(() => {
       assert.isFalse(element._groupIsInternal);
@@ -96,15 +83,8 @@
     };
     element._groupName = groupName;
 
-    sinon.stub(
-        element.$.restAPI,
-        'getIsGroupOwner')
-        .callsFake(() => Promise.resolve(true));
-
-    sinon.stub(
-        element.$.restAPI,
-        'saveGroupName')
-        .callsFake(() => Promise.resolve({status: 200}));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
 
     const button = element.$.inputUpdateNameBtn;
 
@@ -135,10 +115,7 @@
     element._groupConfigOwner = 'testId';
     element._groupOwner = true;
 
-    sinon.stub(
-        element.$.restAPI,
-        'getIsGroupOwner')
-        .callsFake(() => Promise.resolve({status: 200}));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve({status: 200}));
 
     const button = element.$.inputUpdateOwnerBtn;
 
@@ -162,10 +139,7 @@
   test('test for undefined group name', done => {
     groupStub.restore();
 
-    sinon.stub(
-        element.$.restAPI,
-        'getGroupConfig')
-        .callsFake(() => Promise.resolve({}));
+    stubRestApi('getGroupConfig').returns(Promise.resolve({}));
 
     assert.isUndefined(element.groupId);
 
@@ -189,8 +163,7 @@
       name: 'test-group',
     };
     element.groupId = 'gg';
-    sinon.stub(element.$.restAPI, 'saveGroupName')
-        .returns(Promise.resolve({status: 200}));
+    stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
 
     const showStub = sinon.stub(element, 'dispatchEvent');
     element._handleSaveName()
@@ -239,12 +212,11 @@
     element.groupId = 1;
 
     const response = {status: 404};
-    sinon.stub(
-        element.$.restAPI, 'getGroupConfig').callsFake((group, errFn) => {
+    stubRestApi('getGroupConfig').callsFake((group, errFn) => {
       errFn(response);
     });
 
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
       done();
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 9caebde..4b17476 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -21,11 +21,8 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-rule-editor/gr-rule-editor';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-permission_html';
 import {
@@ -34,8 +31,6 @@
   PermissionArray,
 } from '../../../utils/access-util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   LabelNameToLabelTypeInfoMap,
   LabelTypeInfoValues,
@@ -57,6 +52,8 @@
   EditableProjectAccessGroups,
 } from '../gr-repo-access/gr-repo-access-interfaces';
 import {PolymerDomRepeatEvent} from '../../../types/types';
+import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
 
@@ -66,7 +63,6 @@
 
 export interface GrPermission {
   $: {
-    restAPI: RestApiService & Element;
     groupAutocomplete: GrAutocomplete;
   };
 }
@@ -97,9 +93,7 @@
  * @event added-permission-removed
  */
 @customElement('gr-permission')
-export class GrPermission extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrPermission extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -146,14 +140,11 @@
   @property({type: Boolean})
   _originalExclusiveValue?: boolean;
 
+  private readonly restApiService = appContext.restApiService;
+
   constructor() {
     super();
     this._query = () => this._getGroupSuggestions();
-  }
-
-  /** @override */
-  created() {
-    super.created();
     this.addEventListener('access-saved', () => this._handleAccessSaved());
   }
 
@@ -226,9 +217,7 @@
     }
     this.permission.value.modified = true;
     // Allows overall access page to know a change has been made.
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _handleRemovePermission() {
@@ -236,18 +225,11 @@
       return;
     }
     if (this.permission.value.added) {
-      this.dispatchEvent(
-        new CustomEvent('added-permission-removed', {
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireEvent(this, 'added-permission-removed');
     }
     this._deleted = true;
     this.permission.value.deleted = true;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   @observe('_rules.splices')
@@ -341,7 +323,7 @@
   }
 
   _getGroupSuggestions(): Promise<AutocompleteSuggestion[]> {
-    return this.$.restAPI
+    return this.restApiService
       .getSuggestedGroups(
         this._groupFilter || '',
         this.repo,
@@ -349,14 +331,8 @@
       )
       .then(response => {
         const groups: GroupSuggestion[] = [];
-        for (const key in response) {
-          if (!hasOwnProperty(response, key)) {
-            continue;
-          }
-          groups.push({
-            name: key,
-            value: response[key],
-          });
+        for (const [name, value] of Object.entries(response ?? {})) {
+          groups.push({name, value});
         }
         // Does not return groups in which we already have rules for.
         return groups
@@ -415,9 +391,7 @@
     value.added = true;
     // See comment above for why we cannot use "this.set(...)" here.
     this.permission.value.rules[groupId] = value;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _computeHasRange(name: string) {
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
index 9795c92..3559194 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
@@ -93,7 +93,7 @@
               checked="{{permission.value.exclusive}}"
               on-change="_handleValueChange"
               disabled$="[[!editing]]"
-              on-tap="_onTapExclusiveToggle"
+              on-click="_onTapExclusiveToggle"
             ></paper-toggle-button
             >Exclusive
           </template>
@@ -140,5 +140,4 @@
     </div>
     <!-- end deletedContainer -->
   </section>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
index 32430ec..d5668d8 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-permission.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-permission');
 
@@ -25,7 +26,7 @@
 
   setup(() => {
     element = basicFixture.instantiate();
-    sinon.stub(element.$.restAPI, 'getSuggestedGroups').returns(
+    stubRestApi('getSuggestedGroups').returns(
         Promise.resolve({
           'Administrators': {
             id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
index ac82547..0a0259a 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
@@ -20,8 +20,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-plugin-config-array-editor_html';
 import {property, customElement} from '@polymer/decorators';
@@ -37,9 +35,7 @@
 }
 
 @customElement('gr-plugin-config-array-editor')
-class GrPluginConfigArrayEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrPluginConfigArrayEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index 5039972..96cae0e 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -17,9 +17,6 @@
 import '../../../styles/gr-table-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-plugin-list_html';
 import {
@@ -27,22 +24,16 @@
   ListViewParams,
 } from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {PluginInfo} from '../../../types/common';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 interface PluginInfoWithName extends PluginInfo {
   name: string;
 }
-export interface GrPluginList {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-plugin-list')
-export class GrPluginList extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrPluginList extends ListViewMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -81,16 +72,12 @@
   @property({type: String})
   _filter = '';
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Plugins'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+  connectedCallback() {
+    super.connectedCallback();
+    fireTitleChange(this, 'Plugins');
   }
 
   _paramsChanged(params: ListViewParams) {
@@ -103,15 +90,9 @@
 
   _getPlugins(filter: string, pluginsPerPage: number, offset?: number) {
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
-    return this.$.restAPI
+    return this.restApiService
       .getPlugins(filter, pluginsPerPage, offset, errFn)
       .then(plugins => {
         if (!plugins) {
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
index d5318b5..eeca478 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
@@ -78,5 +78,4 @@
       </tbody>
     </table>
   </gr-list-view>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
index 7303748..a9281e4 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-plugin-list.js';
 import 'lodash/lodash.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-plugin-list');
 
@@ -54,13 +55,7 @@
   suite('list with plugins', () => {
     setup(done => {
       plugins = _.times(26, pluginGenerator);
-
-      stub('gr-rest-api-interface', {
-        getPlugins(num, offset) {
-          return Promise.resolve(plugins);
-        },
-      });
-
+      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -113,13 +108,7 @@
   suite('list with less then 26 plugins', () => {
     setup(done => {
       plugins = _.times(25, pluginGenerator);
-
-      stub('gr-rest-api-interface', {
-        getPlugins(num, offset) {
-          return Promise.resolve(plugins);
-        },
-      });
-
+      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -129,24 +118,17 @@
   });
 
   suite('filter', () => {
-    test('_paramsChanged', done => {
-      sinon.stub(
-          element.$.restAPI,
-          'getPlugins')
-          .callsFake(() => Promise.resolve(plugins));
+    test('_paramsChanged', async () => {
+      const getPluginsStub = stubRestApi('getPlugins');
+      getPluginsStub.returns(Promise.resolve(plugins));
       const value = {
         filter: 'test',
         offset: 25,
       };
-      element._paramsChanged(value).then(() => {
-        assert.equal(element.$.restAPI.getPlugins.lastCall.args[0],
-            'test');
-        assert.equal(element.$.restAPI.getPlugins.lastCall.args[1],
-            25);
-        assert.equal(element.$.restAPI.getPlugins.lastCall.args[2],
-            25);
-        done();
-      });
+      await element._paramsChanged(value);
+      assert.equal(getPluginsStub.lastCall.args[0], 'test');
+      assert.equal(getPluginsStub.lastCall.args[1], 25);
+      assert.equal(getPluginsStub.lastCall.args[2], 25);
     });
   });
 
@@ -168,12 +150,12 @@
   suite('404', () => {
     test('fires page-error', done => {
       const response = {status: 404};
-      sinon.stub(element.$.restAPI, 'getPlugins').callsFake(
+      stubRestApi('getPlugins').callsFake(
           (filter, pluginsPerPage, opt_offset, errFn) => {
             errFn(response);
           });
 
-      element.addEventListener('page-error', e => {
+      addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
         done();
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
index 40a1e0a..869a416 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
@@ -72,9 +72,7 @@
   extends PermissionRuleInfo,
     PropertyTreeNode {}
 
-export type PermissionAccessSection = PermissionArrayItem<
-  EditableAccessSectionInfo
->;
+export type PermissionAccessSection = PermissionArrayItem<EditableAccessSectionInfo>;
 
 export interface NewlyAddedGroupInfo {
   name: string;
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 b96ec2c..80c58ca 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
@@ -17,11 +17,8 @@
 import '../../../styles/gr-menu-page-styles';
 import '../../../styles/gr-subpage-styles';
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-access-section/gr-access-section';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-access_html';
 import {encodeURL, getBaseUrl, singleDecodeURL} from '../../../utils/url-util';
@@ -38,8 +35,6 @@
   UrlEncodedRepoName,
   ProjectAccessGroups,
 } from '../../../types/common';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAccessSection} from '../gr-access-section/gr-access-section';
 import {
@@ -52,26 +47,21 @@
   PropertyTreeNode,
   PrimitiveValue,
 } from './gr-repo-access-interfaces';
+import {firePageError, fireAlert} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {WebLinkInfo} from '../../../types/diff';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
 const MAX_AUTOCOMPLETE_RESULTS = 50;
 
-export interface GrRepoAccess {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
-
 /**
  * Fired when save is a no-op
  *
  * @event show-alert
  */
 @customElement('gr-repo-access')
-export class GrRepoAccess extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepoAccess extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -119,21 +109,18 @@
   _sections?: PermissionAccessSection[];
 
   @property({type: Array})
-  _weblinks?: string[];
+  _weblinks?: WebLinkInfo[];
 
   @property({type: Boolean})
   _loading = true;
 
-  private _originalInheritsFrom?: ProjectInfo | null;
+  private originalInheritsFrom?: ProjectInfo | null;
+
+  private readonly restApiService = appContext.restApiService;
 
   constructor() {
     super();
     this._query = () => this._getInheritFromSuggestions();
-  }
-
-  /** @override */
-  created() {
-    super.created();
     this.addEventListener('access-modified', () =>
       this._handleAccessModified()
     );
@@ -155,20 +142,14 @@
 
   _reload(repo: RepoName) {
     const errFn = (response?: Response | null) => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
     this._editing = false;
 
     // Always reset sections when a project changes.
     this._sections = [];
-    const sectionsPromises = this.$.restAPI
+    const sectionsPromises = this.restApiService
       .getRepoAccessRights(repo, errFn)
       .then(res => {
         if (!res) {
@@ -183,7 +164,7 @@
               ...res.inherits_from,
             }
           : null;
-        this._originalInheritsFrom = res.inherits_from
+        this.originalInheritsFrom = res.inherits_from
           ? {
               ...res.inherits_from,
             }
@@ -204,7 +185,7 @@
         return toSortedPermissionsArray(this._local);
       });
 
-    const capabilitiesPromises = this.$.restAPI
+    const capabilitiesPromises = this.restApiService
       .getCapabilities(errFn)
       .then(res => {
         if (!res) {
@@ -214,13 +195,15 @@
         return res;
       });
 
-    const labelsPromises = this.$.restAPI.getRepo(repo, errFn).then(res => {
-      if (!res) {
-        return Promise.resolve(undefined);
-      }
+    const labelsPromises = this.restApiService
+      .getRepo(repo, errFn)
+      .then(res => {
+        if (!res) {
+          return Promise.resolve(undefined);
+        }
 
-      return res.labels;
-    });
+        return res.labels;
+      });
 
     return Promise.all([
       sectionsPromises,
@@ -252,7 +235,7 @@
   }
 
   _getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
-    return this.$.restAPI
+    return this.restApiService
       .getRepos(this._inheritFromFilter, MAX_AUTOCOMPLETE_RESULTS)
       .then(response => {
         const projects: AutocompleteSuggestion[] = [];
@@ -314,9 +297,18 @@
     }
     // Restore inheritFrom.
     if (this._inheritsFrom) {
-      this._inheritsFrom = {...this._originalInheritsFrom};
-      this._inheritFromFilter =
-        'name' in this._inheritsFrom ? this._inheritsFrom.name : undefined;
+      // Can't assign this._inheritsFrom = {...this.originalInheritsFrom}
+      // directly, because this._inheritsFrom is declared as
+      // '...|null|undefined` and typescript reports error when trying
+      // to access .name property (because 'name' in null and 'name' in undefined
+      // lead to runtime error)
+      // After migrating to Typescript v4.2 the code below can be rewritten as
+      // const copy = {...this.originalInheritsFrom};
+      const copy: ProjectInfo | {} = this.originalInheritsFrom
+        ? {...this.originalInheritsFrom}
+        : {};
+      this._inheritsFrom = copy;
+      this._inheritFromFilter = 'name' in copy ? copy.name : undefined;
     }
     if (!this._local) {
       return;
@@ -377,11 +369,9 @@
   /**
    * Used to recursively remove any objects with a 'deleted' bit.
    */
-  _recursivelyRemoveDeleted(obj: PropertyTreeNode) {
-    for (const k in obj) {
-      if (!hasOwnProperty(obj, k)) {
-        continue;
-      }
+  _recursivelyRemoveDeleted(obj?: PropertyTreeNode) {
+    if (!obj) return;
+    for (const k of Object.keys(obj)) {
       const node = obj[k];
       if (typeof node === 'object') {
         if (node.deleted) {
@@ -394,17 +384,15 @@
   }
 
   _recursivelyUpdateAddRemoveObj(
-    obj: PropertyTreeNode,
+    obj: PropertyTreeNode | undefined,
     addRemoveObj: {
       add: PropertyTreeNode;
       remove: PropertyTreeNode;
     },
     path: string[] = []
   ) {
-    for (const k in obj) {
-      if (!hasOwnProperty(obj, k)) {
-        continue;
-      }
+    if (!obj) return;
+    for (const k of Object.keys(obj)) {
       const node = obj[k];
       if (typeof node === 'object') {
         const updatedId = node.updatedId;
@@ -458,8 +446,8 @@
       remove: {},
     };
 
-    const originalInheritsFromId = this._originalInheritsFrom
-      ? singleDecodeURL(this._originalInheritsFrom.id)
+    const originalInheritsFromId = this.originalInheritsFrom
+      ? singleDecodeURL(this.originalInheritsFrom.id)
       : null;
     // TODO(TS): this._inheritsFrom as ProjectInfo might be a mistake.
     // _inheritsFrom can be {}
@@ -516,13 +504,7 @@
       !Object.keys(addRemoveObj.remove).length &&
       !addRemoveObj.parent
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: NOTHING_TO_SAVE},
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireAlert(this, NOTHING_TO_SAVE);
       return;
     }
     const obj: ProjectAccessInput = ({
@@ -548,7 +530,7 @@
     if (!repo) {
       return Promise.resolve();
     }
-    return this.$.restAPI
+    return this.restApiService
       .setRepoAccessRights(repo, obj)
       .then(() => {
         this._reload(repo);
@@ -573,7 +555,7 @@
     if (!this.repo) {
       return;
     }
-    return this.$.restAPI
+    return this.restApiService
       .setRepoAccessRightsForReview(this.repo, obj)
       .then(change => {
         GerritNav.navigateToChange(change);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
index 2c21329..1eba2e7 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
@@ -60,7 +60,7 @@
   <style include="gr-menu-page-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <main class$="[[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
+  <div class$="main [[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
     </div>
@@ -143,6 +143,5 @@
         >
       </div>
     </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </div>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
index d3204e1..a4e019e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
@@ -20,6 +20,7 @@
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {toSortedPermissionsArray} from '../../../utils/access-util.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-access');
 
@@ -101,11 +102,8 @@
   };
   setup(() => {
     element = basicFixture.instantiate();
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(null); },
-    });
-    repoStub = sinon.stub(element.$.restAPI, 'getRepo').returns(
-        Promise.resolve(repoRes));
+    stubRestApi('getAccount').returns(Promise.resolve(null));
+    repoStub = stubRestApi('getRepo').returns(Promise.resolve(repoRes));
     element._loading = false;
     element._ownerOf = [];
     element._canUpload = false;
@@ -118,14 +116,14 @@
   });
 
   test('_repoChanged', done => {
-    const accessStub = sinon.stub(element.$.restAPI,
+    const accessStub = stubRestApi(
         'getRepoAccessRights');
 
     accessStub.withArgs('New Repo').returns(
         Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
     accessStub.withArgs('Another New Repo')
         .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = sinon.stub(element.$.restAPI,
+    const capabilitiesStub = stubRestApi(
         'getCapabilities');
     capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
 
@@ -160,9 +158,9 @@
         name: 'Access Database',
       },
     };
-    const accessStub = sinon.stub(element.$.restAPI, 'getRepoAccessRights')
+    const accessStub = stubRestApi('getRepoAccessRights')
         .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = sinon.stub(element.$.restAPI,
+    const capabilitiesStub = stubRestApi(
         'getCapabilities').returns(Promise.resolve(capabilitiesRes));
 
     element._repoChanged().then(() => {
@@ -240,13 +238,11 @@
   test('fires page-error', done => {
     const response = {status: 404};
 
-    sinon.stub(
-        element.$.restAPI, 'getRepoAccessRights')
-        .callsFake((repoName, errFn) => {
-          errFn(response);
-        });
+    stubRestApi('getRepoAccessRights').callsFake((repoName, errFn) => {
+      errFn(response);
+    });
 
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
       done();
     });
@@ -378,7 +374,7 @@
 
     test('_handleSaveForReview', () => {
       const saveStub =
-          sinon.stub(element.$.restAPI, 'setRepoAccessRightsForReview');
+          stubRestApi('setRepoAccessRightsForReview');
       sinon.stub(element, '_computeAddAndRemove').returns({
         add: {},
         remove: {},
@@ -1161,11 +1157,11 @@
           },
         },
       };
-      sinon.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+      stubRestApi('getRepoAccessRights').returns(
           Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
       sinon.stub(GerritNav, 'navigateToChange');
       let resolver;
-      const saveStub = sinon.stub(element.$.restAPI,
+      const saveStub = stubRestApi(
           'setRepoAccessRights')
           .returns(new Promise(r => resolver = r));
 
@@ -1208,11 +1204,11 @@
           },
         },
       };
-      sinon.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+      stubRestApi('getRepoAccessRights').returns(
           Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
       sinon.stub(GerritNav, 'navigateToChange');
       let resolver;
-      const saveForReviewStub = sinon.stub(element.$.restAPI,
+      const saveForReviewStub = stubRestApi(
           'setRepoAccessRightsForReview')
           .returns(new Promise(r => resolver = r));
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index a74f4bb..df78df3 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -22,19 +22,12 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-change-dialog/gr-create-change-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-commands_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
 import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
   BranchName,
   ConfigInfo,
   PatchSetNum,
@@ -42,6 +35,13 @@
 } from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreateChangeDialog} from '../gr-create-change-dialog/gr-create-change-dialog';
+import {
+  fireAlert,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
@@ -53,16 +53,13 @@
 
 export interface GrRepoCommands {
   $: {
-    restAPI: RestApiService & Element;
     createChangeOverlay: GrOverlay;
     createNewChangeModal: GrCreateChangeDialog;
   };
 }
 
 @customElement('gr-repo-commands')
-export class GrRepoCommands extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepoCommands extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -90,18 +87,14 @@
   @property({type: Boolean})
   _runningGC = false;
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadRepo();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Repo Commands'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Repo Commands');
   }
 
   _loadRepo() {
@@ -109,16 +102,10 @@
       // Do not process the error, if the component is not attached to the DOM
       // anymore, which at least in tests can happen.
       if (!this.isConnected) return;
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+    this.restApiService.getProjectConfig(this.repo, errFn).then(config => {
       if (!config) return;
       // Do not process the response, if the component is not attached to the
       // DOM anymore, which at least in tests can happen.
@@ -137,18 +124,13 @@
   }
 
   _handleRunningGC() {
+    if (!this.repo) return;
     this._runningGC = true;
-    return this.$.restAPI
+    return this.restApiService
       .runRepoGC(this.repo)
       .then(response => {
         if (response?.status === 200) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: GC_MESSAGE},
-              bubbles: true,
-              composed: true,
-            })
-          );
+          fireAlert(this, GC_MESSAGE);
         }
       })
       .finally(() => {
@@ -177,7 +159,7 @@
    */
   _handleEditRepoConfig() {
     this._editingConfig = true;
-    return this.$.restAPI
+    return this.restApiService
       .createChange(
         this.repo,
         CONFIG_BRANCH,
@@ -190,13 +172,7 @@
         const message = change
           ? CREATE_CHANGE_SUCCEEDED_MESSAGE
           : CREATE_CHANGE_FAILED_MESSAGE;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, message);
         if (!change) {
           return;
         }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
index 3880e4a..f572ac3 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
@@ -28,7 +28,7 @@
       margin-bottom: var(--spacing-xxl);
     }
   </style>
-  <main class="gr-form-styles read-only">
+  <div class="main gr-form-styles read-only">
     <h1 id="Title" class="heading-1">Repository Commands</h1>
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
@@ -67,7 +67,7 @@
         </gr-endpoint-decorator>
       </div>
     </div>
-  </main>
+  </div>
   <gr-overlay id="createChangeOverlay" with-backdrop="">
     <gr-dialog
       id="createChangeDialog"
@@ -76,9 +76,7 @@
       on-confirm="_handleCreateChange"
       on-cancel="_handleCloseCreateChange"
     >
-      <div class="header" slot="header">
-        Create Change
-      </div>
+      <div class="header" slot="header">Create Change</div>
       <div class="main" slot="main">
         <gr-create-change-dialog
           id="createNewChangeModal"
@@ -88,5 +86,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
index efe4012..893efe55 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo-commands.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-commands');
 
@@ -31,8 +32,7 @@
     // Note that this probably does not achieve what it is supposed to, because
     // getProjectConfig() is called as soon as the element is attached, so
     // stubbing it here has not effect anymore.
-    repoStub = sinon.stub(element.$.restAPI, 'getProjectConfig')
-        .returns(Promise.resolve({}));
+    repoStub = stubRestApi('getProjectConfig').returns(Promise.resolve({}));
   });
 
   suite('create new change dialog', () => {
@@ -68,7 +68,7 @@
     let alertStub;
 
     setup(() => {
-      createChangeStub = sinon.stub(element.$.restAPI, 'createChange');
+      createChangeStub = stubRestApi('createChange');
       urlStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
       sinon.stub(GerritNav, 'navigateToRelativeUrl');
       handleSpy = sinon.spy(element, '_handleEditRepoConfig');
@@ -118,12 +118,10 @@
       element.repo = 'test';
 
       const response = {status: 404};
-      sinon.stub(
-          element.$.restAPI, 'getProjectConfig')
-          .callsFake((repo, errFn) => {
-            errFn(response);
-          });
-      element.addEventListener('page-error', e => {
+      stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
+        errFn(response);
+      });
+      addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
         done();
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index d9d8560..815e3ac 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -16,32 +16,23 @@
  */
 
 import '../../../styles/shared-styles';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-dashboards_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
-import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {firePageError} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 interface DashboardRef {
   section: string;
   dashboards: DashboardInfo[];
 }
 
-export interface GrRepoDashboards {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-repo-dashboards')
-export class GrRepoDashboards extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepoDashboards extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -55,6 +46,8 @@
   @property({type: Array})
   _dashboards?: DashboardRef[];
 
+  private readonly restApiService = appContext.restApiService;
+
   _repoChanged(repo?: RepoName) {
     this._loading = true;
     if (!repo) {
@@ -62,16 +55,10 @@
     }
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
-    return this.$.restAPI
+    return this.restApiService
       .getRepoDashboards(repo, errFn)
       .then((res?: DashboardInfo[]) => {
         if (!res) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
index 7cdd10e..51ea417 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
@@ -67,5 +67,4 @@
       </template>
     </tbody>
   </table>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
index b4d3575..829611d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo-dashboards.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-dashboards');
 
@@ -30,7 +31,7 @@
 
   suite('dashboard table', () => {
     setup(() => {
-      sinon.stub(element.$.restAPI, 'getRepoDashboards').returns(
+      stubRestApi('getRepoDashboards').returns(
           Promise.resolve([
             {
               id: 'default:contributor',
@@ -123,13 +124,11 @@
   suite('404', () => {
     test('fires page-error', done => {
       const response = {status: 404};
-      sinon.stub(
-          element.$.restAPI, 'getRepoDashboards')
-          .callsFake((repo, errFn) => {
-            errFn(response);
-          });
+      stubRestApi('getRepoDashboards').callsFake((repo, errFn) => {
+        errFn(response);
+      });
 
-      element.addEventListener('page-error', e => {
+      addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
         done();
       });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index 2fce6e1..bb65f984 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -25,21 +25,14 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-detail-list_html';
 import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {encodeURL} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
-import {
-  ErrorCallback,
-  RestApiService,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import {
@@ -53,21 +46,21 @@
 import {AppElementRepoParams} from '../../gr-app-types';
 import {PolymerDomRepeatEvent} from '../../../types/types';
 import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+import {firePageError} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {ErrorCallback} from '../../../api/rest';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
 export interface GrRepoDetailList {
   $: {
-    restAPI: RestApiService & Element;
     overlay: GrOverlay;
     createOverlay: GrOverlay;
     createNewModal: GrCreatePointerDialog;
   };
 }
 @customElement('gr-repo-detail-list')
-export class GrRepoDetailList extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrRepoDetailList extends ListViewMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -120,10 +113,12 @@
   @property({type: String})
   _revisedRef?: GitRef;
 
+  private readonly restApiService = appContext.restApiService;
+
   _determineIfOwner(repo: RepoName) {
-    return this.$.restAPI
+    return this.restApiService
       .getRepoAccess(repo)
-      .then(access => (this._isOwner = !!access && !!access[repo].is_owner));
+      .then(access => (this._isOwner = !!access?.[repo]?.is_owner));
   }
 
   _paramsChanged(params?: AppElementRepoParams) {
@@ -182,16 +177,11 @@
     this._items = [];
     flush();
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
+
     if (detailType === RepoDetailView.BRANCHES) {
-      return this.$.restAPI
+      return this.restApiService
         .getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
         .then(items => {
           if (!items) {
@@ -201,7 +191,7 @@
           this._loading = false;
         });
     } else if (detailType === RepoDetailView.TAGS) {
-      return this.$.restAPI
+      return this.restApiService
         .getRepoTags(filter, repo, itemsPerPage, offset, errFn)
         .then(items => {
           if (!items) {
@@ -249,7 +239,7 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _computeEditingClass(isEditing: boolean) {
@@ -277,7 +267,7 @@
   }
 
   _setRepoHead(repo: RepoName, ref: GitRef, e: PolymerDomRepeatEvent<GitRef>) {
-    return this.$.restAPI.setRepoHead(repo, ref).then(res => {
+    return this.restApiService.setRepoHead(repo, ref).then(res => {
       if (res.status < 400) {
         this._isEditing = false;
         e.model.set('item.revision', ref);
@@ -309,7 +299,7 @@
       return Promise.reject(new Error('undefined repo or refName'));
     }
     if (this.detailType === RepoDetailView.BRANCHES) {
-      return this.$.restAPI
+      return this.restApiService
         .deleteRepoBranches(this._repo, this._refName)
         .then(itemDeleted => {
           if (itemDeleted.status === 204) {
@@ -323,7 +313,7 @@
           }
         });
     } else if (this.detailType === RepoDetailView.TAGS) {
-      return this.$.restAPI
+      return this.restApiService
         .deleteRepoTags(this._repo, this._refName)
         .then(itemDeleted => {
           if (itemDeleted.status === 204) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
index 196797f..2ac32c0 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
@@ -91,9 +91,7 @@
           <th class$="tagger topHeader [[_hideIfBranch(detailType)]]">
             Tagger
           </th>
-          <th class="repositoryBrowser topHeader">
-            Repository Browser
-          </th>
+          <th class="repositoryBrowser topHeader">Repository Browser</th>
           <th class="delete topHeader"></th>
         </tr>
         <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
@@ -111,13 +109,9 @@
             <td
               class$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]"
             >
-              <span class="revisionNoEditing">
-                [[item.revision]]
-              </span>
+              <span class="revisionNoEditing"> [[item.revision]] </span>
               <span class$="revisionEdit [[_computeEditingClass(_isEditing)]]">
-                <span class="revisionWithEditing">
-                  [[item.revision]]
-                </span>
+                <span class="revisionWithEditing"> [[item.revision]] </span>
                 <gr-button
                   link=""
                   on-click="_handleEditRevision"
@@ -220,5 +214,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
index 7727821..990ea4b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
@@ -20,6 +20,7 @@
 import 'lodash/lodash.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-detail-list');
 
@@ -74,18 +75,12 @@
           ref: 'HEAD',
           revision: 'master',
         }].concat(_.times(25, branchGenerator));
-
-        stub('gr-rest-api-interface', {
-          getRepoBranches(num, project, offset) {
-            return Promise.resolve(branches);
-          },
-        });
+        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
 
         const params = {
           repo: 'test',
           detail: 'branches',
         };
-
         element._paramsChanged(params).then(() => { flush(done); });
       });
 
@@ -118,7 +113,7 @@
 
       test('Edit HEAD button not admin', done => {
         sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sinon.stub(element.$.restAPI, 'getRepoAccess').returns(
+        stubRestApi('getRepoAccess').returns(
             Promise.resolve({
               test: {is_owner: false},
             }));
@@ -142,7 +137,7 @@
             .querySelector('.revisionWithEditing');
 
         sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sinon.stub(element.$.restAPI, 'getRepoAccess').returns(
+        stubRestApi('getRepoAccess').returns(
             Promise.resolve({
               test: {is_owner: true},
             }));
@@ -219,7 +214,7 @@
       test('_handleSaveRevision with invalid rev', done => {
         const event = {model: {set: sinon.stub()}};
         element._isEditing = true;
-        sinon.stub(element.$.restAPI, 'setRepoHead').returns(
+        stubRestApi('setRepoHead').returns(
             Promise.resolve({
               status: 400,
             })
@@ -235,7 +230,7 @@
       test('_handleSaveRevision with valid rev', done => {
         const event = {model: {set: sinon.stub()}};
         element._isEditing = true;
-        sinon.stub(element.$.restAPI, 'setRepoHead').returns(
+        stubRestApi('setRepoHead').returns(
             Promise.resolve({
               status: 200,
             })
@@ -257,12 +252,7 @@
     suite('list with less then 25 branches', () => {
       setup(done => {
         branches = _.times(25, branchGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoBranches(num, repo, offset) {
-            return Promise.resolve(branches);
-          },
-        });
+        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
 
         const params = {
           repo: 'test',
@@ -278,40 +268,32 @@
     });
 
     suite('filter', () => {
-      test('_paramsChanged', done => {
-        sinon.stub(
-            element.$.restAPI,
-            'getRepoBranches')
-            .callsFake(() => Promise.resolve(branches));
+      test('_paramsChanged', async () => {
+        const stub = stubRestApi('getRepoBranches').returns(
+            Promise.resolve(branches));
         const params = {
           detail: 'branches',
           repo: 'test',
           filter: 'test',
           offset: 25,
         };
-        element._paramsChanged(params).then(() => {
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[0],
-              'test');
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[1],
-              'test');
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[2],
-              25);
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[3],
-              25);
-          done();
-        });
+        await element._paramsChanged(params);
+        assert.equal(stub.lastCall.args[0], 'test');
+        assert.equal(stub.lastCall.args[1], 'test');
+        assert.equal(stub.lastCall.args[2], 25);
+        assert.equal(stub.lastCall.args[3], 25);
       });
     });
 
     suite('404', () => {
       test('fires page-error', done => {
         const response = {status: 404};
-        sinon.stub(element.$.restAPI, 'getRepoBranches').callsFake(
+        stubRestApi('getRepoBranches').callsFake(
             (filter, repo, reposBranchesPerPage, opt_offset, errFn) => {
               errFn(response);
             });
 
-        element.addEventListener('page-error', e => {
+        addListenerForTest(document, 'page-error', e => {
           assert.deepEqual(e.detail.response, response);
           done();
         });
@@ -360,12 +342,7 @@
     suite('list of repo tags', () => {
       setup(done => {
         tags = _.times(26, tagGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoTags(num, repo, offset) {
-            return Promise.resolve(tags);
-          },
-        });
+        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
 
         const params = {
           repo: 'test',
@@ -435,12 +412,7 @@
     suite('list with less then 25 tags', () => {
       setup(done => {
         tags = _.times(25, tagGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoTags(num, project, offset) {
-            return Promise.resolve(tags);
-          },
-        });
+        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
 
         const params = {
           repo: 'test',
@@ -456,28 +428,19 @@
     });
 
     suite('filter', () => {
-      test('_paramsChanged', done => {
-        sinon.stub(
-            element.$.restAPI,
-            'getRepoTags')
-            .callsFake(() => Promise.resolve(tags));
+      test('_paramsChanged', async () => {
+        const stub = stubRestApi('getRepoTags').returns(Promise.resolve(tags));
         const params = {
           repo: 'test',
           detail: 'tags',
           filter: 'test',
           offset: 25,
         };
-        element._paramsChanged(params).then(() => {
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[0],
-              'test');
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[1],
-              'test');
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[2],
-              25);
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[3],
-              25);
-          done();
-        });
+        await element._paramsChanged(params);
+        assert.equal(stub.lastCall.args[0], 'test');
+        assert.equal(stub.lastCall.args[1], 'test');
+        assert.equal(stub.lastCall.args[2], 25);
+        assert.equal(stub.lastCall.args[3], 25);
       });
     });
 
@@ -520,12 +483,12 @@
     suite('404', () => {
       test('fires page-error', done => {
         const response = {status: 404};
-        sinon.stub(element.$.restAPI, 'getRepoTags').callsFake(
+        stubRestApi('getRepoTags').callsFake(
             (filter, repo, reposTagsPerPage, opt_offset, errFn) => {
               errFn(response);
             });
 
-        element.addEventListener('page-error', e => {
+        addListenerForTest(document, 'page-error', e => {
           assert.deepEqual(e.detail.response, response);
           done();
         });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index ea1cffb..c32ad90 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -19,21 +19,19 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-list_html';
 import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property, observe, computed} from '@polymer/decorators';
 import {AppElementAdminParams} from '../../gr-app-types';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {RepoName, ProjectInfoWithName} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
 import {ProjectState} from '../../../constants/constants';
+import {fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -43,16 +41,13 @@
 
 export interface GrRepoList {
   $: {
-    restAPI: RestApiService & Element;
     createOverlay: GrOverlay;
     createNewModal: GrCreateRepoDialog;
   };
 }
 
 @customElement('gr-repo-list')
-export class GrRepoList extends ListViewMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrRepoList extends ListViewMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -89,17 +84,13 @@
     return this.computeShownItems(this._repos);
   }
 
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._getCreateRepoCapability();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Repos'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Repos');
     this._maybeOpenCreateOverlay(this.params);
   }
 
@@ -130,11 +121,11 @@
   }
 
   _getCreateRepoCapability() {
-    return this.$.restAPI.getAccount().then(account => {
+    return this.restApiService.getAccount().then(account => {
       if (!account) {
         return;
       }
-      return this.$.restAPI
+      return this.restApiService
         .getAccountCapabilities(['createProject'])
         .then(capabilities => {
           if (capabilities?.createProject) {
@@ -146,20 +137,20 @@
 
   _getRepos(filter: string, reposPerPage: number, offset?: number) {
     this._repos = [];
-    return this.$.restAPI.getRepos(filter, reposPerPage, offset).then(repos => {
-      // Late response.
-      if (filter !== this._filter || !repos) {
-        return;
-      }
-      this._repos = repos.filter(repo =>
-        repo.name.toLowerCase().includes(filter.toLowerCase())
-      );
-      this._loading = false;
-    });
+    return this.restApiService
+      .getRepos(filter, reposPerPage, offset)
+      .then(repos => {
+        // Late response.
+        if (filter !== this._filter || !repos) {
+          return;
+        }
+        this._repos = repos;
+        this._loading = false;
+      });
   }
 
   _refreshReposList() {
-    this.$.restAPI.invalidateReposCache();
+    this.restApiService.invalidateReposCache();
     return this._getRepos(this._filter, this._reposPerPage, this._offset);
   }
 
@@ -174,7 +165,9 @@
   }
 
   _handleCreateClicked() {
-    this.$.createOverlay.open();
+    this.$.createOverlay.open().then(() => {
+      this.$.createNewModal.focus();
+    });
   }
 
   _readOnly(repo: ProjectInfoWithName) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
index 4889845..e1a7f489 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
@@ -104,9 +104,7 @@
       on-confirm="_handleCreateRepo"
       on-cancel="_handleCloseCreate"
     >
-      <div class="header" slot="header">
-        Create Repository
-      </div>
+      <div class="header" slot="header">Create Repository</div>
       <div class="main" slot="main">
         <gr-create-repo-dialog
           has-new-repo-name="{{_hasNewRepoName}}"
@@ -115,5 +113,4 @@
       </div>
     </gr-dialog>
   </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
index ad8c6dd..4864af5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
@@ -19,6 +19,7 @@
 import './gr-repo-list.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
 import 'lodash/lodash.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-list');
 
@@ -54,11 +55,7 @@
   suite('list with repos', () => {
     setup(done => {
       repos = _.times(26, repoGenerator);
-      stub('gr-rest-api-interface', {
-        getRepos(num, offset) {
-          return Promise.resolve(repos);
-        },
-      });
+      stubRestApi('getRepos').returns(Promise.resolve(repos));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -89,13 +86,7 @@
   suite('list with less then 25 repos', () => {
     setup(done => {
       repos = _.times(25, repoGenerator);
-
-      stub('gr-rest-api-interface', {
-        getRepos(num, offset) {
-          return Promise.resolve(repos);
-        },
-      });
-
+      stubRestApi('getRepos').returns(Promise.resolve(repos));
       element._paramsChanged(value).then(() => { flush(done); });
     });
 
@@ -111,22 +102,19 @@
       reposFiltered = _.times(1, repoGenerator);
     });
 
-    test('_paramsChanged', done => {
-      sinon.stub(element.$.restAPI, 'getRepos')
-          .callsFake( () => Promise.resolve(repos));
+    test('_paramsChanged', async () => {
+      const repoStub = stubRestApi('getRepos');
+      repoStub.returns(Promise.resolve(repos));
       const value = {
         filter: 'test',
         offset: 25,
       };
-      element._paramsChanged(value).then(() => {
-        assert.isTrue(element.$.restAPI.getRepos.lastCall
-            .calledWithExactly('test', 25, 25));
-        done();
-      });
+      await element._paramsChanged(value);
+      assert.isTrue(repoStub.lastCall.calledWithExactly('test', 25, 25));
     });
 
     test('latest repos requested are always set', done => {
-      const repoStub = sinon.stub(element.$.restAPI, 'getRepos');
+      const repoStub = stubRestApi('getRepos');
       repoStub.withArgs('test').returns(Promise.resolve(repos));
       repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
       element._filter = 'test';
@@ -139,7 +127,7 @@
     });
 
     test('filter is case insensitive', async () => {
-      const repoStub = sinon.stub(element.$.restAPI, 'getRepos');
+      const repoStub = stubRestApi('getRepos');
       const repos = [createRepo('aSDf', 0)];
       repoStub.withArgs('asdf').returns(Promise.resolve(repos));
       element._filter = 'asdf';
@@ -175,7 +163,8 @@
     });
 
     test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open');
+      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
+          Promise.resolve());
       element._handleCreateClicked();
       assert.isTrue(openStub.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index e7eb9dc..ef35f63 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -24,8 +24,6 @@
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-plugin-config_html';
 import {customElement, property} from '@polymer/decorators';
@@ -62,9 +60,7 @@
 }
 
 @customElement('gr-repo-plugin-config')
-class GrRepoPluginConfig extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+class GrRepoPluginConfig extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts
index 208e042..2920cdf 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts
@@ -64,7 +64,7 @@
                 on-change="_handleBooleanChange"
                 data-option-key$="[[option._key]]"
                 disabled$="[[_computeDisabled(disabled, option.info.editable)]]"
-                on-tap="_onTapPluginBoolean"
+                on-click="_onTapPluginBoolean"
               ></paper-toggle-button>
             </template>
             <template is="dom-if" if="[[_isList(option.info.type)]]">
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 101c77a..dc28bcb 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -19,23 +19,16 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-download-commands/gr-download-commands';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
 import '../../shared/gr-select/gr-select';
 import '../../../styles/gr-form-styles';
 import '../../../styles/gr-subpage-styles';
 import '../../../styles/shared-styles';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property, observe} from '@polymer/decorators';
 import {
-  RestApiService,
-  ErrorCallback,
-} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {
   ConfigInfo,
   RepoName,
   InheritedBooleanInfo,
@@ -48,6 +41,10 @@
 import {ProjectState} from '../../../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {appContext} from '../../../services/app-context';
+import {WebLinkInfo} from '../../../types/diff';
+import {ErrorCallback} from '../../../api/rest';
 
 const STATES = {
   active: {value: ProjectState.ACTIVE, label: 'Active'},
@@ -83,15 +80,8 @@
   },
 };
 
-export interface GrRepo {
-  $: {
-    restAPI: RestApiService & Element;
-  };
-}
 @customElement('gr-repo')
-export class GrRepo extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRepo extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -144,18 +134,17 @@
   @property({type: Object})
   _schemesObj?: SchemesInfoMap;
 
+  @property({type: Array})
+  weblinks: WebLinkInfo[] = [];
+
+  private readonly restApiService = appContext.restApiService;
+
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     this._loadRepo();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: this.repo},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, `${this.repo}`);
   }
 
   _computePluginData(
@@ -182,13 +171,7 @@
     const promises = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(response);
     };
 
     promises.push(
@@ -197,7 +180,11 @@
         if (loggedIn) {
           const repo = this.repo;
           if (!repo) throw new Error('undefined repo');
-          this.$.restAPI.getRepoAccess(repo).then(access => {
+          this.restApiService.getRepo(repo).then(repo => {
+            if (!repo?.web_links) return;
+            this.weblinks = repo.web_links;
+          });
+          this.restApiService.getRepoAccess(repo).then(access => {
             if (!access || this.repo !== repo) {
               return;
             }
@@ -210,7 +197,7 @@
     );
 
     promises.push(
-      this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+      this.restApiService.getProjectConfig(this.repo, errFn).then(config => {
         if (!config) {
           return;
         }
@@ -232,7 +219,7 @@
     );
 
     promises.push(
-      this.$.restAPI.getConfig().then(config => {
+      this.restApiService.getConfig().then(config => {
         if (!config) {
           return;
         }
@@ -256,7 +243,7 @@
     if (!_loggedIn) {
       return;
     }
-    this.$.restAPI.getPreferences().then(prefs => {
+    this.restApiService.getPreferences().then(prefs => {
       if (prefs?.download_scheme) {
         // Note (issue 5180): normalize the download scheme with lower-case.
         this._selectedScheme = prefs.download_scheme.toLowerCase();
@@ -324,7 +311,7 @@
   }
 
   _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   _formatRepoConfigForSave(repoConfig: ConfigInfo): ConfigInput {
@@ -340,12 +327,14 @@
       if (key === 'plugin_config') {
         configInputObj.plugin_config_values = repoConfig.plugin_config;
       } else if (typeof repoConfig[key] === 'object') {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
         const repoConfigObj: any = repoConfig[key];
         if (repoConfigObj.configured_value) {
           configInputObj[key as keyof ConfigInput] =
             repoConfigObj.configured_value;
         }
       } else {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
         configInputObj[key as keyof ConfigInput] = repoConfig[key] as any;
       }
     }
@@ -355,7 +344,7 @@
   _handleSaveRepoConfig() {
     if (!this._repoConfig || !this.repo)
       return Promise.reject(new Error('undefined repoConfig or repo'));
-    return this.$.restAPI
+    return this.restApiService
       .saveRepoConfig(
         this.repo,
         this._formatRepoConfigForSave(this._repoConfig)
@@ -399,21 +388,14 @@
     schemesObj?: SchemesInfoMap,
     _selectedScheme?: string
   ) {
-    if (!schemesObj || !repo || !_selectedScheme) {
-      return [];
-    }
+    if (!schemesObj || !repo || !_selectedScheme) return [];
+    if (!hasOwnProperty(schemesObj, _selectedScheme)) return [];
+    const commandObj = schemesObj[_selectedScheme].clone_commands;
     const commands = [];
-    let commandObj: {[title: string]: string} = {};
-    if (hasOwnProperty(schemesObj, _selectedScheme)) {
-      commandObj = schemesObj[_selectedScheme].clone_commands;
-    }
-    for (const title in commandObj) {
-      if (!hasOwnProperty(commandObj, title)) {
-        continue;
-      }
+    for (const [title, command] of Object.entries(commandObj)) {
       commands.push({
         title,
-        command: commandObj[title]
+        command: command
           .replace(/\${project}/gi, encodeURI(repo))
           .replace(
             /\${project-base-name}/gi,
@@ -432,6 +414,10 @@
     return GerritNav.getUrlForProjectChanges(name);
   }
 
+  _computeBrowseUrl(weblinks: WebLinkInfo[]) {
+    return weblinks?.[0]?.url;
+  }
+
   _handlePluginConfigChanged({
     detail: {name, config, notifyPath},
   }: {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
index 3d96f98..8db498a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
@@ -45,17 +45,21 @@
   <style include="gr-form-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
-  <main class="gr-form-styles read-only">
+  <div class="main gr-form-styles read-only">
     <style include="shared-styles">
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
     <div class="info">
-      <h1 id="Title" class="heading-1">
-        [[repo]]
-      </h1>
+      <h1 id="Title" class="heading-1">[[repo]]</h1>
       <hr />
       <div>
-        <a href$="[[_computeChangesUrl(repo)]]">(view changes)</a>
+        <a href$="[[_computeBrowseUrl(weblinks)]]"
+          ><gr-button link disabled="[[!_computeBrowseUrl(weblinks)]]"
+            >Browse</gr-button
+          ></a
+        ><a href$="[[_computeChangesUrl(repo)]]"
+          ><gr-button link>View Changes</gr-button></a
+        >
       </div>
     </div>
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
@@ -436,6 +440,5 @@
         </gr-endpoint-decorator>
       </div>
     </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </div>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
index 93a9d64..c299b55 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
@@ -18,12 +18,13 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo');
 
 suite('gr-repo tests', () => {
   let element;
-
+  let loggedInStub;
   let repoStub;
   const repoConf = {
     description: 'Access inherited by all other projects.',
@@ -98,17 +99,11 @@
   }
 
   setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getConfig() {
-        return Promise.resolve({download: {}});
-      },
-    });
+    loggedInStub = stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getConfig').returns(Promise.resolve({download: {}}));
+    repoStub =
+        stubRestApi('getProjectConfig').returns(Promise.resolve(repoConf));
     element = basicFixture.instantiate();
-    repoStub = sinon.stub(
-        element.$.restAPI,
-        'getProjectConfig')
-        .callsFake(() => Promise.resolve(repoConf));
   });
 
   test('_computePluginData', () => {
@@ -159,37 +154,28 @@
     assert.isTrue(element._readOnly);
   });
 
-  test('form defaults to read only when not logged in', done => {
+  test('form defaults to read only when not logged in', async () => {
     element.repo = REPO;
-    element._loadRepo().then(() => {
-      assert.isTrue(element._readOnly);
-      done();
-    });
+    await element._loadRepo();
+    assert.isTrue(element._readOnly);
   });
 
-  test('form defaults to read only when logged in and not admin', done => {
+  test('form defaults to read only when logged in and not admin', async () => {
     element.repo = REPO;
-    sinon.stub(element, '_getLoggedIn').callsFake(() => Promise.resolve(true));
-    sinon.stub(
-        element.$.restAPI,
-        'getRepoAccess')
+    stubRestApi('getRepoAccess')
         .callsFake(() => Promise.resolve({'test-repo': {}}));
-    element._loadRepo().then(() => {
-      assert.isTrue(element._readOnly);
-      done();
-    });
+    await element._loadRepo();
+    assert.isTrue(element._readOnly);
   });
 
-  test('all form elements are disabled when not admin', done => {
+  test('all form elements are disabled when not admin', async () => {
     element.repo = REPO;
-    element._loadRepo().then(() => {
-      flush();
-      const formFields = getFormFields();
-      for (const field of formFields) {
-        assert.isTrue(field.hasAttribute('disabled'));
-      }
-      done();
-    });
+    await element._loadRepo();
+    flush();
+    const formFields = getFormFields();
+    for (const field of formFields) {
+      assert.isTrue(field.hasAttribute('disabled'));
+    }
   });
 
   test('_formatBooleanSelect', () => {
@@ -246,11 +232,10 @@
     element.repo = 'test';
 
     const response = {status: 404};
-    sinon.stub(
-        element.$.restAPI, 'getProjectConfig').callsFake((repo, errFn) => {
+    stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
       errFn(response);
     });
-    element.addEventListener('page-error', e => {
+    addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
       done();
     });
@@ -261,48 +246,38 @@
   suite('admin', () => {
     setup(() => {
       element.repo = REPO;
-      sinon.stub(element, '_getLoggedIn')
-          .callsFake(() => Promise.resolve(true));
-      sinon.stub(
-          element.$.restAPI,
-          'getRepoAccess')
-          .callsFake(() => Promise.resolve({'test-repo': {is_owner: true}}));
+      loggedInStub.returns(Promise.resolve(true));
+      stubRestApi('getRepoAccess')
+          .returns(Promise.resolve({'test-repo': {is_owner: true}}));
     });
 
-    test('all form elements are enabled', done => {
-      element._loadRepo().then(() => {
-        flush();
-        const formFields = getFormFields();
-        for (const field of formFields) {
-          assert.isFalse(field.hasAttribute('disabled'));
-        }
-        assert.isFalse(element._loading);
-        done();
-      });
+    test('all form elements are enabled', async () => {
+      await element._loadRepo();
+      await flush();
+      const formFields = getFormFields();
+      for (const field of formFields) {
+        assert.isFalse(field.hasAttribute('disabled'));
+      }
+      assert.isFalse(element._loading);
     });
 
-    test('state gets set correctly', done => {
-      element._loadRepo().then(() => {
-        assert.equal(element._repoConfig.state, 'ACTIVE');
-        assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
-        done();
-      });
+    test('state gets set correctly', async () => {
+      await element._loadRepo();
+      assert.equal(element._repoConfig.state, 'ACTIVE');
+      assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
     });
 
-    test('inherited submit type value is calculated correctly', done => {
-      element
-          ._loadRepo().then(() => {
-            const sel = element.$.submitTypeSelect;
-            assert.equal(sel.bindValue, 'INHERIT');
-            assert.equal(
-                sel.nativeSelect.options[0].text,
-                'Inherit (Merge if necessary)'
-            );
-            done();
-          });
+    test('inherited submit type value is calculated correctly', async () => {
+      await element._loadRepo();
+      const sel = element.$.submitTypeSelect;
+      assert.equal(sel.bindValue, 'INHERIT');
+      assert.equal(
+          sel.nativeSelect.options[0].text,
+          'Inherit (Merge if necessary)'
+      );
     });
 
-    test('fields update and save correctly', () => {
+    test('fields update and save correctly', async () => {
       const configInputObj = {
         description: 'new description',
         use_contributor_agreements: 'TRUE',
@@ -322,59 +297,57 @@
         enable_reviewer_by_email: 'TRUE',
       };
 
-      const saveStub = sinon.stub(element.$.restAPI, 'saveRepoConfig')
+      const saveStub = stubRestApi('saveRepoConfig')
           .callsFake(() => Promise.resolve({}));
 
-      const button = element.root.querySelector('gr-button');
+      const button = element.root.querySelectorAll('gr-button')[2];
 
-      return element._loadRepo().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-        element.$.descriptionInput.bindValue = configInputObj.description;
-        element.$.stateSelect.bindValue = configInputObj.state;
-        element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
-        element.$.contentMergeSelect.bindValue =
-            configInputObj.use_content_merge;
-        element.$.newChangeSelect.bindValue =
-            configInputObj.create_new_change_for_all_not_in_target;
-        element.$.requireChangeIdSelect.bindValue =
-            configInputObj.require_change_id;
-        element.$.enableSignedPush.bindValue =
-            configInputObj.enable_signed_push;
-        element.$.requireSignedPush.bindValue =
-            configInputObj.require_signed_push;
-        element.$.rejectImplicitMergesSelect.bindValue =
-            configInputObj.reject_implicit_merges;
-        element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
-            configInputObj.private_by_default;
-        element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
-            configInputObj.match_author_to_committer_date;
-        const inputElement = PolymerElement ?
-          element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
-        inputElement.bindValue = configInputObj.max_object_size_limit;
-        element.$.contributorAgreementSelect.bindValue =
-            configInputObj.use_contributor_agreements;
-        element.$.useSignedOffBySelect.bindValue =
-            configInputObj.use_signed_off_by;
-        element.$.rejectEmptyCommitSelect.bindValue =
-            configInputObj.reject_empty_commit;
-        element.$.unRegisteredCcSelect.bindValue =
-            configInputObj.enable_reviewer_by_email;
+      await element._loadRepo();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+      element.$.descriptionInput.bindValue = configInputObj.description;
+      element.$.stateSelect.bindValue = configInputObj.state;
+      element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
+      element.$.contentMergeSelect.bindValue =
+          configInputObj.use_content_merge;
+      element.$.newChangeSelect.bindValue =
+          configInputObj.create_new_change_for_all_not_in_target;
+      element.$.requireChangeIdSelect.bindValue =
+          configInputObj.require_change_id;
+      element.$.enableSignedPush.bindValue =
+          configInputObj.enable_signed_push;
+      element.$.requireSignedPush.bindValue =
+          configInputObj.require_signed_push;
+      element.$.rejectImplicitMergesSelect.bindValue =
+          configInputObj.reject_implicit_merges;
+      element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
+          configInputObj.private_by_default;
+      element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
+          configInputObj.match_author_to_committer_date;
+      const inputElement = PolymerElement ?
+        element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
+      inputElement.bindValue = configInputObj.max_object_size_limit;
+      element.$.contributorAgreementSelect.bindValue =
+          configInputObj.use_contributor_agreements;
+      element.$.useSignedOffBySelect.bindValue =
+          configInputObj.use_signed_off_by;
+      element.$.rejectEmptyCommitSelect.bindValue =
+          configInputObj.reject_empty_commit;
+      element.$.unRegisteredCcSelect.bindValue =
+          configInputObj.enable_reviewer_by_email;
 
-        assert.isFalse(button.hasAttribute('disabled'));
-        assert.isTrue(element.$.configurations.classList.contains('edited'));
+      assert.isFalse(button.hasAttribute('disabled'));
+      assert.isTrue(element.$.configurations.classList.contains('edited'));
 
-        const formattedObj =
-            element._formatRepoConfigForSave(element._repoConfig);
-        assert.deepEqual(formattedObj, configInputObj);
+      const formattedObj =
+          element._formatRepoConfigForSave(element._repoConfig);
+      assert.deepEqual(formattedObj, configInputObj);
 
-        return element._handleSaveRepoConfig().then(() => {
-          assert.isTrue(button.hasAttribute('disabled'));
-          assert.isFalse(element.$.Title.classList.contains('edited'));
-          assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
-              configInputObj));
-        });
-      });
+      await element._handleSaveRepoConfig();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+      assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
+          configInputObj));
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 8843933..37e0596 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -19,14 +19,12 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-rule-editor_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {AccessPermissionId} from '../../../utils/access-util';
 import {property, customElement, observe} from '@polymer/decorators';
+import {fireEvent} from '../../../utils/event-util';
 
 /**
  * Fired when the rule has been modified or removed.
@@ -102,9 +100,7 @@
 }
 
 @customElement('gr-rule-editor')
-export class GrRuleEditor extends GestureEventListeners(
-  LegacyElementMixin(PolymerElement)
-) {
+export class GrRuleEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -140,9 +136,8 @@
   @property({type: Object})
   _originalRuleValues?: RuleValue;
 
-  /** @override */
-  created() {
-    super.created();
+  constructor() {
+    super();
     this.addEventListener('access-saved', () => this._handleAccessSaved());
   }
 
@@ -158,8 +153,8 @@
   }
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     // Check needed for test purposes.
     if (!this._originalRuleValues && this.rule) {
       // Observer _handleValueChange is called after the ready()
@@ -268,15 +263,11 @@
   _handleRemoveRule() {
     if (!this.rule) return;
     if (this.rule.value.added) {
-      this.dispatchEvent(
-        new CustomEvent('added-rule-removed', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'added-rule-removed');
     }
     this._deleted = true;
     this.rule.value.deleted = true;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _handleUndoRemove() {
@@ -305,9 +296,7 @@
     }
     this.rule.value.modified = true;
     // Allows overall access page to know a change has been made.
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _setOriginalRuleValues(value: RuleValue) {
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
index 98403e0..c4d7688 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
@@ -156,5 +156,4 @@
       >Undo</gr-button
     >
   </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
 `;
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
index b0065d9..9c3646a 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
@@ -192,12 +192,12 @@
       };
       element.section = 'refs/*';
 
-      // Typically called on ready since elements will have properies defined
+      // Typically called on ready since elements will have properties defined
       // by the parent element.
       element._setupValues(element.rule);
       flush();
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -306,7 +306,7 @@
       flush();
       element.rule.value.added = true;
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -371,7 +371,7 @@
       element._setupValues(element.rule);
       flush();
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -425,7 +425,7 @@
       flush();
       element.rule.value.added = true;
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -482,7 +482,7 @@
       element._setupValues(element.rule);
       flush();
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -524,7 +524,7 @@
       flush();
       element.rule.value.added = true;
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
@@ -571,7 +571,7 @@
       element._setupValues(element.rule);
       flush();
       flush(() => {
-        element.attached();
+        element.connectedCallback();
         done();
       });
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index d70e891..2869750 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -26,8 +26,6 @@
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list-item_html';
 import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
@@ -38,7 +36,7 @@
 import {appContext} from '../../../services/app-context';
 import {truncatePath} from '../../../utils/path-list-util';
 import {changeStatuses} from '../../../utils/change-util';
-import {isServiceUser} from '../../../utils/account-util';
+import {isSelf, isServiceUser} from '../../../utils/account-util';
 import {customElement, property} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {
@@ -49,6 +47,7 @@
   Timestamp,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {pluralize} from '../../../utils/string-util';
 
 enum ChangeSize {
   XS = 10,
@@ -77,9 +76,7 @@
 const PRIMARY_REVIEWERS_COUNT = 2;
 
 @customElement('gr-change-list-item')
-export class GrChangeListItem extends ChangeTableMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
-) {
+export class GrChangeListItem extends ChangeTableMixin(PolymerElement) {
   static get template() {
     return htmlTemplate;
   }
@@ -125,8 +122,8 @@
   reporting: ReportingService = appContext.reportingService;
 
   /** @override */
-  attached() {
-    super.attached();
+  connectedCallback() {
+    super.connectedCallback();
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -152,15 +149,18 @@
     if (!label || category === LabelCategory.NOT_APPLICABLE) {
       return 'Label not applicable';
     }
+    const titleParts: string[] = [];
     if (category === LabelCategory.UNRESOLVED_COMMENTS) {
       const num = change?.unresolved_comment_count ?? 0;
-      const plural = num > 1 ? 's' : '';
-      return `${num} unresolved comment${plural}`;
+      titleParts.push(pluralize(num, 'unresolved comment'));
     }
     const significantLabel =
       label.rejected || label.approved || label.disliked || label.recommended;
-    if (significantLabel && significantLabel.name) {
-      return `${labelName}\nby ${significantLabel.name}`;
+    if (significantLabel?.name) {
+      titleParts.push(`${labelName} by ${significantLabel.name}`);
+    }
+    if (titleParts.length > 0) {
+      return titleParts.join(',\n');
     }
     return labelName;
   }
@@ -329,8 +329,8 @@
     );
     reviewers.sort((r1, r2) => {
       if (this.account) {
-        if (r1._account_id === this.account._account_id) return -1;
-        if (r2._account_id === this.account._account_id) return 1;
+        if (isSelf(r1, this.account)) return -1;
+        if (isSelf(r2, this.account)) return 1;
       }
       if (this._hasAttention(r1) && !this._hasAttention(r2)) return -1;
       if (this._hasAttention(r2) && !this._hasAttention(r1)) return 1;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index fdb4534..2f07268 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -54,7 +54,7 @@
       white-space: nowrap;
     }
     .reviewers {
-      --account-max-length: 90px;
+      --account-max-length: 70px;
     }
     .spacer {
       height: 0;
@@ -84,6 +84,9 @@
     a:hover {
       text-decoration: underline;
     }
+    .subject:hover .content {
+      text-decoration: underline;
+    }
     .u-monospace {
       font-family: var(--monospace-font-family);
       font-size: var(--font-size-mono);
@@ -130,21 +133,17 @@
     class="cell subject"
     hidden$="[[isColumnHidden('Subject', visibleChangeTableColumns)]]"
   >
-    <div class="container">
-      <div class="content">
-        <a
-          title$="[[change.subject]]"
-          href$="[[changeURL]]"
-          on-click="_handleChangeClick"
-        >
-          [[change.subject]]
-        </a>
+    <a
+      title$="[[change.subject]]"
+      href$="[[changeURL]]"
+      on-click="_handleChangeClick"
+    >
+      <div class="container">
+        <div class="content">[[change.subject]]</div>
+        <div class="spacer">[[change.subject]]</div>
+        <span>&nbsp;</span>
       </div>
-      <div class="spacer">
-        [[change.subject]]
-      </div>
-      <span>&nbsp;</span>
-    </div>
+    </a>
   </td>
   <td
     class="cell status"
@@ -243,9 +242,7 @@
     class="cell branch"
     hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]"
   >
-    <a href$="[[_computeRepoBranchURL(change)]]">
-      [[change.branch]]
-    </a>
+    <a href$="[[_computeRepoBranchURL(change)]]"> [[change.branch]] </a>
     <template is="dom-if" if="[[change.topic]]">
       (<a href$="[[_computeTopicURL(change)]]"
         ><!--
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
deleted file mode 100644
index d3274f3..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
+++ /dev/null
@@ -1,358 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-change-list-item.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {LabelCategory} from './gr-change-list-item.js';
-
-const basicFixture = fixtureFromElement('gr-change-list-item');
-
-suite('gr-change-list-item tests', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
-    element = basicFixture.instantiate();
-  });
-
-  test('_computeLabelCategory', () => {
-    assert.equal(element._computeLabelCategory({labels: {}}),
-        LabelCategory.NOT_APPLICABLE);
-    assert.equal(element._computeLabelCategory(
-        {labels: {}}, 'Verified'), LabelCategory.NOT_APPLICABLE);
-    assert.equal(element._computeLabelCategory(
-        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
-    LabelCategory.APPROVED);
-    assert.equal(element._computeLabelCategory(
-        {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
-    LabelCategory.REJECTED);
-    assert.equal(element._computeLabelCategory(
-        {
-          labels: {'Code-Review': {approved: true, value: 1}},
-          unresolved_comment_count: 1,
-        }, 'Code-Review'),
-    LabelCategory.UNRESOLVED_COMMENTS);
-    assert.equal(element._computeLabelCategory(
-        {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
-    LabelCategory.POSITIVE);
-    assert.equal(element._computeLabelCategory(
-        {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
-    LabelCategory.NEGATIVE);
-    assert.equal(element._computeLabelCategory(
-        {labels: {'Code-Review': {value: -1}}}, 'Verified'),
-    LabelCategory.NOT_APPLICABLE);
-  });
-
-  test('_computeLabelClass', () => {
-    assert.equal(element._computeLabelClass({labels: {}}),
-        'cell label u-gray-background');
-    assert.equal(element._computeLabelClass(
-        {labels: {}}, 'Verified'), 'cell label u-gray-background');
-    assert.equal(element._computeLabelClass(
-        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
-    'cell label u-green');
-    assert.equal(element._computeLabelClass(
-        {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
-    'cell label u-red');
-    assert.equal(element._computeLabelClass(
-        {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
-    'cell label u-green u-monospace');
-    assert.equal(element._computeLabelClass(
-        {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
-    'cell label u-monospace u-red');
-    assert.equal(element._computeLabelClass(
-        {labels: {'Code-Review': {value: -1}}}, 'Verified'),
-    'cell label u-gray-background');
-  });
-
-  test('_computeLabelTitle', () => {
-    assert.equal(element._computeLabelTitle({labels: {}}, 'Verified'),
-        'Label not applicable');
-    assert.equal(element._computeLabelTitle(
-        {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Verified'),
-    'Verified\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Code-Review'),
-    'Label not applicable');
-    assert.equal(element._computeLabelTitle(
-        {labels: {Verified: {rejected: {name: 'Diffy'}}}}, 'Verified'),
-    'Verified\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}}},
-        'Code-Review'), 'Code-Review\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}}},
-        'Code-Review'), 'Code-Review\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-          rejected: {name: 'Admin'}}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {approved: {name: 'Diffy'},
-          rejected: {name: 'Admin'}}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {approved: {name: 'Diffy'},
-          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-    'Code-Review\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {
-          labels: {'Code-Review': {approved: true, value: 1}},
-          unresolved_comment_count: 1,
-        }, 'Code-Review'),
-    '1 unresolved comment');
-    assert.equal(element._computeLabelTitle(
-        {
-          labels: {'Code-Review': {approved: true, value: 1}},
-          unresolved_comment_count: 2,
-        }, 'Code-Review'),
-    '2 unresolved comments');
-  });
-
-  test('_computeLabelIcon', () => {
-    assert.equal(element._computeLabelIcon({labels: {}}), '');
-    assert.equal(element._computeLabelIcon(
-        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
-    'gr-icons:check');
-    assert.equal(element._computeLabelIcon(
-        {
-          labels: {'Code-Review': {approved: true, value: 1}},
-          unresolved_comment_count: 1,
-        }, 'Code-Review'),
-    'gr-icons:comment');
-  });
-
-  test('_computeLabelValue', () => {
-    assert.equal(element._computeLabelValue({labels: {}}), '');
-    assert.equal(element._computeLabelValue({labels: {}}, 'Verified'), '');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'), '✓');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {value: 1}}}, 'Verified'), '+1');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {value: -1}}}, 'Verified'), '-1');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {approved: true}}}, 'Verified'), '✓');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {rejected: true}}}, 'Verified'), '✕');
-  });
-
-  test('no hidden columns', () => {
-    element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-
-    flush();
-
-    for (const column of element.columnNames) {
-      const elementClass = '.' + column.toLowerCase();
-      assert.isOk(element.shadowRoot
-          .querySelector(elementClass),
-      `Expect ${elementClass} element to be found`);
-      assert.isFalse(element.shadowRoot
-          .querySelector(elementClass).hidden);
-    }
-  });
-
-  test('repo column hidden', () => {
-    element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-
-    flush();
-
-    for (const column of element.columnNames) {
-      const elementClass = '.' + column.toLowerCase();
-      if (column === 'Repo') {
-        assert.isTrue(element.shadowRoot
-            .querySelector(elementClass).hidden);
-      } else {
-        assert.isFalse(element.shadowRoot
-            .querySelector(elementClass).hidden);
-      }
-    }
-  });
-
-  function checkComputeReviewers(
-      userId, reviewerIds, reviewerNames, attSetIds, expected) {
-    element.account = userId ? {_account_id: userId} : null;
-    element.change = {
-      owner: {
-        _account_id: 99,
-      },
-      reviewers: {
-        REVIEWER: [],
-      },
-      attention_set: {},
-    };
-    for (let i = 0; i < reviewerIds.length; i++) {
-      element.change.reviewers.REVIEWER.push({
-        _account_id: reviewerIds[i],
-        name: reviewerNames[i],
-      });
-    }
-    attSetIds.forEach(id => element.change.attention_set[id] = {});
-
-    const actual = element._computeReviewers(element.change)
-        .map(r => r._account_id);
-    assert.deepEqual(actual, expected);
-  }
-
-  test('compute reviewers', () => {
-    checkComputeReviewers(null, [], [], [], []);
-    checkComputeReviewers(1, [], [], [], []);
-    checkComputeReviewers(1, [2], ['a'], [], [2]);
-    checkComputeReviewers(1, [2, 3], [undefined, 'a'], [], [2, 3]);
-    checkComputeReviewers(1, [2, 3], ['a', undefined], [], [3, 2]);
-    checkComputeReviewers(1, [99], ['owner'], [], []);
-    checkComputeReviewers(
-        1, [2, 3, 4, 5], ['b', 'a', 'd', 'c'], [3, 4], [3, 4, 2, 5]);
-    checkComputeReviewers(
-        1, [2, 3, 1, 4, 5], ['b', 'a', 'x', 'd', 'c'], [3, 4], [1, 3, 4, 2, 5]);
-  });
-
-  test('random column does not exist', () => {
-    element.visibleChangeTableColumns = [
-      'Bad',
-    ];
-
-    flush();
-    const elementClass = '.bad';
-    assert.isNotOk(element.shadowRoot
-        .querySelector(elementClass));
-  });
-
-  test('assignee only displayed if there is one', () => {
-    element.change = {};
-    flush();
-    assert.isNotOk(element.shadowRoot
-        .querySelector('.assignee gr-account-link'));
-    assert.equal(element.shadowRoot
-        .querySelector('.assignee').textContent.trim(), '--');
-    element.change = {
-      assignee: {
-        name: 'test',
-        status: 'test',
-      },
-    };
-    flush();
-    assert.isOk(element.shadowRoot
-        .querySelector('.assignee gr-account-link'));
-  });
-
-  test('TShirt sizing tooltip', () => {
-    assert.equal(element._computeSizeTooltip({
-      insertions: 'foo',
-      deletions: 'bar',
-    }), 'Size unknown');
-    assert.equal(element._computeSizeTooltip({
-      insertions: 0,
-      deletions: 0,
-    }), 'Size unknown');
-    assert.equal(element._computeSizeTooltip({
-      insertions: 1,
-      deletions: 2,
-    }), 'added 1, removed 2 lines');
-  });
-
-  test('TShirt sizing', () => {
-    assert.equal(element._computeChangeSize({
-      insertions: 'foo',
-      deletions: 'bar',
-    }), null);
-    assert.equal(element._computeChangeSize({
-      insertions: 1,
-      deletions: 1,
-    }), 'XS');
-    assert.equal(element._computeChangeSize({
-      insertions: 9,
-      deletions: 1,
-    }), 'S');
-    assert.equal(element._computeChangeSize({
-      insertions: 10,
-      deletions: 200,
-    }), 'M');
-    assert.equal(element._computeChangeSize({
-      insertions: 99,
-      deletions: 900,
-    }), 'L');
-    assert.equal(element._computeChangeSize({
-      insertions: 99,
-      deletions: 999,
-    }), 'XL');
-  });
-
-  test('change params passed to gr-navigation', () => {
-    sinon.stub(GerritNav);
-    const change = {
-      internalHost: 'test-host',
-      project: 'test-repo',
-      topic: 'test-topic',
-      branch: 'test-branch',
-    };
-    element.change = change;
-    flush();
-
-    assert.deepEqual(GerritNav.getUrlForChange.lastCall.args, [change]);
-    assert.deepEqual(GerritNav.getUrlForProjectChanges.lastCall.args,
-        [change.project, true, change.internalHost]);
-    assert.deepEqual(GerritNav.getUrlForBranch.lastCall.args,
-        [change.branch, change.project, undefined, change.internalHost]);
-    assert.deepEqual(GerritNav.getUrlForTopic.lastCall.args,
-        [change.topic, change.internalHost]);
-  });
-
-  test('_computeRepoDisplay', () => {
-    const change = {
-      project: 'a/test/repo',
-      internalHost: 'host',
-    };
-    assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
-    assert.equal(element._computeRepoDisplay(change, true),
-        'host/…/test/repo');
-    delete change.internalHost;
-    assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
-    assert.equal(element._computeRepoDisplay(change, true),
-        '…/test/repo');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
new file mode 100644
index 0000000..ac0b929
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -0,0 +1,577 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {
+  createAccountWithId,
+  createChange,
+} from '../../../test/test-data-generators';
+import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  AccountId,
+  BranchName,
+  ChangeInfo,
+  RepoName,
+  TopicName,
+} from '../../../types/common';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import './gr-change-list-item';
+import {GrChangeListItem, LabelCategory} from './gr-change-list-item';
+
+const basicFixture = fixtureFromElement('gr-change-list-item');
+
+suite('gr-change-list-item tests', () => {
+  const account = createAccountWithId();
+  const change: ChangeInfo = {
+    ...createChange(),
+    internalHost: 'host',
+    project: 'a/test/repo' as RepoName,
+    topic: 'test-topic' as TopicName,
+    branch: 'test-branch' as BranchName,
+  };
+
+  let element: GrChangeListItem;
+
+  setup(() => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeLabelCategory', () => {
+    assert.equal(
+      element._computeLabelCategory({...change, labels: {}}, 'Verified'),
+      LabelCategory.NOT_APPLICABLE
+    );
+    assert.equal(
+      element._computeLabelCategory(
+        {...change, labels: {Verified: {approved: account, value: 1}}},
+        'Verified'
+      ),
+      LabelCategory.APPROVED
+    );
+    assert.equal(
+      element._computeLabelCategory(
+        {...change, labels: {Verified: {rejected: account, value: -1}}},
+        'Verified'
+      ),
+      LabelCategory.REJECTED
+    );
+    assert.equal(
+      element._computeLabelCategory(
+        {
+          ...change,
+          labels: {'Code-Review': {approved: account, value: 1}},
+          unresolved_comment_count: 1,
+        },
+        'Code-Review'
+      ),
+      LabelCategory.UNRESOLVED_COMMENTS
+    );
+    assert.equal(
+      element._computeLabelCategory(
+        {...change, labels: {'Code-Review': {value: 1}}},
+        'Code-Review'
+      ),
+      LabelCategory.POSITIVE
+    );
+    assert.equal(
+      element._computeLabelCategory(
+        {...change, labels: {'Code-Review': {value: -1}}},
+        'Code-Review'
+      ),
+      LabelCategory.NEGATIVE
+    );
+    assert.equal(
+      element._computeLabelCategory(
+        {...change, labels: {'Code-Review': {value: -1}}},
+        'Verified'
+      ),
+      LabelCategory.NOT_APPLICABLE
+    );
+  });
+
+  test('_computeLabelClass', () => {
+    assert.equal(
+      element._computeLabelClass({...change, labels: {}}, 'Verified'),
+      'cell label u-gray-background'
+    );
+    assert.equal(
+      element._computeLabelClass(
+        {...change, labels: {Verified: {approved: account, value: 1}}},
+        'Verified'
+      ),
+      'cell label u-green'
+    );
+    assert.equal(
+      element._computeLabelClass(
+        {...change, labels: {Verified: {rejected: account, value: -1}}},
+        'Verified'
+      ),
+      'cell label u-red'
+    );
+    assert.equal(
+      element._computeLabelClass(
+        {...change, labels: {'Code-Review': {value: 1}}},
+        'Code-Review'
+      ),
+      'cell label u-green u-monospace'
+    );
+    assert.equal(
+      element._computeLabelClass(
+        {...change, labels: {'Code-Review': {value: -1}}},
+        'Code-Review'
+      ),
+      'cell label u-monospace u-red'
+    );
+    assert.equal(
+      element._computeLabelClass(
+        {...change, labels: {'Code-Review': {value: -1}}},
+        'Verified'
+      ),
+      'cell label u-gray-background'
+    );
+  });
+
+  test('_computeLabelTitle', () => {
+    assert.equal(
+      element._computeLabelTitle({...change, labels: {}}, 'Verified'),
+      'Label not applicable'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {...change, labels: {Verified: {approved: {name: 'Diffy'}}}},
+        'Verified'
+      ),
+      'Verified by Diffy'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {...change, labels: {Verified: {approved: {name: 'Diffy'}}}},
+        'Code-Review'
+      ),
+      'Label not applicable'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {...change, labels: {Verified: {rejected: {name: 'Diffy'}}}},
+        'Verified'
+      ),
+      'Verified by Diffy'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}},
+        },
+        'Code-Review'
+      ),
+      'Code-Review by Diffy'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}},
+        },
+        'Code-Review'
+      ),
+      'Code-Review by Diffy'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {
+            'Code-Review': {
+              recommended: {name: 'Diffy'},
+              rejected: {name: 'Admin'},
+            },
+          },
+        },
+        'Code-Review'
+      ),
+      'Code-Review by Admin'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {
+            'Code-Review': {
+              approved: {name: 'Diffy'},
+              rejected: {name: 'Admin'},
+            },
+          },
+        },
+        'Code-Review'
+      ),
+      'Code-Review by Admin'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {
+            'Code-Review': {
+              recommended: {name: 'Diffy'},
+              disliked: {name: 'Admin'},
+              value: -1,
+            },
+          },
+        },
+        'Code-Review'
+      ),
+      'Code-Review by Admin'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {
+            'Code-Review': {
+              approved: {name: 'Diffy'},
+              disliked: {name: 'Admin'},
+              value: -1,
+            },
+          },
+        },
+        'Code-Review'
+      ),
+      'Code-Review by Diffy'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {'Code-Review': {approved: account, value: 1}},
+          unresolved_comment_count: 1,
+        },
+        'Code-Review'
+      ),
+      '1 unresolved comment'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {'Code-Review': {approved: {name: 'Diffy'}, value: 1}},
+          unresolved_comment_count: 1,
+        },
+        'Code-Review'
+      ),
+      '1 unresolved comment,\nCode-Review by Diffy'
+    );
+    assert.equal(
+      element._computeLabelTitle(
+        {
+          ...change,
+          labels: {'Code-Review': {approved: account, value: 1}},
+          unresolved_comment_count: 2,
+        },
+        'Code-Review'
+      ),
+      '2 unresolved comments'
+    );
+  });
+
+  test('_computeLabelIcon', () => {
+    assert.equal(
+      element._computeLabelIcon({...change, labels: {}}, 'missingLabel'),
+      ''
+    );
+    assert.equal(
+      element._computeLabelIcon(
+        {...change, labels: {Verified: {approved: account, value: 1}}},
+        'Verified'
+      ),
+      'gr-icons:check'
+    );
+    assert.equal(
+      element._computeLabelIcon(
+        {
+          ...change,
+          labels: {'Code-Review': {approved: account, value: 1}},
+          unresolved_comment_count: 1,
+        },
+        'Code-Review'
+      ),
+      'gr-icons:comment'
+    );
+  });
+
+  test('_computeLabelValue', () => {
+    assert.equal(
+      element._computeLabelValue({...change, labels: {}}, 'Verified'),
+      ''
+    );
+    assert.equal(
+      element._computeLabelValue(
+        {...change, labels: {Verified: {approved: account, value: 1}}},
+        'Verified'
+      ),
+      '✓'
+    );
+    assert.equal(
+      element._computeLabelValue(
+        {...change, labels: {Verified: {value: 1}}},
+        'Verified'
+      ),
+      '+1'
+    );
+    assert.equal(
+      element._computeLabelValue(
+        {...change, labels: {Verified: {value: -1}}},
+        'Verified'
+      ),
+      '-1'
+    );
+    assert.equal(
+      element._computeLabelValue(
+        {...change, labels: {Verified: {approved: account}}},
+        'Verified'
+      ),
+      '✓'
+    );
+    assert.equal(
+      element._computeLabelValue(
+        {...change, labels: {Verified: {rejected: account}}},
+        'Verified'
+      ),
+      '✕'
+    );
+  });
+
+  test('no hidden columns', async () => {
+    element.visibleChangeTableColumns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+
+    await flush();
+
+    for (const column of element.columnNames) {
+      const elementClass = '.' + column.toLowerCase();
+      assert.isFalse(
+        queryAndAssert(element, elementClass).hasAttribute('hidden')
+      );
+    }
+  });
+
+  test('repo column hidden', async () => {
+    element.visibleChangeTableColumns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+
+    await flush();
+
+    for (const column of element.columnNames) {
+      const elementClass = '.' + column.toLowerCase();
+      if (column === 'Repo') {
+        assert.isTrue(
+          queryAndAssert(element, elementClass).hasAttribute('hidden')
+        );
+      } else {
+        assert.isFalse(
+          queryAndAssert(element, elementClass).hasAttribute('hidden')
+        );
+      }
+    }
+  });
+
+  function checkComputeReviewers(
+    userId: number | undefined,
+    reviewerIds: number[],
+    reviewerNames: (string | undefined)[],
+    attSetIds: number[],
+    expected: number[]
+  ) {
+    element.account = userId ? {_account_id: userId as AccountId} : null;
+    element.change = {
+      ...change,
+      owner: {
+        _account_id: 99 as AccountId,
+      },
+      reviewers: {
+        REVIEWER: [],
+      },
+      attention_set: {},
+    };
+    for (let i = 0; i < reviewerIds.length; i++) {
+      element.change!.reviewers.REVIEWER!.push({
+        _account_id: reviewerIds[i] as AccountId,
+        name: reviewerNames[i],
+      });
+    }
+    attSetIds.forEach(id => (element.change!.attention_set![id] = {account}));
+
+    const actual = element
+      ._computeReviewers(element.change)
+      .map(r => r._account_id);
+    assert.deepEqual(actual, expected as AccountId[]);
+  }
+
+  test('compute reviewers', () => {
+    checkComputeReviewers(undefined, [], [], [], []);
+    checkComputeReviewers(1, [], [], [], []);
+    checkComputeReviewers(1, [2], ['a'], [], [2]);
+    checkComputeReviewers(1, [2, 3], [undefined, 'a'], [], [2, 3]);
+    checkComputeReviewers(1, [2, 3], ['a', undefined], [], [3, 2]);
+    checkComputeReviewers(1, [99], ['owner'], [], []);
+    checkComputeReviewers(
+      1,
+      [2, 3, 4, 5],
+      ['b', 'a', 'd', 'c'],
+      [3, 4],
+      [3, 4, 2, 5]
+    );
+    checkComputeReviewers(
+      1,
+      [2, 3, 1, 4, 5],
+      ['b', 'a', 'x', 'd', 'c'],
+      [3, 4],
+      [1, 3, 4, 2, 5]
+    );
+  });
+
+  test('random column does not exist', async () => {
+    element.visibleChangeTableColumns = ['Bad'];
+
+    await flush();
+    const elementClass = '.bad';
+    assert.isNotOk(query(element, elementClass));
+  });
+
+  test('assignee only displayed if there is one', async () => {
+    element.change = change;
+    await flush();
+    assert.isNotOk(query(element, '.assignee gr-account-link'));
+    assert.equal(
+      queryAndAssert(element, '.assignee').textContent!.trim(),
+      '--'
+    );
+    element.change = {
+      ...change,
+      assignee: {
+        name: 'test',
+        status: 'test',
+      },
+    };
+    await flush();
+    queryAndAssert(element, '.assignee gr-account-link');
+  });
+
+  test('TShirt sizing tooltip', () => {
+    assert.equal(
+      element._computeSizeTooltip({
+        ...change,
+        insertions: NaN,
+        deletions: NaN,
+      }),
+      'Size unknown'
+    );
+    assert.equal(
+      element._computeSizeTooltip({...change, insertions: 0, deletions: 0}),
+      'Size unknown'
+    );
+    assert.equal(
+      element._computeSizeTooltip({...change, insertions: 1, deletions: 2}),
+      'added 1, removed 2 lines'
+    );
+  });
+
+  test('TShirt sizing', () => {
+    assert.equal(
+      element._computeChangeSize({
+        ...change,
+        insertions: NaN,
+        deletions: NaN,
+      }),
+      null
+    );
+    assert.equal(
+      element._computeChangeSize({...change, insertions: 1, deletions: 1}),
+      'XS'
+    );
+    assert.equal(
+      element._computeChangeSize({...change, insertions: 9, deletions: 1}),
+      'S'
+    );
+    assert.equal(
+      element._computeChangeSize({...change, insertions: 10, deletions: 200}),
+      'M'
+    );
+    assert.equal(
+      element._computeChangeSize({...change, insertions: 99, deletions: 900}),
+      'L'
+    );
+    assert.equal(
+      element._computeChangeSize({...change, insertions: 99, deletions: 999}),
+      'XL'
+    );
+  });
+
+  test('change params passed to gr-navigation', async () => {
+    const navStub = sinon.stub(GerritNav);
+    element.change = change;
+    await flush();
+
+    assert.deepEqual(navStub.getUrlForChange.lastCall.args, [change]);
+    assert.deepEqual(navStub.getUrlForProjectChanges.lastCall.args, [
+      change.project,
+      true,
+      change.internalHost,
+    ]);
+    assert.deepEqual(navStub.getUrlForBranch.lastCall.args, [
+      change.branch,
+      change.project,
+      undefined,
+      change.internalHost,
+    ]);
+    assert.deepEqual(navStub.getUrlForTopic.lastCall.args, [
+      change.topic,
+      change.internalHost,
+    ]);
+  });
+
+  test('_computeRepoDisplay', () => {
+    assert.equal(
+      element._computeRepoDisplay(change, false),
+      'host/a/test/repo'
+    );
+    assert.equal(element._computeRepoDisplay(change, true), 'host/…/test/repo');
+    delete change.internalHost;
+    assert.equal(element._computeRepoDisplay(change, false), 'a/test/repo');
+    assert.equal(element._computeRepoDisplay(change, true), '…/test/repo');
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts