Merge "user-review-ui: make colon usage a little more consistent"
diff --git a/.gitignore b/.gitignore
index 53bc9f6..0bbcaba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,8 @@
 /infer-out
 /local.properties
 /node_modules/
+/polygerrit-ui/node_modules/
+/polygerrit-ui/app/node_modules/
 /package-lock.json
 /plugins/*
 /polygerrit-ui/coverage/
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 3da69df..185fa07 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -800,6 +800,22 @@
 Users without this access right who are able to upload changes can
 still do the revert locally and upload the revert commit as a new change.
 
+[[category_remove_label]]
+=== Remove Label (Remove Vote)
+
+For every configured label `My-Name` in the project, there is a
+corresponding permission `removeLabel-My-Name` with a range corresponding to
+the defined values. For these values, the users are permitted to remove
+other users' votes from a change.
+
+Change owners can always remove zero or positive votes (even without
+having the `Remove Vote` access right assigned).
+
+Project owners and site administrators can always remove any vote (even
+without having the `Remove Vote` access right assigned).
+
+Users without this access right can still remove their own votes.
+
 [[category_remove_reviewer]]
 === Remove Reviewer
 
@@ -890,6 +906,9 @@
 the Work In Progress bit of the change (even without having the
 `Toggle Work In Progress state` access right assigned).
 
+Must be assigned on the target branch ref (i.e. on 'refs/heads/*', not on
+'refs/for/*').
+
 [[category_delete_own_changes]]
 === Delete Own Changes
 
@@ -1351,10 +1370,11 @@
 [[capability_createProject]]
 === Create Project
 
-Allow project creation.  This capability allows the granted group to
-either link:cmd-create-project.html[create new git projects via ssh]
-or via the web UI.
+Allow project creation.
 
+This capability allows the granted group to create projects via the web UI, via
+link:rest-api-projects.html#create-project][REST] and via
+link:cmd-create-project.html[SSH].
 
 [[capability_emailReviewers]]
 === Email Reviewers
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index 601f2bf..fb37287 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -282,6 +282,22 @@
   canOverrideInChildProjects = true
 ----
 
+Branch configuration supports regular expressions as well, e.g. to exempt 'refs/heads/release/*' pattern,
+when migrating from the label Submit-Rule:
+
+----
+[label "Verified"]
+  branch = refs/heads/release/*
+----
+
+The following SR can be configured:
+
+----
+[submit-requirement "Verified"]
+  submittableIf = label:Verified=MAX AND -label:Verified=MIN
+  applicableIf = branch:^refs/heads/release/.*
+----
+
 
 [[test-submit-requirements]]
 == Testing Submit Requirements
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 264ce73..7a042ba 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -1014,7 +1014,7 @@
 inline comment ("Yeah, I see why, let me try again.").
 
 [[security-fixes]]
--- Security Fixes
+== Security Fixes
 
 If a security vulnerability is discovered you normally want to have an
 embargo about it until fixed releases have been made available. This
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 3f7d90d..d7343c2 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -98,20 +98,29 @@
 See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/styles/themes/[app-theme.ts]
 for the list of available variables.
 
-Just add code like this to your JavaScript plugin:
+You can just create `<style>` elements yourself and add them to the
+`document.head`, but for your convenience the Plugin API provides a simple
+`styleApi().insertCSSRule()` method for doing just that. Typically you would
+define a CSS rule for `html`, which is always applied, or for a specific theme
+such as `html.lightTheme`. 
 
 ``` js
 Gerrit.install(plugin => {
-  const styleEl = document.createElement('style');
-  styleEl.innerHTML = `
-      html {
-        --header-background-color: #c3d9ff;
-      }
-      html.darkTheme {
-        --header-background-color: #c3d9ff90;
-      }
-  `;
-  document.head.appendChild(styleEl);
+  plugin.styleApi().insertCSSRule(`
+    html {
+      --header-text-color: black;
+    }
+  `);
+  plugin.styleApi().insertCSSRule(`
+    html.lightTheme {
+      --header-background-color: red;
+    }
+  `);
+  plugin.styleApi().insertCSSRule(`
+    html.darkTheme {
+      --header-background-color: blue;
+    }
+  `);
 });
 ```
 
@@ -154,12 +163,6 @@
 
 See link:pg-plugin-endpoints.html[endpoints].
 
-=== registerStyleModule
-`plugin.registerStyleModule(endpointName, moduleName)`
-
-This API is deprecated and will be removed either in version 3.6 or 3.7,
-see link:#low-level-style[above] for an alternative.
-
 === on
 Register a JavaScript callback to be invoked when events occur within
 the web interface. Signature
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index 41f544d..dd82f27 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -132,6 +132,10 @@
 === settings-screen
 This endpoint is situated at the end of the body of the settings screen.
 
+=== profile
+This endpoint is situated at the top of the Profile section of the settings
+screen below the section description text.
+
 === reply-text
 This endpoint wraps the textarea in the reply dialog.
 
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 88018a6..2f144c6 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -103,15 +103,17 @@
       "id": "demo~master~Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
       "project": "demo",
       "branch": "master",
-      "attention_set": [
-        {
+      "attention_set": {
+        "1000096": {
           "account": {
-            "name": "John Doe"
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
           },
-         "last_update": "2012-07-17 07:19:27.766000000",
-         "reason": "reviewer or cc replied"
+          "last_update": "2012-07-17 07:19:27.766000000",
+          "reason": "reviewer or cc replied"
         }
-      ]
+      },
       "change_id": "Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
       "subject": "One change",
       "status": "NEW",
@@ -545,15 +547,17 @@
     "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
     "project": "myProject",
     "branch": "master",
-    "attention_set": [
-      {
+    "attention_set": {
+      "1000096": {
         "account": {
-          "name": "John Doe"
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com"
         },
-       "last_update": "2013-02-21 11:16:36.775000000",
-       "reason": "reviewer or cc replied"
+        "last_update": "2013-02-21 11:16:36.775000000",
+        "reason": "reviewer or cc replied"
       }
-    ]
+    },
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
     "subject": "Implementing Feature X",
     "status": "NEW",
@@ -612,15 +616,17 @@
   )]}'
   {
     "added": {
-      "attention_set": [
-        {
+      "attention_set": {
+        "1000096": {
           "account": {
-            "name": "John Doe"
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
           },
-         "last_update": "2013-02-21 11:16:36.775000000",
-         "reason": "reviewer or cc replied"
+          "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"
     },
@@ -651,15 +657,17 @@
       "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
       "project": "myProject",
       "branch": "master",
-      "attention_set": [
-        {
+      "attention_set": {
+        "1000096": {
           "account": {
-            "name": "John Doe"
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
           },
-         "last_update": "2013-02-21 11:16:36.775000000",
-         "reason": "reviewer or cc replied"
+          "last_update": "2013-02-21 11:16:36.775000000",
+          "reason": "reviewer or cc replied"
         }
-      ],
+      },
       "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
       "subject": "Implementing Feature X",
       "status": "NEW",
@@ -719,18 +727,18 @@
     "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
     "project": "myProject",
     "branch": "master",
-    "attention_set": [
-      {
+    "attention_set": {
+      "1000096": {
         "account": {
           "_account_id": 1000096,
           "name": "John Doe",
           "email": "john.doe@example.com",
           "username": "jdoe"
         },
-       "last_update": "2013-02-21 11:16:36.775000000",
-       "reason": "reviewer or cc replied"
+        "last_update": "2013-02-21 11:16:36.775000000",
+        "reason": "reviewer or cc replied"
       }
-    ]
+    },
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
     "subject": "Implementing Feature X",
     "status": "NEW",
@@ -816,6 +824,26 @@
         "+2"
       ]
     },
+    "removable_labels": {
+      "Code-Review": {
+        "-1": [
+          {
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com",
+            "username": "jdoe"
+          }
+        ],
+        "+1": [
+          {
+            "_account_id": 1000097,
+            "name": "Jane Roe",
+            "email": "jane.roe@example.com",
+            "username": "jroe"
+          }
+        ]
+      }
+    },
     "removable_reviewers": [
       {
         "_account_id": 1000096,
@@ -1505,6 +1533,223 @@
   The change could not be rebased due to a path conflict during merge.
 ----
 
+[[rebase-chain]]
+=== Rebase Chain
+--
+'POST /changes/link:#change-id[\{change-id\}]/rebase:chain'
+--
+
+Rebases an ancestry chain of changes.
+
+The operated change is treated as the chain tip. All unsubmitted ancestors are rebased.
+
+Requires a linear ancestry relation (single parenting throughout the chain).
+
+Optionally, the parent revision (of the oldest ancestor to be rebased) can be changed to another
+change, revision or branch through the link:#rebase-input[RebaseInput] entity.
+
+If the chain is outdated, i.e., there's a change that depends on an old revision of its parent, the
+result is the same as individually rebasing all outdated changes on top of their parent's latest
+revision before running the rebase chain action.
+
+.Request
+----
+  POST /changes/myProject~master~I08a021fb07b83fe845140a2c11508b3bdd93b48f/rebase:chain HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "base" : "1234",
+  }
+----
+
+As response a link:#rebase-chain-info[RebaseChainInfo] entity is returned that
+describes the rebased changes. Information about the current patch sets
+are included.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "rebased_changes": [
+      {
+        "id": "myProject~master~I0e534de9d7f0d6f35b71f7d726acf835b2110c66",
+        "project": "myProject",
+        "branch": "master",
+        "hashtags": [
+
+        ],
+        "change_id": "I0e534de9d7f0d6f35b71f7d726acf835b2110c66",
+        "subject": "456",
+        "status": "NEW",
+        "created": "2022-11-21 20: 51: 31.000000000",
+        "updated": "2022-11-21 20: 56: 49.000000000",
+        "submit_type": "MERGE_IF_NECESSARY",
+        "insertions": 0,
+        "deletions": 0,
+        "total_comment_count": 0,
+        "unresolved_comment_count": 0,
+        "has_review_started": true,
+        "meta_rev_id": "a2a6692213f546e1045ecf4647439fac8d6d8faa",
+        "_number": 21,
+        "owner": {
+          "_account_id": 1000000
+        },
+        "current_revision": "c3b2ba222d42a56e05c90f88d4509a124620517d",
+        "revisions": {
+          "c3b2ba222d42a56e05c90f88d4509a124620517d": {
+            "kind": "NO_CHANGE",
+            "_number": 2,
+            "created": "2022-11-21 20: 56: 49.000000000",
+            "uploader": {
+              "_account_id": 1000000
+            },
+            "ref": "refs/changes/21/21/2",
+            "fetch": {
+
+            },
+            "commit": {
+              "parents": [
+                {
+                  "commit": "7803f427dd7c4a2441466e4d740a1850dcee1af4",
+                  "subject": "123"
+                }
+              ],
+              "author": {
+                "name": "Nitzan Gur-Furman",
+                "email": "nitzan@google.com",
+                "date": "2022-11-21 20: 49: 39.000000000",
+                "tz": 60
+              },
+              "committer": {
+                "name": "Administrator",
+                "email": "admin@example.com",
+                "date": "2022-11-21 20: 56: 49.000000000",
+                "tz": 60
+              },
+              "subject": "456",
+              "message": "456\n"
+            },
+            "description": "Rebase"
+          }
+        },
+        "requirements": [
+
+        ],
+        "submit_records": [
+          {
+            "rule_name": "gerrit~DefaultSubmitRule",
+            "status": "NOT_READY",
+            "labels": [
+              {
+                "label": "Code-Review",
+                "status": "NEED"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "id": "myProject~master~I08a021fb07b83fe845140a2c11508b3bdd93b48f",
+        "project": "myProject",
+        "branch": "master",
+        "hashtags": [
+
+        ],
+        "change_id": "I08a021fb07b83fe845140a2c11508b3bdd93b48f",
+        "subject": "789",
+        "status": "NEW",
+        "created": "2022-11-21 20: 51: 31.000000000",
+        "updated": "2022-11-21 20: 56: 49.000000000",
+        "submit_type": "MERGE_IF_NECESSARY",
+        "insertions": 0,
+        "deletions": 0,
+        "total_comment_count": 0,
+        "unresolved_comment_count": 0,
+        "has_review_started": true,
+        "meta_rev_id": "3bfb843fea471f96e16b9199c3a30fff0285bc45",
+        "_number": 22,
+        "owner": {
+          "_account_id": 1000000
+        },
+        "current_revision": "77eb17a9501a5c21963bc6af56085e60f281acbb",
+        "revisions": {
+          "77eb17a9501a5c21963bc6af56085e60f281acbb": {
+            "kind": "NO_CHANGE",
+            "_number": 2,
+            "created": "2022-11-21 20: 56: 49.000000000",
+            "uploader": {
+              "_account_id": 1000000
+            },
+            "ref": "refs/changes/22/22/2",
+            "fetch": {
+
+            },
+            "commit": {
+              "parents": [
+                {
+                  "commit": "c3b2ba222d42a56e05c90f88d4509a124620517d",
+                  "subject": "456"
+                }
+              ],
+              "author": {
+                "name": "Nitzan Gur-Furman",
+                "email": "nitzan@google.com",
+                "date": "2022-11-21 20: 51: 07.000000000",
+                "tz": 60
+              },
+              "committer": {
+                "name": "Administrator",
+                "email": "admin@example.com",
+                "date": "2022-11-21 20: 56: 49.000000000",
+                "tz": 60
+              },
+              "subject": "789",
+              "message": "789\n"
+            },
+            "description": "Rebase"
+          }
+        },
+        "requirements": [
+
+        ],
+        "submit_records": [
+          {
+            "rule_name": "gerrit~DefaultSubmitRule",
+            "status": "NOT_READY",
+            "labels": [
+              {
+                "label": "Code-Review",
+                "status": "NEED"
+              }
+            ]
+          }
+        ]
+      }
+    ],
+  }
+----
+
+If the change cannot be rebased, e.g. due to conflicts, the response is
+"`409 Conflict`" and the error message is contained in the response
+body.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  Change I0e534de9d7f0d6f35b71f7d726acf835b2110c66 could not be rebased due to a conflict during
+  merge.
+
+  merge conflict(s):
+  a.txt
+----
+
 [[move-change]]
 === Move Change
 --
@@ -6520,7 +6765,7 @@
 * a commit ID ("674ac754f91e64a0efb8087e59a176484bd534d1")
 * an abbreviated commit ID that uniquely identifies one revision of the
   change ("674ac754"), at least 4 digits are required
-* a legacy numeric patch number ("1" for first patch set of the change)
+* a numeric patch number ("1" for first patch set of the change)
 * "0" or the literal `edit` for a change edit
 
 [[json-entities]]
@@ -6846,6 +7091,13 @@
 A map of the permitted labels that maps a label name to the list of
 values that are allowed for that label. +
 Only set if link:#detailed-labels[detailed labels] are requested.
+|`removable_labels`   |optional|
+A map of the removable labels that maps a label name to the map of
+values and reviewers (
+link:rest-api-accounts.html#account-info[AccountInfo] entities)
+that are allowed to be removed from the change. +
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`removable_reviewers`|optional|
 The reviewers that can be removed by the calling user as a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities. +
@@ -6964,11 +7216,16 @@
 Whether the new change should be set to work in progress.
 |`base_change`        |optional|
 A link:#change-id[\{change-id\}] that identifies the base change for a create
-change operation. Mutually exclusive with `base_commit`.
+change operation. +
+Mutually exclusive with `base_commit`. +
+If neither `base_commit` nor `base_change` are set, the target branch tip will
+be used as the parent commit.
 |`base_commit`        |optional|
 A 40-digit hex SHA-1 of the commit which will be the parent commit of the newly
-created change. If set, it must be a merged commit on the destination branch.
-Mutually exclusive with `base_change`.
+created change. If set, it must be a merged commit on the destination branch. +
+Mutually exclusive with `base_change`. +
+If neither `base_commit` nor `base_change` are set, the target branch tip will
+be used as the parent commit.
 |`new_branch`         |optional, default to `false`|
 Allow creating a new branch when set to `true`. Using this option is
 only possible for non-merge commits (if the `merge` field is not set).
@@ -7987,6 +8244,22 @@
 options. Unknown validation options are silently ignored.
 |===========================
 
+[[rebase-chain-info]]
+=== RebaseChainInfo
+
+The `RebaseChainInfo` entity contains information about a chain of changes
+that were rebased.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name                ||Description
+|`rebased_changes`         ||List of the unsubmitted ancestors, as link:#change-info[ChangeInfo]
+entities. Includes both rebased changes, and previously up-to-date ancestors. The list is ordered by
+ancestry, where the oldest ancestor is the first.
+|`contains_git_conflicts`  ||Whether any of the rebased changes has conflicts
+due to rebasing.
+|===========================
+
 [[related-change-and-commit-info]]
 === RelatedChangeAndCommitInfo
 
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 9527a56..e12c27c 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -43,6 +43,17 @@
 For more predictable results, use explicit search operators as described
 in the following section.
 
+[IMPORTANT]
+--
+The change search API is backed by a secondary index and might sometimes return
+stale results if the re-indexing operation failed for a change update.
+
+Please also note that changes are not re-indexed if the project configuration
+is updated with newly added or modified
+link:config-submit-requirements.html[submit requirements].
+--
+
+
 [[search-operators]]
 == Search Operators
 
@@ -347,6 +358,18 @@
 regular expressions is limited to the first 32766 bytes of the
 commit message due to limitations in Lucene.
 
+[[subject]]
+subject:'SUBJECT'::
++
+Changes that have a commit message where the first line (aka the subject)
+matches 'SUBJECT'. The matching is done by full text search over the subject.
+
+[[prefixsubject]]
+prefixsubject:'PREFIX'::
++
+Changes that have a commit message where the first line (aka the subject)
+has the prefix 'PREFIX'.
+
 [[comment]]
 comment:'TEXT'::
 +
diff --git a/contrib/git-gc-preserve b/contrib/git-gc-preserve
old mode 100644
new mode 100755
index 54b8fca..a886721
--- a/contrib/git-gc-preserve
+++ b/contrib/git-gc-preserve
@@ -13,25 +13,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-usage() { # error_message
+usage() { # exit code
   cat <<-EOF
 NAME
     git-gc-preserve - Run git gc and preserve old packs to avoid races for JGit
 
-    This command uses custom git config options to configure if preserved packs
-    from the last run of git gc should be pruned and if packs should be preserved.
-
-    This is similar to the implementation in JGit [1] which is used by
-    JGit to avoid errors [2] in such situations.
-
-    Don't run multiple instances of this command concurrently on the same
-    repository since it does not attempt to implement the file locking
-    which git gc --auto does [3].
-
-    [1] https://git.eclipse.org/r/c/jgit/jgit/+/87969
-    [2] https://git.eclipse.org/r/c/jgit/jgit/+/122288
-    [3] https://github.com/git/git/commit/64a99eb4760de2ce2f0c04e146c0a55c34f50f20
-
 SYNOPSIS
     git gc-preserve
 
@@ -39,6 +25,29 @@
     Runs git gc and can preserve old packs to avoid races with concurrently
     executed commands in JGit.
 
+    This command uses custom git config options to configure if preserved packs
+    from the last run of git gc should be pruned and if packs should be preserved.
+
+    This is similar to the implementation in JGit [1] which is used by
+    JGit to avoid errors [2] in such situations.
+
+    The command prevents concurrent runs of the command on the same repository
+    by acquiring an exclusive file lock on the file
+      "\$repopath/gc-preserve.pid"
+    If it cannot acquire the lock it fails immediately with exit code 3.
+
+    Failure Exit Codes
+        1: General failure
+        2: Couldn't determine repository path. If the current working directory
+           is outside of the working tree of the git repository use git option
+           --git-dir to pass the root path of the repository.
+           E.g.
+              $ git --git-dir ~/git/foo gc-preserve
+        3: Another process already runs $0 on the same repository
+
+    [1] https://git.eclipse.org/r/c/jgit/jgit/+/87969
+    [2] https://git.eclipse.org/r/c/jgit/jgit/+/122288
+
 CONFIGURATION
     "gc.prunepreserved": if set to "true" preserved packs from the last gc run
       are pruned before current packs are preserved.
@@ -49,7 +58,30 @@
       across missing objects which might be caused by a concurrent run of
       git gc.
 EOF
-  exit
+  exit "$1"
+}
+
+# acquire file lock, unlock when the script exits
+lock() { # repo
+  readonly LOCKFILE="$1/gc-preserve.pid"
+  test -f "$LOCKFILE" || touch "$LOCKFILE"
+  exec 9> "$LOCKFILE"
+  if flock -nx 9; then
+    echo -n "$$ $USERNAME@$HOSTNAME" >&9
+    trap unlock EXIT
+  else
+    echo "$0 is already running"
+    exit 3
+  fi
+}
+
+unlock() {
+  # only delete if the file descriptor 9 is open
+  if { : >&9 ; } &> /dev/null; then
+    rm -f "$LOCKFILE"
+  fi
+  # close the file handle to release file lock
+  exec 9>&-
 }
 
 # prune preserved packs if gc.prunepreserved == true
@@ -74,7 +106,7 @@
     return 0
   fi
   local packdir=$1/objects/pack
-  pushd "$packdir" >/dev/null || exit
+  pushd "$packdir" >/dev/null || exit 1
   mkdir -p preserved
   printf "Preserving packs: "
   count=0
@@ -85,7 +117,7 @@
     fi
   done
   echo "$count, done."
-  popd >/dev/null || exit
+  popd >/dev/null || exit 1
 }
 
 # pack-0...2.pack to pack-0...2.old-pack
@@ -100,7 +132,7 @@
 
 while [ $# -gt 0 ] ; do
     case "$1" in
-        -u|-h)  usage ;;
+        -u|-h)  usage 0 ;;
     esac
     shift
 done
@@ -108,10 +140,10 @@
 
 repopath=$(git rev-parse --git-dir)
 if [ -z "$repopath" ]; then
-  usage
-  exit $?
+  usage 2
 fi
 
+lock "$repopath"
 prune_preserved "$repopath"
 preserve_packs "$repopath"
-git gc ${args:+"$args"}
+git gc ${args:+"$args"} || { echo "git gc failed"; exit "$?"; }
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 5b4a9e5..ccf74d1 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -1393,6 +1393,17 @@
     }
   }
 
+  protected void assertOnlyRemovableLabel(
+      ChangeInfo info, String labelId, String labelValue, TestAccount reviewer) {
+    assertThat(info.removableLabels).hasSize(1);
+    assertThat(info.removableLabels).containsKey(labelId);
+    assertThat(info.removableLabels.get(labelId)).hasSize(1);
+    assertThat(info.removableLabels.get(labelId)).containsKey(labelValue);
+    assertThat(info.removableLabels.get(labelId).get(labelValue)).hasSize(1);
+    assertThat(info.removableLabels.get(labelId).get(labelValue).get(0).email)
+        .isEqualTo(reviewer.email());
+  }
+
   protected void assertPermissions(
       Project.NameKey project,
       GroupReference groupReference,
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index c67991d..ff5bc00 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -141,6 +141,10 @@
     return create(username, null, username, null, (String[]) null);
   }
 
+  public TestAccount createValid(String username) throws Exception {
+    return create(username, username + "@example.com", username, username);
+  }
+
   public TestAccount admin() throws Exception {
     return create("admin", "admin@example.com", "Administrator", "Adminny", "Administrators");
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
index 7bd0c73..7660948 100644
--- a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
@@ -55,6 +55,11 @@
   }
 
   @Override
+  public void deleteByValue(AccountState value) {
+    throw new UnsupportedOperationException("AccountIndex is disabled");
+  }
+
+  @Override
   public void delete(Account.Id key) {
     throw new UnsupportedOperationException("AccountIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
index 7671ad4..c028a8e 100644
--- a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
@@ -62,6 +62,11 @@
   }
 
   @Override
+  public void deleteByValue(ChangeData value) {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+
+  @Override
   public void delete(Change.Id key) {
     throw new UnsupportedOperationException("ChangeIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
index 2e3dd90..f2aad4a 100644
--- a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
@@ -60,6 +60,11 @@
   }
 
   @Override
+  public void deleteByValue(ProjectData value) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
   public void delete(Project.NameKey key) {
     throw new UnsupportedOperationException("ProjectIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index b1cd506..69139ce 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -197,8 +197,13 @@
         PermissionRule.Builder rule = newRule(projectConfig, p.group());
         rule.setAction(p.action());
         rule.setRange(p.min(), p.max());
-        String permissionName =
-            p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
+        String permissionName;
+        if (p.isAddPermission()) {
+          permissionName =
+              p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
+        } else {
+          permissionName = Permission.forRemoveLabel(p.name());
+        }
         projectConfig.upsertAccessSection(
             p.ref(), as -> as.upsertPermission(permissionName).add(rule));
       }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
index 9a9a21a..5634c78 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -162,12 +162,34 @@
 
   /** Starts a builder for allowing a label permission. */
   public static TestLabelPermission.Builder allowLabel(String name) {
-    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.ALLOW);
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(true)
+        .action(PermissionRule.Action.ALLOW);
   }
 
   /** Starts a builder for denying a label permission. */
   public static TestLabelPermission.Builder blockLabel(String name) {
-    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.BLOCK);
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(true)
+        .action(PermissionRule.Action.BLOCK);
+  }
+
+  /** Starts a builder for allowing a remove-label permission. */
+  public static TestLabelPermission.Builder allowLabelRemoval(String name) {
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(false)
+        .action(PermissionRule.Action.ALLOW);
+  }
+
+  /** Starts a builder for denying a remove-label permission. */
+  public static TestLabelPermission.Builder blockLabelRemoval(String name) {
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(false)
+        .action(PermissionRule.Action.BLOCK);
   }
 
   /** Records a label permission to be updated. */
@@ -191,6 +213,8 @@
 
     abstract boolean impersonation();
 
+    abstract boolean isAddPermission();
+
     /** Builder for {@link TestLabelPermission}. */
     @AutoValue.Builder
     public abstract static class Builder {
@@ -208,6 +232,8 @@
 
       abstract Builder max(int max);
 
+      abstract Builder isAddPermission(boolean isAddPermission);
+
       /** Sets the minimum and maximum values for the permission. */
       public Builder range(int min, int max) {
         checkArgument(min != 0 || max != 0, "empty range");
@@ -243,6 +269,12 @@
     return TestPermissionKey.builder().name(Permission.forLabel(name));
   }
 
+  /** Starts a builder for describing a label removal permission key for deletion. */
+  public static TestPermissionKey.Builder labelRemovalPermissionKey(String name) {
+    checkLabelName(name);
+    return TestPermissionKey.builder().name(Permission.forRemoveLabel(name));
+  }
+
   /** Starts a builder for describing a capability key for deletion. */
   public static TestPermissionKey.Builder capabilityKey(String name) {
     return TestPermissionKey.builder().name(name).section(GLOBAL_CAPABILITIES);
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 6d2fa32..d029fad 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -43,6 +43,7 @@
   public static final String FORGE_SERVER = "forgeServerAsCommitter";
   public static final String LABEL = "label-";
   public static final String LABEL_AS = "labelAs-";
+  public static final String REMOVE_LABEL = "removeLabel-";
   public static final String OWNER = "owner";
   public static final String PUSH = "push";
   public static final String PUSH_MERGE = "pushMerge";
@@ -60,6 +61,7 @@
   private static final List<String> NAMES_LC;
   private static final int LABEL_INDEX;
   private static final int LABEL_AS_INDEX;
+  private static final int REMOVE_LABEL_INDEX;
 
   static {
     NAMES_LC = new ArrayList<>();
@@ -79,6 +81,7 @@
     NAMES_LC.add(FORGE_SERVER.toLowerCase());
     NAMES_LC.add(LABEL.toLowerCase());
     NAMES_LC.add(LABEL_AS.toLowerCase());
+    NAMES_LC.add(REMOVE_LABEL.toLowerCase());
     NAMES_LC.add(OWNER.toLowerCase());
     NAMES_LC.add(PUSH.toLowerCase());
     NAMES_LC.add(PUSH_MERGE.toLowerCase());
@@ -93,15 +96,19 @@
 
     LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
     LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
+    REMOVE_LABEL_INDEX = NAMES_LC.indexOf(Permission.REMOVE_LABEL.toLowerCase());
   }
 
   /** Returns true if the name is recognized as a permission name. */
   public static boolean isPermission(String varName) {
-    return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
+    return isLabel(varName)
+        || isLabelAs(varName)
+        || isRemoveLabel(varName)
+        || NAMES_LC.contains(varName.toLowerCase());
   }
 
   public static boolean hasRange(String varName) {
-    return isLabel(varName) || isLabelAs(varName);
+    return isLabel(varName) || isLabelAs(varName) || isRemoveLabel(varName);
   }
 
   /** Returns true if the permission name is actually for a review label. */
@@ -114,6 +121,11 @@
     return var.startsWith(LABEL_AS) && LABEL_AS.length() < var.length();
   }
 
+  /** Returns true if the permission is for impersonated review labels. */
+  public static boolean isRemoveLabel(String var) {
+    return var.startsWith(REMOVE_LABEL) && REMOVE_LABEL.length() < var.length();
+  }
+
   /** Returns permission name for the given review label. */
   public static String forLabel(String labelName) {
     return LABEL + labelName;
@@ -124,12 +136,19 @@
     return LABEL_AS + labelName;
   }
 
+  /** Returns permission name to remove a label for another user. */
+  public static String forRemoveLabel(String labelName) {
+    return REMOVE_LABEL + labelName;
+  }
+
   @Nullable
   public static String extractLabel(String varName) {
     if (isLabel(varName)) {
       return varName.substring(LABEL.length());
     } else if (isLabelAs(varName)) {
       return varName.substring(LABEL_AS.length());
+    } else if (isRemoveLabel(varName)) {
+      return varName.substring(REMOVE_LABEL.length());
     }
     return null;
   }
@@ -205,6 +224,8 @@
       return LABEL_INDEX;
     } else if (isLabelAs(a.getName())) {
       return LABEL_AS_INDEX;
+    } else if (isRemoveLabel(a.getName())) {
+      return REMOVE_LABEL_INDEX;
     }
 
     int index = NAMES_LC.indexOf(a.getName().toLowerCase());
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index cce28e9..0ebb859 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -27,12 +27,14 @@
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.Arrays;
 import java.util.Collection;
@@ -178,6 +180,24 @@
   /** Rebase the current revision of a change. */
   void rebase(RebaseInput in) throws RestApiException;
 
+  /**
+   * Rebase the current revisions of a change's chain using default options.
+   *
+   * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
+   *     chain
+   */
+  default Response<RebaseChainInfo> rebaseChain() throws RestApiException {
+    return rebaseChain(new RebaseInput());
+  }
+
+  /**
+   * Rebase the current revisions of a change's chain.
+   *
+   * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
+   *     chain
+   */
+  Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException;
+
   /** Deletes a change. */
   void delete() throws RestApiException;
 
@@ -634,6 +654,11 @@
     }
 
     @Override
+    public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void delete() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 40ae2ec..a865187 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -105,6 +105,7 @@
   public Map<String, ActionInfo> actions;
   public Map<String, LabelInfo> labels;
   public Map<String, Collection<String>> permittedLabels;
+  public Map<String, Map<String, List<AccountInfo>>> removableLabels;
   public Collection<AccountInfo> removableReviewers;
   public Map<ReviewerState, Collection<AccountInfo>> reviewers;
   public Map<ReviewerState, Collection<AccountInfo>> pendingReviewers;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
index 24182cc..51c35dc 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -150,16 +150,17 @@
   /** Returns {@code null} if nothing has been added to {@code oldCollection} */
   @Nullable
   private static ImmutableList<?> getAddedForCollection(
-      Collection<?> oldCollection, Collection<?> newCollection) {
-    ImmutableList<?> notInOldCollection = getAdditions(oldCollection, newCollection);
+      @Nullable Collection<?> oldCollection, Collection<?> newCollection) {
+    ImmutableList<?> notInOldCollection = getAdditionsForCollection(oldCollection, newCollection);
     return notInOldCollection.isEmpty() ? null : notInOldCollection;
   }
 
   @Nullable
-  private static ImmutableList<Object> getAdditions(
-      Collection<?> oldCollection, Collection<?> newCollection) {
-    if (oldCollection == null)
-      return newCollection != null ? ImmutableList.copyOf(newCollection) : null;
+  private static ImmutableList<Object> getAdditionsForCollection(
+      @Nullable Collection<?> oldCollection, Collection<?> newCollection) {
+    if (oldCollection == null) {
+      return ImmutableList.copyOf(newCollection);
+    }
 
     Map<Object, List<Object>> duplicatesMap = newCollection.stream().collect(groupingBy(v -> v));
     oldCollection.forEach(
@@ -173,7 +174,18 @@
 
   /** Returns {@code null} if nothing has been added to {@code oldMap} */
   @Nullable
-  private static ImmutableMap<Object, Object> getAddedForMap(Map<?, ?> oldMap, Map<?, ?> newMap) {
+  private static ImmutableMap<Object, Object> getAddedForMap(
+      @Nullable Map<?, ?> oldMap, Map<?, ?> newMap) {
+    ImmutableMap<Object, Object> notInOldMap = getAdditionsForMap(oldMap, newMap);
+    return notInOldMap.isEmpty() ? null : notInOldMap;
+  }
+
+  @Nullable
+  private static ImmutableMap<Object, Object> getAdditionsForMap(
+      @Nullable Map<?, ?> oldMap, Map<?, ?> newMap) {
+    if (oldMap == null) {
+      return ImmutableMap.copyOf(newMap);
+    }
     ImmutableMap.Builder<Object, Object> additionsBuilder = ImmutableMap.builder();
     for (Map.Entry<?, ?> entry : newMap.entrySet()) {
       Object added = getAdded(oldMap.get(entry.getKey()), entry.getValue());
@@ -181,8 +193,7 @@
         additionsBuilder.put(entry.getKey(), added);
       }
     }
-    ImmutableMap<Object, Object> additions = additionsBuilder.build();
-    return additions.isEmpty() ? null : additions;
+    return additionsBuilder.build();
   }
 
   private static Object get(Field field, Object obj) {
diff --git a/java/com/google/gerrit/extensions/common/RebaseChainInfo.java b/java/com/google/gerrit/extensions/common/RebaseChainInfo.java
new file mode 100644
index 0000000..b327007
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/RebaseChainInfo.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+public class RebaseChainInfo {
+  public List<ChangeInfo> rebasedChanges;
+  /**
+   * Whether any of the changes contain conflicts.
+   *
+   * <p>If {@code true}, some of the rebased changes are marked with conflicts.
+   */
+  public Boolean containsGitConflicts;
+}
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 0073ec2..df2c5cb 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -55,6 +55,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -308,6 +309,7 @@
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
+    modules.add(new ProjectQueryBuilderModule());
     modules.add(new PluginApiModule());
     modules.add(new SearchingChangeCacheImplModule());
     modules.add(new InternalAccountDirectoryModule());
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 129d961..961bf9b 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -79,7 +79,6 @@
           "/dashboard/*",
           "/groups/self",
           "/settings/*",
-          "/topic/*",
           "/Documentation/q/*");
 
   /**
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index cc3117d..870d827 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -60,6 +60,9 @@
    */
   void replace(V obj);
 
+  /** Delete a document from the index by value */
+  void deleteByValue(V value);
+
   /**
    * Delete a document from the index by key.
    *
@@ -153,4 +156,14 @@
   default boolean isEnabled() {
     return true;
   }
+
+  /**
+   * Rewriter that should be invoked on queries to this index.
+   *
+   * <p>The default implementation does not do anything. Should be overridden by implementation, if
+   * needed.
+   */
+  default IndexRewriter<V> getIndexRewriter() {
+    return (in, opts) -> in;
+  }
 }
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index 3114b4c..e050f53 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -15,14 +15,12 @@
 package com.google.gerrit.index.project;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.prefix;
 import static com.google.gerrit.index.FieldDef.storedOnly;
 
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 
@@ -38,23 +36,53 @@
         .toByteArray(project.getNameKey());
   }
 
-  public static final FieldDef<ProjectData, String> NAME =
-      exact("name").stored().build(p -> p.getProject().getName());
+  public static final IndexedField<ProjectData, String> NAME_FIELD =
+      IndexedField.<ProjectData>stringBuilder("RepoName")
+          .required()
+          .size(200)
+          .stored()
+          .build(p -> p.getProject().getName());
 
-  public static final FieldDef<ProjectData, String> DESCRIPTION =
-      fullText("description").stored().build(p -> p.getProject().getDescription());
+  public static final IndexedField<ProjectData, String>.SearchSpec NAME_SPEC =
+      NAME_FIELD.exact("name");
 
-  public static final FieldDef<ProjectData, String> PARENT_NAME =
-      exact("parent_name").build(p -> p.getProject().getParentName());
+  public static final IndexedField<ProjectData, String> DESCRIPTION_FIELD =
+      IndexedField.<ProjectData>stringBuilder("Description")
+          .stored()
+          .build(p -> p.getProject().getDescription());
 
-  public static final FieldDef<ProjectData, Iterable<String>> NAME_PART =
-      prefix("name_part").buildRepeatable(p -> SchemaUtil.getNameParts(p.getProject().getName()));
+  public static final IndexedField<ProjectData, String>.SearchSpec DESCRIPTION_SPEC =
+      DESCRIPTION_FIELD.fullText("description");
 
-  public static final FieldDef<ProjectData, String> STATE =
-      exact("state").stored().build(p -> p.getProject().getState().name());
+  public static final IndexedField<ProjectData, String> PARENT_NAME_FIELD =
+      IndexedField.<ProjectData>stringBuilder("ParentName")
+          .build(p -> p.getProject().getParentName());
 
-  public static final FieldDef<ProjectData, Iterable<String>> ANCESTOR_NAME =
-      exact("ancestor_name").buildRepeatable(ProjectData::getParentNames);
+  public static final IndexedField<ProjectData, String>.SearchSpec PARENT_NAME_SPEC =
+      PARENT_NAME_FIELD.exact("parent_name");
+
+  public static final IndexedField<ProjectData, Iterable<String>> NAME_PART_FIELD =
+      IndexedField.<ProjectData>iterableStringBuilder("NamePart")
+          .size(200)
+          .build(p -> SchemaUtil.getNameParts(p.getProject().getName()));
+
+  public static final IndexedField<ProjectData, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+      NAME_PART_FIELD.prefix("name_part");
+
+  public static final IndexedField<ProjectData, String> STATE_FIELD =
+      IndexedField.<ProjectData>stringBuilder("State")
+          .stored()
+          .build(p -> p.getProject().getState().name());
+
+  public static final IndexedField<ProjectData, String>.SearchSpec STATE_SPEC =
+      STATE_FIELD.exact("state");
+
+  public static final IndexedField<ProjectData, Iterable<String>> ANCESTOR_NAME_FIELD =
+      IndexedField.<ProjectData>iterableStringBuilder("AncestorName")
+          .build(ProjectData::getParentNames);
+
+  public static final IndexedField<ProjectData, Iterable<String>>.SearchSpec ANCESTOR_NAME_SPEC =
+      ANCESTOR_NAME_FIELD.exact("ancestor_name");
 
   /**
    * All values of all refs that were used in the course of indexing this document. This covers
diff --git a/java/com/google/gerrit/index/project/ProjectIndex.java b/java/com/google/gerrit/index/project/ProjectIndex.java
index b2ddaff..0aa7393 100644
--- a/java/com/google/gerrit/index/project/ProjectIndex.java
+++ b/java/com/google/gerrit/index/project/ProjectIndex.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
+import java.util.function.Function;
 
 /**
  * Index for Gerrit projects (repositories). This class is mainly used for typing the generic parent
@@ -30,6 +31,8 @@
 
   @Override
   default Predicate<ProjectData> keyPredicate(Project.NameKey nameKey) {
-    return new ProjectPredicate(ProjectField.NAME, nameKey.get());
+    return new ProjectPredicate(ProjectField.NAME_SPEC, nameKey.get());
   }
+
+  Function<ProjectData, Project.NameKey> ENTITY_TO_KEY = (p) -> p.getProject().getNameKey();
 }
diff --git a/java/com/google/gerrit/index/project/ProjectPredicate.java b/java/com/google/gerrit/index/project/ProjectPredicate.java
index 11875ef..0eaf2b6 100644
--- a/java/com/google/gerrit/index/project/ProjectPredicate.java
+++ b/java/com/google/gerrit/index/project/ProjectPredicate.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.index.project;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IndexPredicate;
 
 /** Predicate that is mapped to a field in the project index. */
 public class ProjectPredicate extends IndexPredicate<ProjectData> {
-  public ProjectPredicate(FieldDef<ProjectData, ?> def, String value) {
+  public ProjectPredicate(SchemaField<ProjectData, ?> def, String value) {
     super(def, value);
   }
 }
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 0619566..05c23e1 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -16,6 +16,8 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 
@@ -31,14 +33,26 @@
   static final Schema<ProjectData> V1 =
       schema(
           /* version= */ 1,
-          ProjectField.NAME,
-          ProjectField.DESCRIPTION,
-          ProjectField.PARENT_NAME,
-          ProjectField.NAME_PART,
-          ProjectField.ANCESTOR_NAME);
+          ImmutableList.of(
+              ProjectField.NAME_FIELD,
+              ProjectField.DESCRIPTION_FIELD,
+              ProjectField.PARENT_NAME_FIELD,
+              ProjectField.NAME_PART_FIELD,
+              ProjectField.ANCESTOR_NAME_FIELD),
+          ImmutableList.<IndexedField<ProjectData, ?>.SearchSpec>of(
+              ProjectField.NAME_SPEC,
+              ProjectField.DESCRIPTION_SPEC,
+              ProjectField.PARENT_NAME_SPEC,
+              ProjectField.NAME_PART_SPEC,
+              ProjectField.ANCESTOR_NAME_SPEC));
 
   @Deprecated
-  static final Schema<ProjectData> V2 = schema(V1, ProjectField.STATE, ProjectField.REF_STATE);
+  static final Schema<ProjectData> V2 =
+      schema(
+          V1,
+          ImmutableList.of(ProjectField.REF_STATE),
+          ImmutableList.<IndexedField<ProjectData, ?>>of(ProjectField.STATE_FIELD),
+          ImmutableList.<IndexedField<ProjectData, ?>.SearchSpec>of(ProjectField.STATE_SPEC));
 
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<ProjectData> V3 = schema(V2);
diff --git a/java/com/google/gerrit/index/query/AndPredicate.java b/java/com/google/gerrit/index/query/AndPredicate.java
index 23ae312..fda961d 100644
--- a/java/com/google/gerrit/index/query/AndPredicate.java
+++ b/java/com/google/gerrit/index/query/AndPredicate.java
@@ -134,7 +134,7 @@
       cmp = a.estimateCost() - b.estimateCost();
     }
 
-    if (cmp == 0 && a instanceof DataSource && b instanceof DataSource) {
+    if (cmp == 0 && a instanceof DataSource) {
       DataSource<?> as = (DataSource<?>) a;
       DataSource<?> bs = (DataSource<?>) b;
       cmp = as.getCardinality() - bs.getCardinality();
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index f237006..1c8bbc3 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -268,7 +268,9 @@
                 limit,
                 getRequestedFields());
         logger.atFine().log("Query options: %s", opts);
-        Predicate<T> pred = rewriter.rewrite(q, opts);
+        // Apply index-specific rewrite first
+        Predicate<T> pred = indexes.getSearchIndex().getIndexRewriter().rewrite(q, opts);
+        pred = rewriter.rewrite(pred, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
         }
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index d60be14..92bc126 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -279,6 +279,11 @@
 
     @Override
     public void insert(ChangeData obj) {}
+
+    @Override
+    public void deleteByValue(ChangeData value) {
+      delete(ChangeIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 
   /** Fake implementation of {@link AccountIndex} where all filtering happens in-memory. */
@@ -311,6 +316,11 @@
 
     @Override
     public void insert(AccountState obj) {}
+
+    @Override
+    public void deleteByValue(AccountState value) {
+      delete(AccountIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 
   /** Fake implementation of {@link GroupIndex} where all filtering happens in-memory. */
@@ -344,6 +354,11 @@
 
     @Override
     public void insert(InternalGroup obj) {}
+
+    @Override
+    public void deleteByValue(InternalGroup value) {
+      delete(GroupIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 
   /** Fake implementation of {@link ProjectIndex} where all filtering happens in-memory. */
@@ -376,5 +391,10 @@
 
     @Override
     public void insert(ProjectData obj) {}
+
+    @Override
+    public void deleteByValue(ProjectData value) {
+      delete(ProjectIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 }
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index c158c65..f9dc31a 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -107,6 +107,7 @@
   private final Set<NrtFuture> notDoneNrtFutures;
   private final AutoFlush autoFlush;
   private ScheduledExecutorService autoCommitExecutor;
+  private final Function<V, K> valueToKeyFunction;
 
   @SuppressWarnings("ThreadPriorityCheck")
   AbstractLuceneIndex(
@@ -118,7 +119,8 @@
       String subIndex,
       GerritIndexWriterConfig writerConfig,
       SearcherFactory searcherFactory,
-      AutoFlush autoFlush)
+      AutoFlush autoFlush,
+      Function<V, K> valueToKeyFunction)
       throws IOException {
     this.schema = schema;
     this.sitePaths = sitePaths;
@@ -126,6 +128,7 @@
     this.name = name;
     this.skipFields = skipFields;
     this.autoFlush = autoFlush;
+    this.valueToKeyFunction = valueToKeyFunction;
     String index = Joiner.on('_').skipNulls().join(name, subIndex);
     long commitPeriod = writerConfig.getCommitWithinMs();
 
@@ -299,6 +302,11 @@
   }
 
   @Override
+  public void deleteByValue(V value) {
+    delete(valueToKeyFunction.apply(value));
+  }
+
+  @Override
   public void deleteAll() {
     try {
       writer.deleteAll();
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 84cc83a..024b102 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -85,7 +85,8 @@
         subIndex,
         writerConfig,
         searcherFactory,
-        autoFlush);
+        autoFlush,
+        ChangeIndex.ENTITY_TO_KEY);
   }
 
   @Override
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 2e1771f..9c0baa8 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -108,7 +108,8 @@
         null,
         new GerritIndexWriterConfig(cfg, ACCOUNTS),
         new SearcherFactory(),
-        autoFlush);
+        autoFlush,
+        AccountIndex.ENTITY_TO_KEY);
     this.accountCache = accountCache;
 
     indexWriterConfig = new GerritIndexWriterConfig(cfg, ACCOUNTS);
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index e16adf5..6365260 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -243,6 +243,11 @@
   }
 
   @Override
+  public void deleteByValue(ChangeData value) {
+    delete(ChangeIndex.ENTITY_TO_KEY.apply(value));
+  }
+
+  @Override
   public void delete(Change.Id changeId) {
     Term idTerm = LuceneChangeIndex.idTerm(changeId);
     try {
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index b741042..6301421 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -98,7 +98,8 @@
         null,
         new GerritIndexWriterConfig(cfg, GROUPS),
         new SearcherFactory(),
-        autoFlush);
+        autoFlush,
+        GroupIndex.ENTITY_TO_KEY);
     this.groupCache = groupCache;
 
     indexWriterConfig = new GerritIndexWriterConfig(cfg, GROUPS);
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 6b2b693..911d91f 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.index.project.ProjectField.NAME;
+import static com.google.gerrit.index.project.ProjectField.NAME_SPEC;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
@@ -58,14 +58,14 @@
     implements ProjectIndex {
   private static final String PROJECTS = "projects";
 
-  private static final String NAME_SORT_FIELD = sortFieldName(NAME);
+  private static final String NAME_SORT_FIELD = sortFieldName(NAME_SPEC);
 
   private static Term idTerm(ProjectData projectState) {
     return idTerm(projectState.getProject().getNameKey());
   }
 
   private static Term idTerm(Project.NameKey nameKey) {
-    return QueryBuilder.stringTerm(NAME.getName(), nameKey.get());
+    return QueryBuilder.stringTerm(NAME_SPEC.getName(), nameKey.get());
   }
 
   private final GerritIndexWriterConfig indexWriterConfig;
@@ -98,7 +98,8 @@
         null,
         new GerritIndexWriterConfig(cfg, PROJECTS),
         new SearcherFactory(),
-        autoFlush);
+        autoFlush,
+        ProjectIndex.ENTITY_TO_KEY);
     this.projectCache = projectCache;
 
     indexWriterConfig = new GerritIndexWriterConfig(cfg, PROJECTS);
@@ -109,7 +110,7 @@
   void add(Document doc, Values<ProjectData> values) {
     // Add separate DocValues field for the field that is needed for sorting.
     SchemaField<ProjectData, ?> f = values.getField();
-    if (f == NAME) {
+    if (f == NAME_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(NAME_SORT_FIELD, new BytesRef(value)));
     }
@@ -155,7 +156,7 @@
   @Nullable
   @Override
   protected ProjectData fromDocument(Document doc) {
-    Project.NameKey nameKey = Project.nameKey(doc.getField(NAME.getName()).stringValue());
+    Project.NameKey nameKey = Project.nameKey(doc.getField(NAME_SPEC.getName()).stringValue());
     return projectCache.get().get(nameKey).map(ProjectState::toProjectData).orElse(null);
   }
 }
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 75891fe..0342fe5 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -64,6 +64,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -446,6 +447,7 @@
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
+    modules.add(new ProjectQueryBuilderModule());
     modules.add(new PluginApiModule());
 
     modules.add(new SearchingChangeCacheImplModule(replica));
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 65a81f7..eda6e09 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -100,30 +100,30 @@
           enablePeerIPInReflogRecord,
           Providers.of(null),
           state,
-          null);
+          /* realUser= */ null);
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return create(null, id);
+      return create(/* remotePeer= */ null, id);
     }
 
     @VisibleForTesting
     @UsedAt(UsedAt.Project.GOOGLE)
     public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
-      return runAs(null, id, null, properties);
+      return runAs(/* remotePeer= */ null, id, /* caller= */ null, properties);
     }
 
-    public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
-      return runAs(remotePeer, id, null);
+    public IdentifiedUser create(@Nullable SocketAddress remotePeer, Account.Id id) {
+      return runAs(remotePeer, id, /* caller= */ null);
     }
 
     public IdentifiedUser runAs(
-        SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+        @Nullable SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
       return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
     }
 
     private IdentifiedUser runAs(
-        SocketAddress remotePeer,
+        @Nullable SocketAddress remotePeer,
         Account.Id id,
         @Nullable CurrentUser caller,
         PropertyMap properties) {
@@ -244,7 +244,7 @@
       AccountCache accountCache,
       GroupBackend groupBackend,
       Boolean enablePeerIPInReflogRecord,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Provider<SocketAddress> remotePeerProvider,
       AccountState state,
       @Nullable CurrentUser realUser) {
     this(
@@ -270,7 +270,7 @@
       AccountCache accountCache,
       GroupBackend groupBackend,
       Boolean enablePeerIPInReflogRecord,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
       @Nullable CurrentUser realUser,
       PropertyMap properties) {
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 3d449b7..2962108 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -35,11 +35,13 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.update.RepoContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
@@ -169,4 +171,54 @@
       return src;
     }
   }
+
+  /**
+   * Gets the commit ID for the latest patch-set of a given change.
+   *
+   * <p>This also takes into account the patch sets that are added in the provided {@link
+   * RepoContext}.
+   *
+   * @param ctx to look for pending updates in.
+   * @param notesFactory to fetch existing patch sets with.
+   * @param changeId to get the latest commit for.
+   * @return the latest commit ID.
+   * @throws IOException if no committed nor pending commits found for the change.
+   */
+  public static RevCommit getCurrentRevCommitIncludingPending(
+      RepoContext ctx, ChangeNotes.Factory notesFactory, Change.Id changeId) throws IOException {
+    Map<String, ObjectId> refUpdates = ctx.getRepoView().getRefs(changeId.toRefPrefix());
+    refUpdates.remove("meta");
+    if (!refUpdates.isEmpty()) {
+      Optional<PatchSet.Id> latestPendingPatchSet =
+          refUpdates.keySet().stream()
+              .map(r -> PatchSet.Id.fromRef(changeId.toRefPrefix() + r))
+              .max(PatchSet.Id::compareTo);
+      if (latestPendingPatchSet.isPresent()) {
+        return ctx.getRevWalk().parseCommit(refUpdates.get(latestPendingPatchSet.get().getId()));
+      }
+    }
+    return getCurrentCommittedRevCommit(ctx.getProject(), ctx.getRevWalk(), notesFactory, changeId);
+  }
+
+  /**
+   * Gets the commit ID for the latest committed patch-set of a given change.
+   *
+   * <p>This DOES NOT take into account the patch sets that are added in the provided {@link
+   * RepoContext}.
+   *
+   * @param project name.
+   * @param notesFactory to fetch existing patch sets with.
+   * @param changeId to get the latest commit for.
+   * @return the latest commit ID.
+   * @throws IOException if no committed commits found for the change.
+   */
+  public static RevCommit getCurrentCommittedRevCommit(
+      Project.NameKey project,
+      RevWalk revWalk,
+      ChangeNotes.Factory notesFactory,
+      Change.Id changeId)
+      throws IOException {
+    ChangeNotes notes = notesFactory.createChecked(project, changeId);
+    return revWalk.parseCommit(notes.getCurrentPatchSet().commitId());
+  }
 }
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 827c078..830928a 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -112,19 +111,16 @@
     }
     ChangeNotes changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
     PatchSet ps = psUtil.get(changeNotes, psId);
-    NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
-    if (notify.shouldNotify()) {
-      email
-          .create(
-              ctx,
-              ps,
-              preUpdateMetaId,
-              mailMessage,
-              comments,
-              /* patchSetComment= */ null,
-              /* labels= */ ImmutableList.of())
-          .sendAsync();
-    }
+    email
+        .create(
+            ctx,
+            ps,
+            preUpdateMetaId,
+            mailMessage,
+            comments,
+            /* patchSetComment= */ null,
+            /* labels= */ ImmutableList.of())
+        .sendAsync();
     commentAdded.fire(
         ctx.getChangeData(changeNotes),
         ps,
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index fd08fa8..8f413f9 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -23,7 +23,6 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
@@ -39,21 +38,17 @@
 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.index.change.ChangeField;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.List;
 import java.util.NavigableSet;
+import java.util.Objects;
 import java.util.Set;
 import java.util.TreeSet;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -166,20 +161,17 @@
   private final GitReferenceUpdated gitRefUpdated;
   private final AllUsersName allUsers;
   private final Provider<PersonIdent> serverIdent;
-  private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
   StarredChangesUtil(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
       AllUsersName allUsers,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<InternalChangeQuery> queryProvider) {
+      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
     this.repoManager = repoManager;
     this.gitRefUpdated = gitRefUpdated;
     this.allUsers = allUsers;
     this.serverIdent = serverIdent;
-    this.queryProvider = queryProvider;
   }
 
   public NavigableSet<String> getLabels(Account.Id accountId, Change.Id changeId) {
@@ -238,7 +230,7 @@
       batchUpdate.setAllowNonFastForwards(true);
       batchUpdate.setRefLogIdent(serverIdent.get());
       batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
-      for (Account.Id accountId : byChangeFromIndex(changeId).keySet()) {
+      for (Account.Id accountId : getStars(repo, changeId)) {
         String refName = RefNames.refsStarredChanges(changeId, accountId);
         Ref ref = repo.getRefDatabase().exactRef(refName);
         if (ref != null) {
@@ -264,12 +256,7 @@
   public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       ImmutableMap.Builder<Account.Id, StarRef> builder = ImmutableMap.builder();
-      for (String refPart : getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId))) {
-        Integer id = Ints.tryParse(refPart);
-        if (id == null) {
-          continue;
-        }
-        Account.Id accountId = Account.id(id);
+      for (Account.Id accountId : getStars(repo, changeId)) {
         builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
       }
       return builder.build();
@@ -308,22 +295,15 @@
     }
   }
 
-  public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId) {
-    List<ChangeData> changeData =
-        queryProvider
-            .get()
-            .setRequestedFields(ChangeField.CHANGE_ID_SPEC, ChangeField.STAR_SPEC)
-            .byLegacyChangeId(changeId);
-    if (changeData.size() != 1) {
-      throw new NoSuchChangeException(changeId);
-    }
-    return changeData.get(0).stars();
-  }
-
-  private static Set<String> getRefNames(Repository repo, String prefix) throws IOException {
-    RefDatabase refDb = repo.getRefDatabase();
+  private static Set<Account.Id> getStars(Repository allUsers, Change.Id changeId)
+      throws IOException {
+    String prefix = RefNames.refsStarredChangesPrefix(changeId);
+    RefDatabase refDb = allUsers.getRefDatabase();
     return refDb.getRefsByPrefix(prefix).stream()
         .map(r -> r.getName().substring(prefix.length()))
+        .map(refPart -> Ints.tryParse(refPart))
+        .filter(Objects::nonNull)
+        .map(id -> Account.id(id))
         .collect(toSet());
   }
 
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index c80059b..ca63565 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -82,12 +82,12 @@
      * accounts.
      */
     @UsedAt(UsedAt.Project.PLUGIN_CODE_OWNERS)
-    public AccountControl get(IdentifiedUser identifiedUser) {
+    public AccountControl get(CurrentUser user) {
       return new AccountControl(
           permissionBackend,
           projectCache,
           groupControlFactory,
-          identifiedUser,
+          user,
           userFactory,
           accountVisibility);
     }
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 65eb332..fcfc805 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -132,12 +132,18 @@
     private final String input;
     private final ImmutableList<AccountState> list;
     private final ImmutableList<AccountState> filteredInactive;
+    private final CurrentUser searchedAsUser;
 
     @VisibleForTesting
-    Result(String input, List<AccountState> list, List<AccountState> filteredInactive) {
+    Result(
+        String input,
+        List<AccountState> list,
+        List<AccountState> filteredInactive,
+        CurrentUser searchedAsUser) {
       this.input = requireNonNull(input);
       this.list = canonicalize(list);
       this.filteredInactive = canonicalize(filteredInactive);
+      this.searchedAsUser = requireNonNull(searchedAsUser);
     }
 
     private ImmutableList<AccountState> canonicalize(List<AccountState> list) {
@@ -180,13 +186,21 @@
       }
     }
 
-    public IdentifiedUser asUniqueUser() throws UnresolvableAccountException {
+    private void ensureSelfIsUniqueIdentifiedUser() throws UnresolvableAccountException {
       ensureUnique();
+      if (!searchedAsUser.isIdentifiedUser()) {
+        throw new UnresolvableAccountException(this);
+      }
+    }
+
+    public IdentifiedUser asUniqueUser() throws UnresolvableAccountException {
       if (isSelf()) {
+        ensureSelfIsUniqueIdentifiedUser();
         // In the special case of "self", use the exact IdentifiedUser from the request context, to
         // preserve the peer address and any other per-request state.
-        return self.get().asIdentifiedUser();
+        return searchedAsUser.asIdentifiedUser();
       }
+      ensureUnique();
       return userFactory.create(asUnique());
     }
 
@@ -194,11 +208,10 @@
         throws UnresolvableAccountException {
       ensureUnique();
       if (isSelf()) {
-        // TODO(dborowitz): This preserves old behavior, but it seems wrong to discard the caller.
-        return self.get().asIdentifiedUser();
+        return searchedAsUser.asIdentifiedUser();
       }
       return userFactory.runAs(
-          null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
+          /* remotePeer= */ null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
     }
 
     @VisibleForTesting
@@ -221,16 +234,57 @@
       return false;
     }
 
+    /**
+     * Searches can be done on behalf of either the current user or another provided user. The
+     * results of some searchers, such as BySelf, are affected by the context user.
+     */
+    default boolean requiresContextUser() {
+      return false;
+    }
+
     Optional<I> tryParse(String input) throws IOException;
 
-    Stream<AccountState> search(I input) throws IOException, ConfigInvalidException;
+    /**
+     * This method should be implemented for every searcher which doesn't require a context user.
+     *
+     * @param input to search for
+     * @return stream of the matching accounts
+     * @throws IOException by some subclasses
+     * @throws ConfigInvalidException by some subclasses
+     */
+    default Stream<AccountState> search(I input) throws IOException, ConfigInvalidException {
+      throw new IllegalStateException("search(I) default implementation should never be called.");
+    }
+
+    /**
+     * This method should be implemented for every searcher which requires a context user.
+     *
+     * @param input to search for
+     * @param asUser the context user for the search
+     * @return stream of the matching accounts
+     * @throws IOException by some subclasses
+     * @throws ConfigInvalidException by some subclasses
+     */
+    default Stream<AccountState> search(I input, CurrentUser asUser)
+        throws IOException, ConfigInvalidException {
+      if (!requiresContextUser()) {
+        return search(input);
+      }
+      throw new IllegalStateException(
+          "The searcher requires a context user, but doesn't implement search(input, asUser).");
+    }
 
     boolean shortCircuitIfNoResults();
 
-    default Optional<Stream<AccountState>> trySearch(String input)
+    default Optional<Stream<AccountState>> trySearch(String input, CurrentUser asUser)
         throws IOException, ConfigInvalidException {
       Optional<I> parsed = tryParse(input);
-      return parsed.isPresent() ? Optional.of(search(parsed.get())) : Optional.empty();
+      if (parsed.isEmpty()) {
+        return Optional.empty();
+      }
+      return requiresContextUser()
+          ? Optional.of(search(parsed.get(), asUser))
+          : Optional.of(search(parsed.get()));
     }
   }
 
@@ -251,7 +305,7 @@
     }
   }
 
-  private class BySelf extends StringSearcher {
+  private static class BySelf extends StringSearcher {
     @Override
     public boolean callerShouldFilterOutInactiveCandidates() {
       return false;
@@ -263,17 +317,21 @@
     }
 
     @Override
+    public boolean requiresContextUser() {
+      return true;
+    }
+
+    @Override
     protected boolean matches(String input) {
       return "self".equals(input) || "me".equals(input);
     }
 
     @Override
-    public Stream<AccountState> search(String input) {
-      CurrentUser user = self.get();
-      if (!user.isIdentifiedUser()) {
+    public Stream<AccountState> search(String input, CurrentUser asUser) {
+      if (!asUser.isIdentifiedUser()) {
         return Stream.empty();
       }
-      return Stream.of(user.asIdentifiedUser().state());
+      return Stream.of(asUser.asIdentifiedUser().state());
     }
 
     @Override
@@ -400,9 +458,20 @@
   }
 
   private class ByFullName implements Searcher<AccountState> {
+    boolean allowSkippingVisibilityCheck = true;
+
+    ByFullName() {
+      super();
+    }
+
+    ByFullName(boolean allowSkippingVisibilityCheck) {
+      this();
+      this.allowSkippingVisibilityCheck = allowSkippingVisibilityCheck;
+    }
+
     @Override
     public boolean callerMayAssumeCandidatesAreVisible() {
-      return true; // Rely on enforceVisibility from the index.
+      return allowSkippingVisibilityCheck;
     }
 
     @Override
@@ -424,9 +493,25 @@
   }
 
   private class ByDefaultSearch extends StringSearcher {
+    boolean allowSkippingVisibilityCheck = true;
+
+    ByDefaultSearch() {
+      super();
+    }
+
+    ByDefaultSearch(boolean allowSkippingVisibilityCheck) {
+      this();
+      this.allowSkippingVisibilityCheck = allowSkippingVisibilityCheck;
+    }
+
     @Override
     public boolean callerMayAssumeCandidatesAreVisible() {
-      return true; // Rely on enforceVisibility from the index.
+      return allowSkippingVisibilityCheck;
+    }
+
+    @Override
+    public boolean requiresContextUser() {
+      return true;
     }
 
     @Override
@@ -435,14 +520,14 @@
     }
 
     @Override
-    public Stream<AccountState> search(String input) {
+    public Stream<AccountState> search(String input, CurrentUser asUser) {
       // At this point we have no clue. Just perform a whole bunch of suggestions and pray we come
       // up with a reasonable result list.
       // TODO(dborowitz): This doesn't match the documentation; consider whether it's possible to be
       // more strict here.
       boolean canSeeSecondaryEmails = false;
       try {
-        if (permissionBackend.user(self.get()).test(GlobalPermission.MODIFY_ACCOUNT)) {
+        if (permissionBackend.user(asUser).test(GlobalPermission.MODIFY_ACCOUNT)) {
           canSeeSecondaryEmails = true;
         }
       } catch (PermissionBackendException e) {
@@ -477,6 +562,18 @@
           .addAll(nameOrEmailSearchers)
           .build();
 
+  private final ImmutableList<Searcher<?>> forcedVisibilitySearchers =
+      ImmutableList.of(
+          new ByNameAndEmail(),
+          new ByEmail(),
+          new FromRealm(),
+          new ByFullName(false),
+          new ByDefaultSearch(false),
+          new BySelf(),
+          new ByExactAccountId(),
+          new ByParenthesizedAccountId(),
+          new ByUsername());
+
   private final AccountCache accountCache;
   private final AccountControl.Factory accountControlFactory;
   private final Emails emails;
@@ -538,12 +635,63 @@
    * @throws IOException if an error occurs.
    */
   public Result resolve(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, this::canSeePredicate, AccountResolver::isActive);
+    return searchImpl(
+        input, searchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive);
   }
 
   public Result resolve(String input, Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, this::canSeePredicate, accountActivityPredicate);
+    return searchImpl(
+        input, searchers, self.get(), this::currentUserCanSeePredicate, accountActivityPredicate);
+  }
+
+  /**
+   * Resolves all accounts matching the input string, visible to the provided user.
+   *
+   * <p>The following input formats are recognized:
+   *
+   * <ul>
+   *   <li>The strings {@code "self"} and {@code "me"}, if the provided user is an {@link
+   *       IdentifiedUser}. In this case, may return exactly one inactive account.
+   *   <li>A bare account ID ({@code "18419"}). In this case, may return exactly one inactive
+   *       account. This case short-circuits if the input matches.
+   *   <li>An account ID in parentheses following a full name ({@code "Full Name (18419)"}). This
+   *       case short-circuits if the input matches.
+   *   <li>A username ({@code "username"}).
+   *   <li>A full name and email address ({@code "Full Name <email@example>"}). This case
+   *       short-circuits if the input matches.
+   *   <li>An email address ({@code "email@example"}. This case short-circuits if the input matches.
+   *   <li>An account name recognized by the configured {@link Realm#lookup(String)} Realm}.
+   *   <li>A full name ({@code "Full Name"}).
+   *   <li>As a fallback, a {@link
+   *       com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema,
+   *       boolean, String) default search} against the account index.
+   * </ul>
+   *
+   * @param asUser user to resolve the users by.
+   * @param input input string.
+   * @param forceVisibilityCheck whether to force all searchers to check for visibility.
+   * @return a result describing matching accounts. Never null even if the result set is empty.
+   * @throws ConfigInvalidException if an error occurs.
+   * @throws IOException if an error occurs.
+   */
+  public Result resolveAsUser(CurrentUser asUser, String input, boolean forceVisibilityCheck)
+      throws ConfigInvalidException, IOException {
+    return resolveAsUser(asUser, input, AccountResolver::isActive, forceVisibilityCheck);
+  }
+
+  public Result resolveAsUser(
+      CurrentUser asUser,
+      String input,
+      Predicate<AccountState> accountActivityPredicate,
+      boolean forceVisibilityCheck)
+      throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input,
+        forceVisibilityCheck ? forcedVisibilitySearchers : searchers,
+        asUser,
+        new ProvidedUserCanSeePredicate(asUser),
+        accountActivityPredicate);
   }
 
   /**
@@ -556,22 +704,23 @@
    * instead will be stored as a link to the corresponding Gerrit Account.
    */
   public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, this::canSeePredicate, AccountResolver::allVisible);
+    return searchImpl(
+        input,
+        searchers,
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::allVisible);
   }
 
   public Result resolveIncludeInactiveIgnoreVisibility(String input)
       throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, this::allVisiblePredicate, AccountResolver::allVisible);
+    return searchImpl(
+        input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::allVisible);
   }
 
   public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, this::allVisiblePredicate, AccountResolver::isActive);
-  }
-
-  public Result resolveIgnoreVisibility(
-      String input, Predicate<AccountState> accountActivityPredicate)
-      throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, this::allVisiblePredicate, accountActivityPredicate);
+    return searchImpl(
+        input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::isActive);
   }
 
   /**
@@ -600,7 +749,11 @@
   @Deprecated
   public Result resolveByNameOrEmail(String input) throws ConfigInvalidException, IOException {
     return searchImpl(
-        input, nameOrEmailSearchers, this::canSeePredicate, AccountResolver::isActive);
+        input,
+        nameOrEmailSearchers,
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::isActive);
   }
 
   /**
@@ -619,16 +772,26 @@
     return searchImpl(
         input,
         ImmutableList.of(new ByNameAndEmail(), new ByEmail(), new ByFullName(), new ByUsername()),
-        this::canSeePredicate,
+        self.get(),
+        this::currentUserCanSeePredicate,
         AccountResolver::isActive);
   }
 
-  private Predicate<AccountState> canSeePredicate() {
-    return this::canSee;
+  private Predicate<AccountState> currentUserCanSeePredicate() {
+    return accountControlFactory.get()::canSee;
   }
 
-  private boolean canSee(AccountState accountState) {
-    return accountControlFactory.get().canSee(accountState);
+  private class ProvidedUserCanSeePredicate implements Supplier<Predicate<AccountState>> {
+    CurrentUser asUser;
+
+    ProvidedUserCanSeePredicate(CurrentUser asUser) {
+      this.asUser = asUser;
+    }
+
+    @Override
+    public Predicate<AccountState> get() {
+      return accountControlFactory.get(asUser.asIdentifiedUser())::canSee;
+    }
   }
 
   private Predicate<AccountState> allVisiblePredicate() {
@@ -648,14 +811,16 @@
   Result searchImpl(
       String input,
       ImmutableList<Searcher<?>> searchers,
+      CurrentUser asUser,
       Supplier<Predicate<AccountState>> visibilitySupplier,
       Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
+    requireNonNull(asUser);
     visibilitySupplier = Suppliers.memoize(visibilitySupplier::get);
     List<AccountState> inactive = new ArrayList<>();
 
     for (Searcher<?> searcher : searchers) {
-      Optional<Stream<AccountState>> maybeResults = searcher.trySearch(input);
+      Optional<Stream<AccountState>> maybeResults = searcher.trySearch(input, asUser);
       if (!maybeResults.isPresent()) {
         continue;
       }
@@ -677,22 +842,25 @@
       }
 
       if (!list.isEmpty()) {
-        return createResult(input, list);
+        return createResult(input, list, asUser);
       }
       if (searcher.shortCircuitIfNoResults()) {
         // For a short-circuiting searcher, return results even if empty.
-        return !inactive.isEmpty() ? emptyResult(input, inactive) : createResult(input, list);
+        return !inactive.isEmpty()
+            ? emptyResult(input, inactive, asUser)
+            : createResult(input, list, asUser);
       }
     }
-    return emptyResult(input, inactive);
+    return emptyResult(input, inactive, asUser);
   }
 
-  private Result createResult(String input, List<AccountState> list) {
-    return new Result(input, list, ImmutableList.of());
+  private Result createResult(String input, List<AccountState> list, CurrentUser searchedAsUser) {
+    return new Result(input, list, ImmutableList.of(), searchedAsUser);
   }
 
-  private Result emptyResult(String input, List<AccountState> inactive) {
-    return new Result(input, ImmutableList.of(), inactive);
+  private Result emptyResult(
+      String input, List<AccountState> inactive, CurrentUser searchedAsUser) {
+    return new Result(input, ImmutableList.of(), inactive, searchedAsUser);
   }
 
   private Stream<AccountState> toAccountStates(Set<Account.Id> ids) {
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 8d5fea4..d6ea294 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -202,7 +202,6 @@
   private ExternalIdNotes externalIdNotes;
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -228,7 +227,6 @@
   }
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
diff --git a/java/com/google/gerrit/server/account/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
index 1e28d7d..46c730c 100644
--- a/java/com/google/gerrit/server/account/GroupCache.java
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.exceptions.StorageException;
 import java.util.Collection;
 import java.util.Map;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Tracks group objects in memory for efficient access. */
 public interface GroupCache {
@@ -62,6 +66,22 @@
   Map<AccountGroup.UUID, InternalGroup> get(Collection<AccountGroup.UUID> groupUuids);
 
   /**
+   * Returns an {@code InternalGroup} instance for the given {@code AccountGroup.UUID} at the given
+   * {@code metaId} of {@link com.google.gerrit.entities.RefNames#refsGroups} ref.
+   *
+   * <p>The caller is responsible to ensure the presence of {@code metaId} and the corresponding
+   * meta ref.
+   *
+   * @param groupUuid the UUID of the internal group
+   * @param metaId the sha1 of commit in {@link com.google.gerrit.entities.RefNames#refsGroups} ref.
+   * @return the internal group at specific sha1 {@code metaId}
+   * @throws StorageException if no internal group with this UUID exists on this server at the
+   *     specific sha1, or if an error occurred during lookup.
+   */
+  @UsedAt(Project.GOOGLE)
+  InternalGroup getFromMetaId(AccountGroup.UUID groupUuid, ObjectId metaId) throws StorageException;
+
+  /**
    * 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.
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index 2d947ba..6f4fce9 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache;
@@ -122,15 +123,19 @@
   private final LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId;
   private final LoadingCache<String, Optional<InternalGroup>> byName;
   private final LoadingCache<String, Optional<InternalGroup>> byUUID;
+  private final LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedByUuidCache;
 
   @Inject
   GroupCacheImpl(
       @Named(BYID_NAME) LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId,
       @Named(BYNAME_NAME) LoadingCache<String, Optional<InternalGroup>> byName,
-      @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID) {
+      @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID,
+      @Named(BYUUID_NAME_PERSISTED)
+          LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedByUuidCache) {
     this.byId = byId;
     this.byName = byName;
     this.byUUID = byUUID;
+    this.persistedByUuidCache = persistedByUuidCache;
   }
 
   @Override
@@ -185,6 +190,21 @@
   }
 
   @Override
+  public InternalGroup getFromMetaId(AccountGroup.UUID groupUuid, ObjectId metaId)
+      throws StorageException {
+    Cache.GroupKeyProto key =
+        Cache.GroupKeyProto.newBuilder()
+            .setUuid(groupUuid.get())
+            .setRevision(ObjectIdConverter.create().toByteString(metaId))
+            .build();
+    try {
+      return persistedByUuidCache.get(key);
+    } catch (ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
   public void evict(AccountGroup.Id groupId) {
     if (groupId != null) {
       logger.atFine().log("Evict group %s by ID", groupId.get());
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index e0569f4..66a845a 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
@@ -100,6 +101,7 @@
 import com.google.gerrit.server.restapi.change.PutMessage;
 import com.google.gerrit.server.restapi.change.PutTopic;
 import com.google.gerrit.server.restapi.change.Rebase;
+import com.google.gerrit.server.restapi.change.RebaseChain;
 import com.google.gerrit.server.restapi.change.Restore;
 import com.google.gerrit.server.restapi.change.Revert;
 import com.google.gerrit.server.restapi.change.RevertSubmission;
@@ -144,6 +146,7 @@
   private final ApplyPatch applyPatch;
   private final Provider<SubmittedTogether> submittedTogether;
   private final Rebase.CurrentRevision rebase;
+  private final RebaseChain rebaseChain;
   private final DeleteChange deleteChange;
   private final GetTopic getTopic;
   private final PutTopic putTopic;
@@ -197,6 +200,7 @@
       ApplyPatch applyPatch,
       Provider<SubmittedTogether> submittedTogether,
       Rebase.CurrentRevision rebase,
+      RebaseChain rebaseChain,
       DeleteChange deleteChange,
       GetTopic getTopic,
       PutTopic putTopic,
@@ -248,6 +252,7 @@
     this.applyPatch = applyPatch;
     this.submittedTogether = submittedTogether;
     this.rebase = rebase;
+    this.rebaseChain = rebaseChain;
     this.deleteChange = deleteChange;
     this.getTopic = getTopic;
     this.putTopic = putTopic;
@@ -427,6 +432,15 @@
   }
 
   @Override
+  public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException {
+    try {
+      return rebaseChain.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase chain", e);
+    }
+  }
+
+  @Override
   public void delete() throws RestApiException {
     try {
       deleteChange.apply(change, null);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java b/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java
new file mode 100644
index 0000000..8ed1175
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import com.google.gerrit.server.query.project.ProjectQueryBuilder;
+import com.google.gerrit.server.query.project.ProjectQueryBuilderImpl;
+import com.google.inject.AbstractModule;
+
+public class ProjectQueryBuilderModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(ProjectQueryBuilder.class).to(ProjectQueryBuilderImpl.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 2883ef8..8773bb7 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -525,7 +525,7 @@
   public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
-    if (sendMail && notify.shouldNotify()) {
+    if (sendMail) {
       Runnable sender =
           new Runnable() {
             @Override
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 500bb77..912d202 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -146,7 +146,7 @@
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
       ChangeField.SUBMIT_RULE_OPTIONS_STRICT.toBuilder().build();
 
-  static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
+  public static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
       ImmutableSet.of(
           ALL_COMMITS,
           ALL_REVISIONS,
@@ -688,6 +688,7 @@
             !cd.change().isAbandoned()
                 ? labelsJson.permittedLabels(user.getAccountId(), cd)
                 : ImmutableMap.of();
+        out.removableLabels = labelsJson.removableLabels(accountLoader, user, cd);
       }
     }
 
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index b512a2d..f3fd68e 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -76,9 +76,6 @@
     if (sendEmail) {
       try {
         NotifyResolver.Result notify = ctx.getNotify(change.getId());
-        if (!notify.shouldNotify()) {
-          return;
-        }
         DeleteReviewerSender emailSender =
             deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 1199be5..afb9d76 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -193,15 +193,13 @@
         notify = notify.withHandling(NotifyHandling.OWNER);
       }
       try {
-        if (notify.shouldNotify()) {
-          emailReviewers(
-              ctx.getProject(),
-              currChange,
-              mailMessage,
-              Timestamp.from(ctx.getWhen()),
-              notify,
-              ctx.getRepoView());
-        }
+        emailReviewers(
+            ctx.getProject(),
+            currChange,
+            mailMessage,
+            Timestamp.from(ctx.getWhen()),
+            notify,
+            ctx.getRepoView());
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot email update for change %s", currChange.getId());
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
index f6ae6a3..f67ce4a 100644
--- a/java/com/google/gerrit/server/change/EmailNewPatchSet.java
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -52,7 +53,7 @@
     EmailNewPatchSet create(
         PostUpdateContext postUpdateContext,
         PatchSet patchSet,
-        String message,
+        @Nullable String message,
         ImmutableSet<PatchSetApproval> outdatedApprovals,
         @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
         @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
@@ -75,7 +76,7 @@
       MessageIdGenerator messageIdGenerator,
       @Assisted PostUpdateContext postUpdateContext,
       @Assisted PatchSet patchSet,
-      @Assisted String message,
+      @Nullable @Assisted String message,
       @Assisted ImmutableSet<PatchSetApproval> outdatedApprovals,
       @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
       @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
diff --git a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
index 194a4f0..9a75469 100644
--- a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
+++ b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
@@ -65,6 +65,33 @@
    */
   public List<RelatedChangesSorter.PatchSetData> getRelated(ChangeData changeData, PatchSet basePs)
       throws IOException, PermissionBackendException {
+    List<ChangeData> cds = getUnsortedRelated(changeData, basePs, false);
+    if (cds.isEmpty()) {
+      return Collections.emptyList();
+    }
+    return sorter.sort(cds, basePs);
+  }
+
+  /**
+   * Gets ancestor changes of a specific change revision.
+   *
+   * @param changeData the change of the inputted revision.
+   * @param basePs the revision that the method checks for related changes.
+   * @param alwaysIncludeOriginalChange whether to return the given change when no ancestors found.
+   * @return list of ancestor changes, sorted via {@link RelatedChangesSorter}
+   */
+  public List<RelatedChangesSorter.PatchSetData> getAncestors(
+      ChangeData changeData, PatchSet basePs, boolean alwaysIncludeOriginalChange)
+      throws IOException, PermissionBackendException {
+    List<ChangeData> cds = getUnsortedRelated(changeData, basePs, alwaysIncludeOriginalChange);
+    if (cds.isEmpty()) {
+      return Collections.emptyList();
+    }
+    return sorter.sortAncestors(cds, basePs);
+  }
+
+  private List<ChangeData> getUnsortedRelated(
+      ChangeData changeData, PatchSet basePs, boolean alwaysIncludeOriginalChange) {
     Set<String> groups = getAllGroups(changeData.patchSets());
     logger.atFine().log("groups = %s", groups);
     if (groups.isEmpty()) {
@@ -78,12 +105,10 @@
       return Collections.emptyList();
     }
     if (cds.size() == 1 && cds.get(0).getId().equals(changeData.getId())) {
-      return Collections.emptyList();
+      return alwaysIncludeOriginalChange ? cds : Collections.emptyList();
     }
 
-    cds = reloadChangeIfStale(cds, changeData, basePs);
-
-    return sorter.sort(cds, basePs);
+    return reloadChangeIfStale(cds, changeData, basePs);
   }
 
   private List<ChangeData> reloadChangeIfStale(
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index cfa15ae..5555ba6 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -36,15 +36,19 @@
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.extensions.common.AccountInfo;
 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.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLoader;
 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.project.DeleteVoteControl;
+import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -69,10 +73,17 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
+  private final DeleteVoteControl deleteVoteControl;
+  private final RemoveReviewerControl removeReviewerControl;
 
   @Inject
-  LabelsJson(PermissionBackend permissionBackend) {
+  LabelsJson(
+      PermissionBackend permissionBackend,
+      DeleteVoteControl deleteVoteControl,
+      RemoveReviewerControl removeReviewerControl) {
     this.permissionBackend = permissionBackend;
+    this.deleteVoteControl = deleteVoteControl;
+    this.removeReviewerControl = removeReviewerControl;
   }
 
   /**
@@ -133,6 +144,46 @@
     return permitted.asMap();
   }
 
+  /**
+   * Returns A map of all labels that the provided user has permission to remove.
+   *
+   * @param accountLoader to load the reviewers' data with.
+   * @param user a Gerrit user.
+   * @param cd {@link ChangeData} corresponding to a specific gerrit change.
+   * @return A Map of {@code labelName} -> {Map of {@code value} -> List of {@link AccountInfo}}
+   *     that the user can remove votes from.
+   */
+  Map<String, Map<String, List<AccountInfo>>> removableLabels(
+      AccountLoader accountLoader, CurrentUser user, ChangeData cd)
+      throws PermissionBackendException {
+    if (cd.change().isMerged()) {
+      return new HashMap<>();
+    }
+
+    Map<String, Map<String, List<AccountInfo>>> res = new HashMap<>();
+    LabelTypes labelTypes = cd.getLabelTypes();
+    for (PatchSetApproval approval : cd.currentApprovals()) {
+      Optional<LabelType> labelType = labelTypes.byLabel(approval.labelId());
+      if (!labelType.isPresent()) {
+        continue;
+      }
+      if (!(deleteVoteControl.testDeleteVotePermissions(user, cd, approval, labelType.get())
+          || removeReviewerControl.testRemoveReviewer(
+              cd, user, approval.accountId(), approval.value()))) {
+        continue;
+      }
+      if (!res.containsKey(approval.label())) {
+        res.put(approval.label(), new HashMap<>());
+      }
+      String labelValue = LabelValue.formatValue(approval.value());
+      if (!res.get(approval.label()).containsKey(labelValue)) {
+        res.get(approval.label()).put(labelValue, new ArrayList<>());
+      }
+      res.get(approval.label()).get(labelValue).add(accountLoader.get(approval.accountId()));
+    }
+    return res;
+  }
+
   private static void clearOnlyZerosEntries(SetMultimap<String, String> permitted) {
     List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
     for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
@@ -217,10 +268,10 @@
     }
   }
 
-  private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd) {
+  private Map<String, Short> currentLabels(@Nullable Account.Id accountId, ChangeData cd) {
     Map<String, Short> result = new HashMap<>();
     for (PatchSetApproval psa : cd.currentApprovals()) {
-      if (psa.accountId().equals(accountId)) {
+      if (accountId == null || psa.accountId().equals(accountId)) {
         result.put(psa.label(), psa.value());
       }
     }
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 1fe67af..cff3de2 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -22,6 +22,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -361,9 +362,7 @@
   @Override
   public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
-    if (notify.shouldNotify() && sendEmail) {
-      requireNonNull(mailMessage);
-
+    if (sendEmail) {
       emailNewPatchSetFactory
           .create(
               ctx,
@@ -372,8 +371,8 @@
               approvalCopierResult.outdatedApprovals().stream()
                   .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
                   .collect(toImmutableSet()),
-              oldReviewers.byState(REVIEWER),
-              oldReviewers.byState(CC),
+              oldReviewers == null ? ImmutableSet.of() : oldReviewers.byState(REVIEWER),
+              oldReviewers == null ? ImmutableSet.of() : oldReviewers.byState(CC),
               changeKind,
               preUpdateMetaId)
           .sendAsync();
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 4de21d6..49ec812 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -22,7 +22,9 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -30,6 +32,8 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -46,8 +50,8 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
@@ -73,19 +77,24 @@
 public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
     RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
+
+    RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, Change.Id baseChangeId);
   }
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final MergeUtilFactory mergeUtilFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeNotes.Factory notesFactory;
 
   private final ChangeNotes notes;
   private final PatchSet originalPatchSet;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final ProjectCache projectCache;
+  private final Project.NameKey projectName;
 
   private ObjectId baseCommitId;
+  private Change.Id baseChangeId;
   private PersonIdent committerIdent;
   private boolean fireRevisionCreated = true;
   private boolean validate = true;
@@ -104,26 +113,78 @@
   private PatchSetInserter patchSetInserter;
   private PatchSet rebasedPatchSet;
 
-  @Inject
+  @AssistedInject
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
       MergeUtilFactory mergeUtilFactory,
       RebaseUtil rebaseUtil,
       ChangeResource.Factory changeResourceFactory,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
       ProjectCache projectCache,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet originalPatchSet,
       @Assisted ObjectId baseCommitId) {
+    this(
+        patchSetInserterFactory,
+        mergeUtilFactory,
+        rebaseUtil,
+        changeResourceFactory,
+        notesFactory,
+        identifiedUserFactory,
+        projectCache,
+        notes,
+        originalPatchSet);
+    this.baseCommitId = baseCommitId;
+    this.baseChangeId = null;
+  }
+
+  @AssistedInject
+  RebaseChangeOp(
+      PatchSetInserter.Factory patchSetInserterFactory,
+      MergeUtilFactory mergeUtilFactory,
+      RebaseUtil rebaseUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
+      @Assisted PatchSet originalPatchSet,
+      @Assisted Change.Id baseChangeId) {
+    this(
+        patchSetInserterFactory,
+        mergeUtilFactory,
+        rebaseUtil,
+        changeResourceFactory,
+        notesFactory,
+        identifiedUserFactory,
+        projectCache,
+        notes,
+        originalPatchSet);
+    this.baseChangeId = baseChangeId;
+    this.baseCommitId = null;
+  }
+
+  private RebaseChangeOp(
+      PatchSetInserter.Factory patchSetInserterFactory,
+      MergeUtilFactory mergeUtilFactory,
+      RebaseUtil rebaseUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
+      ProjectCache projectCache,
+      ChangeNotes notes,
+      PatchSet originalPatchSet) {
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.rebaseUtil = rebaseUtil;
     this.changeResourceFactory = changeResourceFactory;
+    this.notesFactory = notesFactory;
     this.identifiedUserFactory = identifiedUserFactory;
     this.projectCache = projectCache;
     this.notes = notes;
+    this.projectName = notes.getProjectName();
     this.originalPatchSet = originalPatchSet;
-    this.baseCommitId = baseCommitId;
   }
 
   public RebaseChangeOp setCommitterIdent(PersonIdent committerIdent) {
@@ -204,14 +265,23 @@
 
   @Override
   public void updateRepo(RepoContext ctx)
-      throws MergeConflictException, InvalidChangeOperationException, RestApiException, IOException,
-          NoSuchChangeException, PermissionBackendException {
+      throws InvalidChangeOperationException, RestApiException, IOException, NoSuchChangeException,
+          PermissionBackendException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
     RevWalk rw = ctx.getRevWalk();
     RevCommit original = rw.parseCommit(originalPatchSet.commitId());
     rw.parseBody(original);
-    RevCommit baseCommit = rw.parseCommit(baseCommitId);
+    RevCommit baseCommit;
+    if (baseCommitId != null && baseChangeId == null) {
+      baseCommit = rw.parseCommit(baseCommitId);
+    } else if (baseChangeId != null) {
+      baseCommit =
+          PatchSetUtil.getCurrentRevCommitIncludingPending(ctx, notesFactory, baseChangeId);
+    } else {
+      throw new IllegalStateException(
+          "Exactly one of base commit and base change must be provided.");
+    }
     CurrentUser changeOwner = identifiedUserFactory.create(notes.getChange().getOwner());
 
     String newCommitMessage;
@@ -224,12 +294,12 @@
       newCommitMessage = original.getFullMessage();
     }
 
-    rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage);
+    rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage, notes.getChangeId());
     Base base =
         rebaseUtil.parseBase(
             new RevisionResource(
                 changeResourceFactory.create(notes, changeOwner), originalPatchSet),
-            baseCommitId.name());
+            baseCommit.getName());
 
     rebasedPatchSetId =
         ChangeUtil.nextPatchSetIdFromChangeRefs(
@@ -320,8 +390,7 @@
   }
 
   private MergeUtil newMergeUtil() {
-    ProjectState project =
-        projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
+    ProjectState project = projectCache.get(projectName).orElseThrow(illegalState(projectName));
     return forceContentMerge
         ? mergeUtilFactory.create(project, true)
         : mergeUtilFactory.create(project);
@@ -338,7 +407,11 @@
    * @throws IOException the merge failed for another reason.
    */
   private CodeReviewCommit rebaseCommit(
-      RepoContext ctx, RevCommit original, ObjectId base, String commitMessage)
+      RepoContext ctx,
+      RevCommit original,
+      ObjectId base,
+      String commitMessage,
+      Change.Id originalChangeId)
       throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
 
@@ -372,8 +445,9 @@
 
       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));
+            String.format(
+                "Change %s could not be rebased due to a conflict during merge.\n\n%s",
+                originalChangeId.toString(), MergeUtil.createConflictMessage(conflicts)));
       }
 
       Map<String, MergeResult<? extends Sequence>> mergeResults =
@@ -413,7 +487,6 @@
               cb.getAuthor(), cb.getCommitter().getWhen(), cb.getCommitter().getTimeZone()));
     }
     ObjectId objectId = ctx.getInserter().insert(cb);
-    ctx.getInserter().flush();
     CodeReviewCommit commit = ((CodeReviewRevWalk) ctx.getRevWalk()).parseCommit(objectId);
     commit.setFilesWithGitConflicts(filesWithGitConflicts);
     return commit;
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index ba938ee..8acc925 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -22,12 +22,19 @@
 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.RebaseInput;
+import com.google.gerrit.extensions.restapi.AuthException;
 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.git.ObjectIds;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 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.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Inject;
@@ -46,20 +53,65 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
   private final PatchSetUtil psUtil;
+  private final RebaseChangeOp.Factory rebaseFactory;
 
   @Inject
   RebaseUtil(
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      RebaseChangeOp.Factory rebaseFactory) {
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
     this.psUtil = psUtil;
+    this.rebaseFactory = rebaseFactory;
+  }
+
+  /**
+   * Checks whether the given change fulfills all preconditions to be rebased.
+   *
+   * <p>This method does not check whether the calling user is allowed to rebase the change.
+   */
+  public void verifyRebasePreconditions(RevWalk rw, ChangeNotes changeNotes, PatchSet patchSet)
+      throws ResourceConflictException, IOException {
+    // Not allowed to rebase if the current patch set is locked.
+    psUtil.checkPatchSetNotLocked(changeNotes);
+
+    Change change = changeNotes.getChange();
+    if (!change.isNew()) {
+      throw new ResourceConflictException(
+          String.format("Change %s is %s", change.getId(), ChangeUtil.status(change)));
+    }
+
+    if (!hasOneParent(rw, patchSet)) {
+      throw new ResourceConflictException(
+          String.format(
+              "Error rebasing %s. Cannot rebase %s",
+              change.getId(),
+              countParents(rw, patchSet) > 1 ? "merge commits" : "commit with no ancestor"));
+    }
+  }
+
+  public static boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
+    // Prevent rebase of exotic changes (merge commit, no ancestor).
+    return countParents(rw, ps) == 1;
+  }
+
+  private static int countParents(RevWalk rw, PatchSet ps) throws IOException {
+    RevCommit c = rw.parseCommit(ps.commitId());
+    return c.getParentCount();
+  }
+
+  private static boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
+    ObjectId baseId = base.commitId();
+    ObjectId tipId = tip.commitId();
+    return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
   }
 
   public boolean canRebase(PatchSet patchSet, BranchNameKey dest, Repository git, RevWalk rw) {
     try {
-      findBaseRevision(patchSet, dest, git, rw);
+      @SuppressWarnings("unused")
+      ObjectId base = findBaseRevision(patchSet, dest, git, rw, true);
       return true;
     } catch (RestApiException e) {
       return false;
@@ -129,6 +181,100 @@
   }
 
   /**
+   * Parse or find the commit onto which a patch set should be rebased.
+   *
+   * <p>If a {@code rebaseInput.base} is provided, parse it. Otherwise, finds the latest patch set
+   * of the change corresponding to this commit's parent, or the destination branch tip in the case
+   * where the parent's change is merged.
+   *
+   * @param git the repository.
+   * @param rw the RevWalk.
+   * @param permissionBackend to check base reading permissions with.
+   * @param rsrc to find the base for
+   * @param rebaseInput to optionally parse the base from.
+   * @param verifyNeedsRebase whether to verify if the change base is not already up to date
+   * @return the commit onto which the patch set should be rebased.
+   * @throws RestApiException if rebase is not possible.
+   * @throws IOException if accessing the repository fails.
+   * @throws PermissionBackendException if the user don't have permissions to read the base change.
+   */
+  public ObjectId parseOrFindBaseRevision(
+      Repository git,
+      RevWalk rw,
+      PermissionBackend permissionBackend,
+      RevisionResource rsrc,
+      RebaseInput rebaseInput,
+      boolean verifyNeedsRebase)
+      throws RestApiException, IOException, PermissionBackendException {
+    Change change = rsrc.getChange();
+
+    if (rebaseInput == null || rebaseInput.base == null) {
+      return findBaseRevision(rsrc.getPatchSet(), change.getDest(), git, rw, verifyNeedsRebase);
+    }
+
+    String inputBase = rebaseInput.base.trim();
+
+    if (inputBase.isEmpty()) {
+      return getDestRefTip(git, change.getDest());
+    }
+
+    Base base;
+    try {
+      base = parseBase(rsrc, inputBase);
+    } catch (NoSuchChangeException e) {
+      throw new UnprocessableEntityException(
+          String.format("Base change not found: %s", inputBase), e);
+    }
+    if (base == null) {
+      throw new ResourceConflictException(
+          "base revision is missing from the destination branch: " + inputBase);
+    }
+    return getLatestRevisionForBaseChange(rw, permissionBackend, rsrc, base);
+  }
+
+  private ObjectId getDestRefTip(Repository git, BranchNameKey destRefKey)
+      throws ResourceConflictException, IOException {
+    // Remove existing dependency to other patch set.
+    Ref destRef = git.exactRef(destRefKey.branch());
+    if (destRef == null) {
+      throw new ResourceConflictException(
+          "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
+    }
+    return destRef.getObjectId();
+  }
+
+  private ObjectId getLatestRevisionForBaseChange(
+      RevWalk rw, PermissionBackend permissionBackend, RevisionResource childRsrc, Base base)
+      throws ResourceConflictException, AuthException, PermissionBackendException, IOException {
+
+    Change child = childRsrc.getChange();
+    PatchSet.Id baseId = base.patchSet().id();
+    if (child.getId().equals(baseId.changeId())) {
+      throw new ResourceConflictException(
+          String.format("cannot rebase change %s onto itself", childRsrc.getChange().getId()));
+    }
+
+    permissionBackend.user(childRsrc.getUser()).change(base.notes()).check(ChangePermission.READ);
+
+    Change baseChange = base.notes().getChange();
+    if (!baseChange.getProject().equals(child.getProject())) {
+      throw new ResourceConflictException(
+          "base change is in wrong project: " + baseChange.getProject());
+    } else if (!baseChange.getDest().equals(child.getDest())) {
+      throw new ResourceConflictException(
+          "base change is targeting wrong branch: " + baseChange.getDest());
+    } else if (baseChange.isAbandoned()) {
+      throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
+    } else if (isMergedInto(rw, childRsrc.getPatchSet(), base.patchSet())) {
+      throw new ResourceConflictException(
+          "base change "
+              + baseChange.getKey()
+              + " is a descendant of the current change - recursion not allowed");
+    }
+    return base.patchSet().commitId();
+  }
+
+  /**
    * Find the commit onto which a patch set should be rebased.
    *
    * <p>This is defined as the latest patch set of the change corresponding to this commit's parent,
@@ -138,12 +284,17 @@
    * @param destBranch the destination branch.
    * @param git the repository.
    * @param rw the RevWalk.
+   * @param verifyNeedsRebase whether to verify if the change base is not already up to date
    * @return the commit onto which the patch set should be rebased.
    * @throws RestApiException if rebase is not possible.
    * @throws IOException if accessing the repository fails.
    */
   public ObjectId findBaseRevision(
-      PatchSet patchSet, BranchNameKey destBranch, Repository git, RevWalk rw)
+      PatchSet patchSet,
+      BranchNameKey destBranch,
+      Repository git,
+      RevWalk rw,
+      boolean verifyNeedsRebase)
       throws RestApiException, IOException {
     ObjectId baseId = null;
     RevCommit commit = rw.parseCommit(patchSet.commitId());
@@ -170,7 +321,7 @@
         }
 
         if (depChange.isNew()) {
-          if (depPatchSet.id().equals(depChange.currentPatchSetId())) {
+          if (verifyNeedsRebase && depPatchSet.id().equals(depChange.currentPatchSetId())) {
             throw new ResourceConflictException(
                 "Change is already based on the latest patch set of the dependent change.");
           }
@@ -189,10 +340,29 @@
             "The destination branch does not exist: " + destBranch.branch());
       }
       baseId = destRef.getObjectId();
-      if (baseId.equals(parentId)) {
+      if (verifyNeedsRebase && baseId.equals(parentId)) {
         throw new ResourceConflictException("Change is already up to date.");
       }
     }
     return baseId;
   }
+
+  public RebaseChangeOp getRebaseOp(RevisionResource revRsrc, RebaseInput input, ObjectId baseRev) {
+    return applyRebaseInputToOp(
+        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseRev), input);
+  }
+
+  public RebaseChangeOp getRebaseOp(
+      RevisionResource revRsrc, RebaseInput input, Change.Id baseChange) {
+    return applyRebaseInputToOp(
+        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseChange), input);
+  }
+
+  private RebaseChangeOp applyRebaseInputToOp(RebaseChangeOp op, RebaseInput input) {
+    return op.setForceContentMerge(true)
+        .setAllowConflicts(input.allowConflicts)
+        .setValidationOptions(
+            ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions))
+        .setFireRevisionCreated(true);
+  }
 }
diff --git a/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index b6e3121..f4b1a83c 100644
--- a/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -75,16 +75,7 @@
     checkArgument(!in.isEmpty(), "Input may not be empty");
     // Map of all patch sets, keyed by commit SHA-1.
     Map<ObjectId, PatchSetData> byId = collectById(in);
-    PatchSetData start = byId.get(startPs.commitId());
-    requireNonNull(
-        start,
-        () ->
-            String.format(
-                "commit %s of patch set %s not found in %s",
-                startPs.commitId().name(),
-                startPs.id(),
-                byId.entrySet().stream()
-                    .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
+    PatchSetData start = getCheckedPatchSetData(byId, startPs);
 
     // Map of patch set -> immediate parent.
     ListMultimap<PatchSetData, PatchSetData> parents =
@@ -120,6 +111,34 @@
     return result;
   }
 
+  public List<PatchSetData> sortAncestors(List<ChangeData> in, PatchSet startPs)
+      throws IOException, PermissionBackendException {
+    checkArgument(!in.isEmpty(), "Input may not be empty");
+    // Map of all patch sets, keyed by commit SHA-1.
+    Map<ObjectId, PatchSetData> byId = collectById(in);
+    PatchSetData start = getCheckedPatchSetData(byId, startPs);
+
+    // Map of patch set -> immediate parent.
+    ListMultimap<PatchSetData, PatchSetData> parents =
+        MultimapBuilder.hashKeys(in.size()).arrayListValues(3).build();
+
+    for (ChangeData cd : in) {
+      for (PatchSet ps : cd.patchSets()) {
+        PatchSetData thisPsd = requireNonNull(byId.get(ps.commitId()));
+
+        for (RevCommit p : thisPsd.commit().getParents()) {
+          PatchSetData parentPsd = byId.get(p);
+          if (parentPsd != null) {
+            parents.put(thisPsd, parentPsd);
+          }
+        }
+      }
+    }
+
+    Collection<PatchSetData> ancestors = walkAncestors(parents, start);
+    return List.copyOf(ancestors);
+  }
+
   private Map<ObjectId, PatchSetData> collectById(List<ChangeData> in) throws IOException {
     Project.NameKey project = in.get(0).change().getProject();
     Map<ObjectId, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
@@ -143,6 +162,19 @@
     return result;
   }
 
+  private PatchSetData getCheckedPatchSetData(Map<ObjectId, PatchSetData> byId, PatchSet ps) {
+    PatchSetData psData = byId.get(ps.commitId());
+    return requireNonNull(
+        psData,
+        () ->
+            String.format(
+                "commit %s of patch set %s not found in %s",
+                ps.commitId().name(),
+                ps.id(),
+                byId.entrySet().stream()
+                    .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
+  }
+
   private Collection<PatchSetData> walkAncestors(
       ListMultimap<PatchSetData, PatchSetData> parents, PatchSetData start)
       throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index f3ad4f7..b5e0181 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -277,8 +277,9 @@
     IdentifiedUser reviewerUser;
     boolean exactMatchFound = false;
     try {
-      if (input instanceof InternalReviewerInput
-          && ((InternalReviewerInput) input).skipVisibilityCheck) {
+      if (ReviewerState.REMOVED.equals(input.state)
+          || (input instanceof InternalReviewerInput
+              && ((InternalReviewerInput) input).skipVisibilityCheck)) {
         reviewerUser =
             accountResolver.resolveIncludeInactiveIgnoreVisibility(input.reviewer).asUniqueUser();
       } else {
diff --git a/java/com/google/gerrit/server/change/ValidationOptionsUtil.java b/java/com/google/gerrit/server/change/ValidationOptionsUtil.java
new file mode 100644
index 0000000..137239c
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ValidationOptionsUtil.java
@@ -0,0 +1,38 @@
+// 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.change;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+
+/** Utilities for validation options parsing. */
+public final class ValidationOptionsUtil {
+  public static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
+      @Nullable Map<String, String> validationOptions) {
+    if (validationOptions == null) {
+      return ImmutableListMultimap.of();
+    }
+
+    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
+        ImmutableListMultimap.builder();
+    validationOptions
+        .entrySet()
+        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
+    return validationOptionsBuilder.build();
+  }
+
+  private ValidationOptionsUtil() {}
+}
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index 5a4580c..32ec401 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -20,14 +20,9 @@
 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";
-
-  public static String UI_FEATURE_SUBMIT_REQUIREMENTS_UI = "UiFeature__submit_requirements_ui";
-
   public static String GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION =
       "GerritBackendFeature__attach_nonce_to_documentation";
 
   /** Features, enabled by default in the current release. */
-  public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
-      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS, UI_FEATURE_SUBMIT_REQUIREMENTS_UI);
+  public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = ImmutableSet.of();
 }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 2841f92..f0b2a78 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -41,6 +40,7 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RevertedSender;
@@ -64,7 +64,6 @@
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
@@ -317,7 +316,8 @@
             .create(changeId, revertCommit, changeToRevert.getDest().branch())
             .setTopic(input.topic == null ? changeToRevert.getTopic() : input.topic.trim());
     ins.setMessage("Uploaded patch set 1.");
-    ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
+    ins.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
 
     ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
 
@@ -344,20 +344,6 @@
     return changeId;
   }
 
-  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
-      @Nullable Map<String, String> validationOptions) {
-    if (validationOptions == null) {
-      return ImmutableListMultimap.of();
-    }
-
-    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
-        ImmutableListMultimap.builder();
-    validationOptions
-        .entrySet()
-        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
-    return validationOptionsBuilder.build();
-  }
-
   /**
    * Notify the owners of a change that their change is being reverted.
    *
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index c0c934b..87d8db1 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -115,7 +115,6 @@
   private final RetryHelper retryHelper;
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -150,7 +149,6 @@
   }
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -185,7 +183,6 @@
         Optional.of(currentUser));
   }
 
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   private GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 213094e..352d376 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -116,9 +116,9 @@
    */
   public static Set<String> projectFields(QueryOptions opts) {
     Set<String> fs = opts.fields();
-    return fs.contains(ProjectField.NAME.getName())
+    return fs.contains(ProjectField.NAME_SPEC.getName())
         ? fs
-        : Sets.union(fs, ImmutableSet.of(ProjectField.NAME.getName()));
+        : Sets.union(fs, ImmutableSet.of(ProjectField.NAME_SPEC.getName()));
   }
 
   private IndexUtils() {
diff --git a/java/com/google/gerrit/server/index/account/AccountIndex.java b/java/com/google/gerrit/server/index/account/AccountIndex.java
index ca7264c..66b85af 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndex.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.query.account.AccountPredicates;
+import java.util.function.Function;
 
 /**
  * Index for Gerrit accounts. This class is mainly used for typing the generic parent class that
@@ -32,4 +33,6 @@
   default Predicate<AccountState> keyPredicate(Account.Id id) {
     return AccountPredicates.id(getSchema(), id);
   }
+
+  Function<AccountState, Account.Id> ENTITY_TO_KEY = (a) -> a.account().id();
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 254a7e5..1c89566f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -988,7 +988,7 @@
 
   /** Serialized change object, used for pre-populating results. */
   private static final TypeToken<Entities.Change> CHANGE_TYPE_TOKEN =
-      new TypeToken<Entities.Change>() {
+      new TypeToken<>() {
         private static final long serialVersionUID = 1L;
       };
 
@@ -1007,7 +1007,7 @@
 
   /** Serialized approvals for the current patch set, used for pre-populating results. */
   private static final TypeToken<Iterable<Entities.PatchSetApproval>> APPROVAL_TYPE_TOKEN =
-      new TypeToken<Iterable<Entities.PatchSetApproval>>() {
+      new TypeToken<>() {
         private static final long serialVersionUID = 1L;
       };
 
@@ -1086,7 +1086,7 @@
   public static final IndexedField<ChangeData, String>.SearchSpec COMMIT_MESSAGE_EXACT =
       COMMIT_MESSAGE_EXACT_FIELD.exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT);
 
-  /** Commit message of the current patch set. */
+  /** Subject of the current patch set (aka first line of the commit message). */
   public static final IndexedField<ChangeData, String> SUBJECT_FIELD =
       IndexedField.<ChangeData>stringBuilder("Subject")
           .required()
@@ -1095,6 +1095,9 @@
   public static final IndexedField<ChangeData, String>.SearchSpec SUBJECT_SPEC =
       SUBJECT_FIELD.fullText(ChangeQueryBuilder.FIELD_SUBJECT);
 
+  public static final IndexedField<ChangeData, String>.SearchSpec PREFIX_SUBJECT_SPEC =
+      SUBJECT_FIELD.prefix(ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
+
   /** Summary or inline comment. */
   public static final IndexedField<ChangeData, Iterable<String>> COMMENT_FIELD =
       IndexedField.<ChangeData>iterableStringBuilder("Comment")
@@ -1297,7 +1300,7 @@
 
   /** Serialized patch set object, used for pre-populating results. */
   private static final TypeToken<Iterable<Entities.PatchSet>> PATCH_SET_TYPE_TOKEN =
-      new TypeToken<Iterable<Entities.PatchSet>>() {
+      new TypeToken<>() {
         private static final long serialVersionUID = 1L;
       };
 
@@ -1618,7 +1621,7 @@
   /** Serialized submit requirements, used for pre-populating results. */
   private static final TypeToken<Iterable<Cache.SubmitRequirementResultProto>>
       STORED_SUBMIT_REQUIREMENTS_TYPE_TOKEN =
-          new TypeToken<Iterable<Cache.SubmitRequirementResultProto>>() {
+          new TypeToken<>() {
             private static final long serialVersionUID = 1L;
           };
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 6fc2665..74e9af1 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangePredicates;
+import java.util.function.Function;
 
 /**
  * Index for Gerrit changes. This class is mainly used for typing the generic parent class that
@@ -32,4 +33,6 @@
   default Predicate<ChangeData> keyPredicate(Change.Id id) {
     return ChangePredicates.idStr(id);
   }
+
+  Function<ChangeData, Change.Id> ENTITY_TO_KEY = ChangeData::getId;
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 4b88919..dc3907d 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -46,6 +46,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
@@ -283,9 +284,9 @@
    * @param id change to delete.
    * @return future for the deleting task, the result of the future is always {@code null}
    */
-  public ListenableFuture<ChangeData> deleteAsync(Change.Id id) {
+  public ListenableFuture<ChangeData> deleteAsync(Project.NameKey project, Change.Id id) {
     fireChangeScheduledForDeletionFromIndexEvent(id.get());
-    return submit(new DeleteTask(id));
+    return submit(new DeleteTask(id, Optional.of(project)));
   }
 
   /**
@@ -298,8 +299,12 @@
     doDelete(id);
   }
 
+  private void doDelete(Project.NameKey project, Change.Id id) {
+    new DeleteTask(id, Optional.of(project)).call();
+  }
+
   private void doDelete(Change.Id id) {
-    new DeleteTask(id).call();
+    new DeleteTask(id, Optional.empty()).call();
   }
 
   /**
@@ -423,7 +428,7 @@
         doIndex(changeData);
         return changeData;
       } catch (NoSuchChangeException e) {
-        doDelete(id);
+        doDelete(project, id);
       }
       return null;
     }
@@ -456,9 +461,11 @@
   // Not AbstractIndexTask as it doesn't need a request context.
   private class DeleteTask implements Callable<ChangeData> {
     private final Change.Id id;
+    private final Optional<Project.NameKey> project;
 
-    private DeleteTask(Change.Id id) {
+    private DeleteTask(Change.Id id, Optional<Project.NameKey> project) {
       this.id = id;
+      this.project = project;
     }
 
     @Nullable
@@ -476,7 +483,12 @@
                     .changeId(id.get())
                     .indexVersion(i.getSchema().getVersion())
                     .build())) {
-          i.delete(id);
+          // Some index implementation require ProjectKey to build a database key
+          // If delete(K) method is used, this will require changeId -> projectKey lookup (index
+          // query), which is expensive.
+          // Use changeData with ProjectKey and deleteByValue(V) method, if possible
+          project.ifPresentOrElse(
+              p -> i.deleteByValue(changeDataFactory.create(p, id)), () -> i.delete(id));
         } catch (RuntimeException e) {
           throw new StorageException(
               String.format(
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 5dc1500..895c4d8 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -214,6 +214,7 @@
   @Deprecated static final Schema<ChangeData> V78 = schema(V77);
 
   /** Remove draft and star fields. */
+  @Deprecated
   static final Schema<ChangeData> V79 =
       new Schema.Builder<ChangeData>()
           .add(V78)
@@ -222,6 +223,7 @@
           .build();
 
   /** Add subject field. */
+  @Deprecated
   static final Schema<ChangeData> V80 =
       new Schema.Builder<ChangeData>()
           .add(V79)
@@ -229,6 +231,13 @@
           .addSearchSpecs(ChangeField.SUBJECT_SPEC)
           .build();
 
+  /** Add prefixsubject field. */
+  static final Schema<ChangeData> V81 =
+      new Schema.Builder<ChangeData>()
+          .add(V80)
+          .addSearchSpecs(ChangeField.PREFIX_SUBJECT_SPEC)
+          .build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index 26477a4..8f5e36e 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.index.change.ChangeField.CHANGE_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -68,10 +69,13 @@
       int pageSizeMultiplier,
       int limit,
       Set<String> fields) {
-    // Always include project since it is needed to load the change from NoteDb.
-    if (!fields.contains(CHANGE_SPEC.getName()) && !fields.contains(PROJECT_SPEC.getName())) {
+    // Always include project and change id since both are needed to load the change from NoteDb.
+    if (!fields.contains(CHANGE_SPEC.getName())
+        && !(fields.contains(PROJECT_SPEC.getName())
+            && fields.contains(NUMERIC_ID_STR_SPEC.getName()))) {
       fields = new HashSet<>(fields);
       fields.add(PROJECT_SPEC.getName());
+      fields.add(NUMERIC_ID_STR_SPEC.getName());
     }
     return QueryOptions.create(config, start, pageSize, pageSizeMultiplier, limit, fields);
   }
diff --git a/java/com/google/gerrit/server/index/group/GroupIndex.java b/java/com/google/gerrit/server/index/group/GroupIndex.java
index 28c0384..f6a9224 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndex.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.group.GroupPredicates;
+import java.util.function.Function;
 
 /**
  * Index for internal Gerrit groups. This class is mainly used for typing the generic parent class
@@ -33,4 +34,6 @@
   default Predicate<InternalGroup> keyPredicate(AccountGroup.UUID uuid) {
     return GroupPredicates.uuid(uuid);
   }
+
+  Function<InternalGroup, AccountGroup.UUID> ENTITY_TO_KEY = (g) -> g.getGroupUUID();
 }
diff --git a/java/com/google/gerrit/server/index/project/StalenessChecker.java b/java/com/google/gerrit/server/index/project/StalenessChecker.java
index 9c44c00..9f6bb31 100644
--- a/java/com/google/gerrit/server/index/project/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/project/StalenessChecker.java
@@ -40,7 +40,7 @@
  */
 public class StalenessChecker {
   private static final ImmutableSet<String> FIELDS =
-      ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+      ImmutableSet.of(ProjectField.NAME_SPEC.getName(), ProjectField.REF_STATE.getName());
 
   private final ProjectCache projectCache;
   private final ProjectIndexCollection indexes;
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index e362c4b..2b8a501 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -85,7 +85,13 @@
 import java.util.Optional;
 import java.util.Set;
 
-/** A service that can attach the comments from a {@link MailMessage} to a change. */
+/**
+ * Users can post comments on gerrit changes by replying directly to gerrit emails. This service
+ * parses the {@link MailMessage} sent by users and attaches the comments to a change.
+ *
+ * <p>This functionality can be configured or disabled by host. See {@link
+ * com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule}
+ */
 @Singleton
 public class MailProcessor {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
index 23e1cc3..a308168 100644
--- a/java/com/google/gerrit/server/mail/receive/MailReceiver.java
+++ b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
@@ -131,27 +131,20 @@
       if (async) {
         @SuppressWarnings("unused")
         Future<?> possiblyIgnoredError =
-            workQueue
-                .getDefaultQueue()
-                .submit(
-                    () -> {
-                      try {
-                        mailProcessor.process(m);
-                        requestDeletion(m.id());
-                      } catch (RestApiException | UpdateException e) {
-                        logger.atSevere().withCause(e).log(
-                            "Mail: Can't process message %s . Won't delete.", m.id());
-                      }
-                    });
+            workQueue.getDefaultQueue().submit(() -> processMessage(m));
       } else {
         // Synchronous processing is used only in tests.
-        try {
-          mailProcessor.process(m);
-          requestDeletion(m.id());
-        } catch (RestApiException | UpdateException e) {
-          logger.atSevere().withCause(e).log("Mail: Can't process messages. Won't delete.");
-        }
+        processMessage(m);
       }
     }
   }
+
+  private void processMessage(MailMessage m) {
+    try {
+      mailProcessor.process(m);
+      requestDeletion(m.id());
+    } catch (RestApiException | UpdateException e) {
+      logger.atSevere().withCause(e).log("Mail: Can't process message %s . Won't delete.", m.id());
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 800066e..968bb1a 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -98,7 +98,7 @@
   }
 
   @Nullable
-  public List<String> getReviewerNames() {
+  private List<String> getReviewerNames() {
     if (reviewers.isEmpty()) {
       return null;
     }
@@ -110,7 +110,7 @@
   }
 
   @Nullable
-  public List<String> getRemovedReviewerNames() {
+  private List<String> getRemovedReviewerNames() {
     if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 9299d74..cbf47c5 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -24,6 +25,7 @@
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
@@ -35,11 +37,13 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.GroupBackedUser;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class ProjectWatch {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -240,15 +244,16 @@
   }
 
   private boolean filterMatch(CurrentUser user, String filter) throws QueryParseException {
-    ChangeQueryBuilder qb;
+    WatcherChangeQueryBuilder qb;
     Predicate<ChangeData> p = null;
 
     if (user == null) {
-      qb = args.queryBuilder.get().asUser(args.anonymousUser.get());
+      qb = WatcherChangeQueryBuilder.asUser(args.queryBuilder.get(), args.anonymousUser.get());
     } else {
-      qb = args.queryBuilder.get().asUser(user);
+      qb = WatcherChangeQueryBuilder.asUser(args.queryBuilder.get(), user);
       p = qb.isVisible();
     }
+    qb.forceAccountVisibilityCheck();
 
     if (filter != null) {
       Predicate<ChangeData> filterPredicate = qb.parse(filter);
@@ -260,4 +265,40 @@
     }
     return p == null || p.asMatchable().match(changeData);
   }
+
+  private static class WatcherChangeQueryBuilder extends ChangeQueryBuilder {
+    private WatcherChangeQueryBuilder(Arguments args) {
+      super(args);
+    }
+
+    public static WatcherChangeQueryBuilder asUser(ChangeQueryBuilder other, CurrentUser user) {
+      return new WatcherChangeQueryBuilder(other.getArgs().asUser(user));
+    }
+
+    @Override
+    protected Predicate<ChangeData> defaultField(String query) throws QueryParseException {
+      if (query.startsWith("refs/")) {
+        return ref(query);
+      }
+
+      // Adapt the capacity of this list when adding more default predicates.
+      List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(11);
+      predicates.add(file(query));
+      try {
+        predicates.add(label(query));
+      } catch (StorageException | IOException | ConfigInvalidException | QueryParseException e) {
+        // Skip.
+      }
+      predicates.add(commit(query));
+      predicates.add(message(query));
+      predicates.add(comment(query));
+      predicates.add(projects(query));
+      predicates.add(ref(query));
+      predicates.add(branch(query));
+      predicates.add(topic(query));
+      // Adapt the capacity of the "predicates" list when adding more default
+      // predicates.
+      return Predicate.or(predicates);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index 2b856fb..43a212c 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -28,6 +28,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multimaps;
 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;
@@ -66,6 +67,7 @@
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.diff.HistogramDiff;
 import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -76,6 +78,7 @@
 /** Implementation of the {@link GitFileDiffCache} */
 @Singleton
 public class GitFileDiffCacheImpl implements GitFileDiffCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private static final String GIT_DIFF = "git_file_diff";
 
   public static Module module() {
@@ -340,8 +343,7 @@
         throws IOException {
       if (!key.useTimeout()) {
         try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
-          FileHeader fileHeader = formatter.get().toFileHeader(diffEntry);
-          return GitFileDiff.create(diffEntry, fileHeader);
+          return GitFileDiff.create(diffEntry, getFileHeader(formatter, diffEntry));
         }
       }
       // This submits the DiffFormatter to a different thread. The CloseablePool and our usage of it
@@ -353,7 +355,7 @@
           diffExecutor.submit(
               () -> {
                 try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
-                  return GitFileDiff.create(diffEntry, formatter.get().toFileHeader(diffEntry));
+                  return GitFileDiff.create(diffEntry, getFileHeader(formatter, diffEntry));
                 }
               });
       try {
@@ -385,6 +387,46 @@
           ? diffEntry.getOldPath()
           : diffEntry.getNewPath();
     }
+
+    private FileHeader getFileHeader(
+        CloseablePool<DiffFormatter>.Handle formatter, DiffEntry diffEntry) throws IOException {
+      logger.atFine().log("getting file header for %s", formatDiffEntryForLogging(diffEntry));
+      try {
+        return formatter.get().toFileHeader(diffEntry);
+      } catch (MissingObjectException e) {
+        throw new IOException(
+            String.format("Failed to get file header for %s", formatDiffEntryForLogging(diffEntry)),
+            e);
+      }
+    }
+
+    private String formatDiffEntryForLogging(DiffEntry diffEntry) {
+      StringBuilder buf = new StringBuilder();
+      buf.append("DiffEntry[");
+      buf.append(diffEntry.getChangeType());
+      buf.append(" ");
+      switch (diffEntry.getChangeType()) {
+        case ADD:
+          buf.append(String.format("%s (%s)", diffEntry.getNewPath(), diffEntry.getNewId().name()));
+          break;
+        case COPY:
+        case RENAME:
+          buf.append(
+              String.format(
+                  "%s (%s) -> %s (%s)",
+                  diffEntry.getOldPath(),
+                  diffEntry.getOldId().name(),
+                  diffEntry.getNewPath(),
+                  diffEntry.getNewId().name()));
+          break;
+        case DELETE:
+        case MODIFY:
+          buf.append(String.format("%s (%s)", diffEntry.getOldPath(), diffEntry.getOldId().name()));
+          break;
+      }
+      buf.append("]");
+      return buf.toString();
+    }
   }
 
   /**
diff --git a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
new file mode 100644
index 0000000..622f0cf
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.server.util.LabelVote;
+
+/** Abstract permission representing a label. */
+public abstract class AbstractLabelPermission implements ChangePermissionOrLabel {
+  public enum ForUser {
+    SELF,
+    ON_BEHALF_OF
+  }
+
+  protected final ForUser forUser;
+  protected final String name;
+
+  /**
+   * Construct a reference to an abstract label permission.
+   *
+   * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public AbstractLabelPermission(ForUser forUser, String name) {
+    this.forUser = requireNonNull(forUser, "ForUser");
+    this.name = LabelType.checkName(name);
+  }
+
+  /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+  public ForUser forUser() {
+    return forUser;
+  }
+
+  /** Returns name of the label, e.g. {@code "Code-Review"}. */
+  public String label() {
+    return name;
+  }
+
+  protected abstract String permissionPrefix();
+
+  protected String permissionName() {
+    if (forUser == ON_BEHALF_OF) {
+      return permissionPrefix() + "As";
+    }
+    return permissionPrefix();
+  }
+
+  @Override
+  public final String describeForException() {
+    if (forUser == ON_BEHALF_OF) {
+      return permissionPrefix() + " on behalf of " + name;
+    }
+    return permissionPrefix() + " " + name;
+  }
+
+  @Override
+  public final int hashCode() {
+    return (permissionPrefix() + name).hashCode();
+  }
+
+  @Override
+  @SuppressWarnings("EqualsGetClass")
+  public final boolean equals(Object other) {
+    if (this.getClass().isAssignableFrom(other.getClass())) {
+      AbstractLabelPermission b = (AbstractLabelPermission) other;
+      return forUser == b.forUser && name.equals(b.name);
+    }
+    return false;
+  }
+
+  @Override
+  public final String toString() {
+    return permissionName() + "[" + name + ']';
+  }
+
+  /** A {@link AbstractLabelPermission} at a specific value. */
+  public abstract static class WithValue implements ChangePermissionOrLabel {
+    private final ForUser forUser;
+    private final LabelVote label;
+
+    /**
+     * Construct a reference to an abstract label permission at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param label label name and vote.
+     */
+    public WithValue(ForUser forUser, LabelVote label) {
+      this.forUser = requireNonNull(forUser, "ForUser");
+      this.label = requireNonNull(label, "LabelVote");
+    }
+
+    /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+    public ForUser forUser() {
+      return forUser;
+    }
+
+    /** Returns name of the label, e.g. {@code "Code-Review"}. */
+    public String label() {
+      return label.label();
+    }
+
+    /** Returns specific value of the label, e.g. 1 or 2. */
+    public short value() {
+      return label.value();
+    }
+
+    public abstract String permissionName();
+
+    @Override
+    public final String describeForException() {
+      if (forUser == ON_BEHALF_OF) {
+        return permissionName() + " on behalf of " + label.formatWithEquals();
+      }
+      return permissionName() + " " + label.formatWithEquals();
+    }
+
+    @Override
+    public final int hashCode() {
+      return (permissionName() + label).hashCode();
+    }
+
+    @Override
+    @SuppressWarnings("EqualsGetClass")
+    public final boolean equals(Object other) {
+      if (this.getClass().isAssignableFrom(other.getClass())) {
+        AbstractLabelPermission.WithValue b = (AbstractLabelPermission.WithValue) other;
+        return forUser == b.forUser && label.equals(b.label);
+      }
+      return false;
+    }
+
+    @Override
+    public final String toString() {
+      if (forUser == ON_BEHALF_OF) {
+        return permissionName() + "As[" + label.format() + ']';
+      }
+      return permissionName() + "[" + label.format() + ']';
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 8d432c8..e36ce7b 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.permissions;
 
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
@@ -216,7 +216,10 @@
     public void check(ChangePermissionOrLabel perm)
         throws AuthException, PermissionBackendException {
       if (!can(perm)) {
-        throw new AuthException(perm.describeForException() + " not permitted");
+        throw new AuthException(
+            perm.describeForException()
+                + " not permitted"
+                + perm.hintForException().map(hint -> " (" + hint + ")").orElse(""));
       }
     }
 
@@ -240,10 +243,10 @@
     private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
       if (perm instanceof ChangePermission) {
         return can((ChangePermission) perm);
-      } else if (perm instanceof LabelPermission) {
-        return can((LabelPermission) perm);
-      } else if (perm instanceof LabelPermission.WithValue) {
-        return can((LabelPermission.WithValue) perm);
+      } else if (perm instanceof AbstractLabelPermission) {
+        return can((AbstractLabelPermission) perm);
+      } else if (perm instanceof AbstractLabelPermission.WithValue) {
+        return can((AbstractLabelPermission.WithValue) perm);
       }
       throw new PermissionBackendException(perm + " unsupported");
     }
@@ -288,11 +291,11 @@
       throw new PermissionBackendException(perm + " unsupported");
     }
 
-    private boolean can(LabelPermission perm) {
+    private boolean can(AbstractLabelPermission perm) {
       return !label(labelPermissionName(perm)).isEmpty();
     }
 
-    private boolean can(LabelPermission.WithValue perm) {
+    private boolean can(AbstractLabelPermission.WithValue perm) {
       PermissionRange r = label(labelPermissionName(perm));
       if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
         return false;
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index 63b0378..6ceed3e 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -16,7 +16,9 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.access.GerritPermission;
+import java.util.Optional;
 
 public enum ChangePermission implements ChangePermissionOrLabel {
   READ,
@@ -53,24 +55,40 @@
    * <p>Before checking this permission, the caller should first verify the current patch set of the
    * change is not locked by calling {@code PatchSetUtil.isPatchSetLocked}.
    */
-  REBASE,
+  REBASE(
+      /* description= */ null,
+      /* hint= */ "change owners and users with the 'Submit' or 'Rebase' permission can rebase"
+          + " if they have the 'Push' permission"),
   REVERT,
   SUBMIT,
   SUBMIT_AS("submit on behalf of other users"),
   TOGGLE_WORK_IN_PROGRESS_STATE;
 
   private final String description;
+  private final String hint;
 
   ChangePermission() {
     this.description = null;
+    this.hint = null;
   }
 
   ChangePermission(String description) {
     this.description = requireNonNull(description);
+    this.hint = null;
+  }
+
+  ChangePermission(@Nullable String description, String hint) {
+    this.description = description;
+    this.hint = requireNonNull(hint);
   }
 
   @Override
   public String describeForException() {
     return description != null ? description : GerritPermission.describeEnumValue(this);
   }
+
+  @Override
+  public Optional<String> hintForException() {
+    return Optional.ofNullable(hint);
+  }
 }
diff --git a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
index 2824efd..9254158 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
@@ -15,6 +15,17 @@
 package com.google.gerrit.server.permissions;
 
 import com.google.gerrit.extensions.api.access.GerritPermission;
+import java.util.Optional;
 
-/** A {@link ChangePermission} or a {@link LabelPermission}. */
-public interface ChangePermissionOrLabel extends GerritPermission {}
+/** A {@link ChangePermission} or a {@link AbstractLabelPermission}. */
+public interface ChangePermissionOrLabel extends GerritPermission {
+  /**
+   * A hint that explains under which conditions this permission is permitted.
+   *
+   * <p>This is useful for permissions that are not directly assigned but are indirectly permitted
+   * by the user having other permissions or being the change owner.
+   */
+  default Optional<String> hintForException() {
+    return Optional.empty();
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 9d69d9b..89f0493 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.api.access.PluginProjectPermission;
-import com.google.gerrit.server.permissions.LabelPermission.ForUser;
+import com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser;
 import java.util.EnumSet;
 import java.util.Optional;
 import java.util.Set;
@@ -160,19 +160,29 @@
     return Optional.ofNullable(CHANGE_PERMISSIONS.inverse().get(permissionName));
   }
 
-  public static String labelPermissionName(LabelPermission labelPermission) {
-    if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
-      return Permission.forLabelAs(labelPermission.label());
+  public static String labelPermissionName(AbstractLabelPermission labelPermission) {
+    if (labelPermission instanceof LabelPermission) {
+      if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
+        return Permission.forLabelAs(labelPermission.label());
+      }
+      return Permission.forLabel(labelPermission.label());
+    } else if (labelPermission instanceof LabelRemovalPermission) {
+      return Permission.forRemoveLabel(labelPermission.label());
     }
-    return Permission.forLabel(labelPermission.label());
+    throw new IllegalStateException("invalid AbstractLabelPermission subtype");
   }
 
   // TODO(dborowitz): Can these share a common superinterface?
-  public static String labelPermissionName(LabelPermission.WithValue labelPermission) {
-    if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
-      return Permission.forLabelAs(labelPermission.label());
+  public static String labelPermissionName(AbstractLabelPermission.WithValue labelPermission) {
+    if (labelPermission instanceof LabelPermission.WithValue) {
+      if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
+        return Permission.forLabelAs(labelPermission.label());
+      }
+      return Permission.forLabel(labelPermission.label());
+    } else if (labelPermission instanceof LabelRemovalPermission.WithValue) {
+      return Permission.forRemoveLabel(labelPermission.label());
     }
-    return Permission.forLabel(labelPermission.label());
+    throw new IllegalStateException("invalid AbstractLabelPermission.WithValue subtype");
   }
 
   private DefaultPermissionMappings() {}
diff --git a/java/com/google/gerrit/server/permissions/LabelPermission.java b/java/com/google/gerrit/server/permissions/LabelPermission.java
index c266caa..4652364 100644
--- a/java/com/google/gerrit/server/permissions/LabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -14,24 +14,14 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.SELF;
-import static java.util.Objects.requireNonNull;
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.SELF;
 
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.server.util.LabelVote;
 
 /** Permission representing a label. */
-public class LabelPermission implements ChangePermissionOrLabel {
-  public enum ForUser {
-    SELF,
-    ON_BEHALF_OF;
-  }
-
-  private final ForUser forUser;
-  private final String name;
-
+public class LabelPermission extends AbstractLabelPermission {
   /**
    * Construct a reference to a label permission.
    *
@@ -67,55 +57,16 @@
    * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
    */
   public LabelPermission(ForUser forUser, String name) {
-    this.forUser = requireNonNull(forUser, "ForUser");
-    this.name = LabelType.checkName(name);
-  }
-
-  /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
-  public ForUser forUser() {
-    return forUser;
-  }
-
-  /** Returns name of the label, e.g. {@code "Code-Review"}. */
-  public String label() {
-    return name;
+    super(forUser, name);
   }
 
   @Override
-  public String describeForException() {
-    if (forUser == ON_BEHALF_OF) {
-      return "label on behalf of " + name;
-    }
-    return "label " + name;
-  }
-
-  @Override
-  public int hashCode() {
-    return name.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    if (other instanceof LabelPermission) {
-      LabelPermission b = (LabelPermission) other;
-      return forUser == b.forUser && name.equals(b.name);
-    }
-    return false;
-  }
-
-  @Override
-  public String toString() {
-    if (forUser == ON_BEHALF_OF) {
-      return "LabelAs[" + name + ']';
-    }
-    return "Label[" + name + ']';
+  public String permissionPrefix() {
+    return "label";
   }
 
   /** A {@link LabelPermission} at a specific value. */
-  public static class WithValue implements ChangePermissionOrLabel {
-    private final ForUser forUser;
-    private final LabelVote label;
-
+  public static class WithValue extends AbstractLabelPermission.WithValue {
     /**
      * Construct a reference to a label at a specific value.
      *
@@ -195,53 +146,12 @@
      * @param label label name and vote.
      */
     public WithValue(ForUser forUser, LabelVote label) {
-      this.forUser = requireNonNull(forUser, "ForUser");
-      this.label = requireNonNull(label, "LabelVote");
-    }
-
-    /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
-    public ForUser forUser() {
-      return forUser;
-    }
-
-    /** Returns name of the label, e.g. {@code "Code-Review"}. */
-    public String label() {
-      return label.label();
-    }
-
-    /** Returns specific value of the label, e.g. 1 or 2. */
-    public short value() {
-      return label.value();
+      super(forUser, label);
     }
 
     @Override
-    public String describeForException() {
-      if (forUser == ON_BEHALF_OF) {
-        return "label on behalf of " + label.formatWithEquals();
-      }
-      return "label " + label.formatWithEquals();
-    }
-
-    @Override
-    public int hashCode() {
-      return label.hashCode();
-    }
-
-    @Override
-    public boolean equals(Object other) {
-      if (other instanceof WithValue) {
-        WithValue b = (WithValue) other;
-        return forUser == b.forUser && label.equals(b.label);
-      }
-      return false;
-    }
-
-    @Override
-    public String toString() {
-      if (forUser == ON_BEHALF_OF) {
-        return "LabelAs[" + label.format() + ']';
-      }
-      return "Label[" + label.format() + ']';
+    public String permissionName() {
+      return "label";
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java b/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java
new file mode 100644
index 0000000..2553601
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.SELF;
+
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.server.util.LabelVote;
+
+/** Permission representing a label removal. */
+public class LabelRemovalPermission extends AbstractLabelPermission {
+  /**
+   * Construct a reference to a label removal permission.
+   *
+   * @param type type description of the label.
+   */
+  public LabelRemovalPermission(LabelType type) {
+    this(type.getName());
+  }
+
+  /**
+   * Construct a reference to a label removal permission.
+   *
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public LabelRemovalPermission(String name) {
+    super(SELF, name);
+  }
+
+  @Override
+  public String permissionPrefix() {
+    return "removeLabel";
+  }
+
+  /** A {@link LabelRemovalPermission} at a specific value. */
+  public static class WithValue extends AbstractLabelPermission.WithValue {
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, LabelValue value) {
+      this(type.getName(), value.getValue());
+    }
+
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, short value) {
+      this(type.getName(), value);
+    }
+
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(String name, short value) {
+      this(LabelVote.create(name, value));
+    }
+
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param label label name and vote.
+     */
+    public WithValue(LabelVote label) {
+      super(SELF, label);
+    }
+
+    @Override
+    public String permissionName() {
+      return "removeLabel";
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index fea2827..eb5e053 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -474,6 +474,18 @@
     }
 
     /**
+     * Test which values of a label the user may be able to remove.
+     *
+     * @param label definition of the label to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelRemovalPermission.WithValue> testRemoval(LabelType label)
+        throws PermissionBackendException {
+      return test(removalValuesOf(requireNonNull(label, "LabelType")));
+    }
+
+    /**
      * Test which values of a group of labels the user may be able to set.
      *
      * @param types definition of the labels to test values of.
@@ -486,10 +498,29 @@
       return test(types.stream().flatMap(t -> valuesOf(t).stream()).collect(toSet()));
     }
 
+    /**
+     * Test which values of a group of labels the user may be able to remove.
+     *
+     * @param types definition of the labels to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelRemovalPermission.WithValue> testLabelRemovals(Collection<LabelType> types)
+        throws PermissionBackendException {
+      requireNonNull(types, "LabelType");
+      return test(types.stream().flatMap(t -> removalValuesOf(t).stream()).collect(toSet()));
+    }
+
     private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
       return label.getValues().stream()
           .map(v -> new LabelPermission.WithValue(label, v))
           .collect(toSet());
     }
+
+    private static Set<LabelRemovalPermission.WithValue> removalValuesOf(LabelType label) {
+      return label.getValues().stream()
+          .map(v -> new LabelRemovalPermission.WithValue(label, v))
+          .collect(toSet());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/project/DeleteVoteControl.java b/java/com/google/gerrit/server/project/DeleteVoteControl.java
new file mode 100644
index 0000000..3f3f88a
--- /dev/null
+++ b/java/com/google/gerrit/server/project/DeleteVoteControl.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.LabelRemovalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import java.util.Set;
+
+public class DeleteVoteControl {
+  private final PermissionBackend permissionBackend;
+  private final ChangeData.Factory changeDataFactory;
+
+  @Inject
+  public DeleteVoteControl(
+      PermissionBackend permissionBackend, ChangeData.Factory changeDataFactory) {
+    this.permissionBackend = permissionBackend;
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  public boolean testDeleteVotePermissions(
+      CurrentUser user, ChangeNotes notes, PatchSetApproval approval, LabelType labelType)
+      throws PermissionBackendException {
+    return testDeleteVotePermissions(user, changeDataFactory.create(notes), approval, labelType);
+  }
+
+  public boolean testDeleteVotePermissions(
+      CurrentUser user, ChangeData cd, PatchSetApproval approval, LabelType labelType)
+      throws PermissionBackendException {
+    if (canRemoveReviewerWithoutRemoveLabelPermission(
+        cd.change(), user, approval.accountId(), approval.value())) {
+      return true;
+    }
+    // Test if the user is allowed to remove vote of the given label type and value.
+    Set<LabelRemovalPermission.WithValue> allowed =
+        permissionBackend.user(user).change(cd).testRemoval(labelType);
+    return allowed.contains(new LabelRemovalPermission.WithValue(labelType, approval.value()));
+  }
+
+  private boolean canRemoveReviewerWithoutRemoveLabelPermission(
+      Change change, CurrentUser user, Account.Id reviewer, int value)
+      throws PermissionBackendException {
+    if (user.isIdentifiedUser()) {
+      Account.Id aId = user.getAccountId();
+      if (aId.equals(reviewer)) {
+        return true; // A user can always remove their own votes.
+      } else if (aId.equals(change.getOwner()) && 0 <= value) {
+        return true; // The change owner may remove any zero or positive score.
+      }
+    }
+
+    // Users with the remove reviewer permission, the branch owner, project
+    // owner and site admin can remove anyone
+    PermissionBackend.WithUser withUser = permissionBackend.user(user);
+    PermissionBackend.ForProject forProject = withUser.project(change.getProject());
+    return forProject.ref(change.getDest().branch()).test(RefPermission.WRITE_CONFIG)
+        || withUser.test(GlobalPermission.ADMINISTRATE_SERVER);
+  }
+}
diff --git a/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java b/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java
index 85a3ab9..df2e1cf 100644
--- a/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java
+++ b/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java
@@ -92,7 +92,6 @@
   }
 
   @Override
-  @SuppressWarnings("CheckReturnValue")
   public void run() {
     logger.atFine().log("Loading project_list cache");
     cache.refreshProjectList();
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 7fdd113..6498d1b 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -300,17 +300,21 @@
   @Override
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
     try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
-      return Streams.concat(
-              Arrays.stream(config.getStringList("groups", /* subsection= */ null, "relevantGroup"))
-                  .map(AccountGroup::uuid),
-              all().stream()
-                  .map(n -> inMemoryProjectCache.getIfPresent(n))
-                  .filter(Objects::nonNull)
-                  .flatMap(p -> p.getAllGroupUUIDs().stream())
-                  // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
-                  // against them just in case there is a bug or corner case.
-                  .filter(id -> id != null && id.get() != null))
-          .collect(toSet());
+      Set<AccountGroup.UUID> relevantGroupUuids =
+          Streams.concat(
+                  Arrays.stream(
+                          config.getStringList("groups", /* subsection= */ null, "relevantGroup"))
+                      .map(AccountGroup::uuid),
+                  all().stream()
+                      .map(n -> inMemoryProjectCache.getIfPresent(n))
+                      .filter(Objects::nonNull)
+                      .flatMap(p -> p.getAllGroupUUIDs().stream())
+                      // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
+                      // against them just in case there is a bug or corner case.
+                      .filter(id -> id != null && id.get() != null))
+              .collect(toSet());
+      logger.atFine().log("relevant group UUIDs: %s", relevantGroupUuids);
+      return relevantGroupUuids;
     }
   }
 
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 1bc309c..3fda87a 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -32,10 +32,12 @@
 @Singleton
 public class RemoveReviewerControl {
   private final PermissionBackend permissionBackend;
+  private final ChangeData.Factory changeDataFactory;
 
   @Inject
-  RemoveReviewerControl(PermissionBackend permissionBackend) {
+  RemoveReviewerControl(PermissionBackend permissionBackend, ChangeData.Factory changeDataFactory) {
     this.permissionBackend = permissionBackend;
+    this.changeDataFactory = changeDataFactory;
   }
 
   /**
@@ -64,6 +66,20 @@
 
   /** Returns true if the user is allowed to remove this reviewer. */
   public boolean testRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
+      throws PermissionBackendException {
+    return testRemoveReviewer(notes, currentUser, approval.accountId(), approval.value());
+  }
+
+  /** Returns true if the user is allowed to remove this reviewer. */
+  public boolean testRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int value)
+      throws PermissionBackendException {
+    return testRemoveReviewer(changeDataFactory.create(notes), currentUser, reviewer, value);
+  }
+
+  /** Returns true if the user is allowed to remove this reviewer. */
+  public boolean testRemoveReviewer(
       ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
       throws PermissionBackendException {
     if (canRemoveReviewerWithoutPermissionCheck(
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index e31411c..d5c4a97 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -159,7 +159,7 @@
       return AccountPredicates.preferredEmail(email);
     }
 
-    throw new QueryParseException("'email' operator is not supported by account index version");
+    throw new QueryParseException("'email' operator is not supported on this gerrit host");
   }
 
   @Operator
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index ec1fcad..fc4df49 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -334,7 +334,7 @@
    * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the edit ref for this
    * change and a given user.
    */
-  private Table<Account.Id, PatchSet.Id, ObjectId> editsByUser;
+  private Table<Account.Id, PatchSet.Id, Ref> editRefsByUser;
 
   private Set<Account.Id> reviewedBy;
   /**
@@ -966,7 +966,7 @@
    * submit requirements are evaluated online.
    *
    * <p>For changes loaded from the index, the value will be set from index field {@link
-   * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS}.
+   * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS_FIELD}.
    */
   public Map<SubmitRequirement, SubmitRequirementResult> submitRequirements() {
     if (submitRequirements == null) {
@@ -1097,8 +1097,8 @@
     return editRefs().rowKeySet();
   }
 
-  public Table<Account.Id, PatchSet.Id, ObjectId> editRefs() {
-    if (editsByUser == null) {
+  public Table<Account.Id, PatchSet.Id, Ref> editRefs() {
+    if (editRefsByUser == null) {
       if (!lazyload()) {
         return HashBasedTable.create();
       }
@@ -1106,7 +1106,7 @@
       if (c == null) {
         return HashBasedTable.create();
       }
-      editsByUser = HashBasedTable.create();
+      editRefsByUser = HashBasedTable.create();
       Change.Id id = requireNonNull(change.getId());
       try (Repository repo = repoManager.openRepository(project())) {
         for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_USERS)) {
@@ -1117,7 +1117,7 @@
           if (id.equals(ps.changeId())) {
             Account.Id accountId = Account.Id.fromRef(ref.getName());
             if (accountId != null) {
-              editsByUser.put(accountId, ps, ref.getObjectId());
+              editRefsByUser.put(accountId, ps, ref);
             }
           }
         }
@@ -1125,7 +1125,7 @@
         throw new StorageException(e);
       }
     }
-    return editsByUser;
+    return editRefsByUser;
   }
 
   public Set<Account.Id> draftsByUser() {
@@ -1273,13 +1273,13 @@
       }
 
       ImmutableSetMultimap.Builder<NameKey, RefState> result = ImmutableSetMultimap.builder();
-      for (Table.Cell<Account.Id, PatchSet.Id, ObjectId> edit : editRefs().cellSet()) {
+      for (Table.Cell<Account.Id, PatchSet.Id, Ref> edit : editRefs().cellSet()) {
         result.put(
             project,
             RefState.create(
                 RefNames.refsEdit(
                     edit.getRowKey(), edit.getColumnKey().changeId(), edit.getColumnKey()),
-                edit.getValue()));
+                edit.getValue().getObjectId()));
       }
 
       // TODO: instantiating the notes is too much. We don't want to parse NoteDb, we just want the
@@ -1309,21 +1309,6 @@
             .forEach(r -> draftsByUser.put(Account.Id.fromRef(r.ref()), r.id()));
       }
     }
-    if (editsByUser == null) {
-      // Recover edit refs as well. Edits are represented as refs in the repository.
-      // ChangeData exposes #editsByUser which just provides a Set of Account.Ids of users who
-      // have edits on this change. Recovering this list from RefStates makes it available even
-      // on ChangeData instances retrieved from the index.
-      editsByUser = HashBasedTable.create();
-      if (refStates.containsKey(project())) {
-        refStates.get(project()).stream()
-            .filter(r -> RefNames.isRefsEdit(r.ref()))
-            .forEach(
-                r ->
-                    editsByUser.put(
-                        Account.Id.fromRef(r.ref()), PatchSet.Id.fromEditRef(r.ref()), r.id()));
-      }
-    }
   }
 
   public ImmutableList<byte[]> getRefStatePatterns() {
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 84ceb3d..c344edd 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -336,6 +336,10 @@
     return new ChangeIndexPredicate(ChangeField.SUBJECT_SPEC, subject);
   }
 
+  public static Predicate<ChangeData> prefixSubject(String subject) {
+    return new ChangeIndexPredicate(ChangeField.PREFIX_SUBJECT_SPEC, subject);
+  }
+
   /**
    * Returns a predicate that matches changes where the provided {@code comment} appears in any
    * comment on any patch set of the change. Uses full-text search semantics.
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 83c348c..ca18ab2 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -187,6 +187,7 @@
   public static final String FIELD_MERGED_ON = "mergedon";
   public static final String FIELD_MESSAGE = "message";
   public static final String FIELD_SUBJECT = "subject";
+  public static final String FIELD_PREFIX_SUBJECT = "prefixsubject";
   public static final String FIELD_MESSAGE_EXACT = "messageexact";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
@@ -410,7 +411,7 @@
       this.submitRules = submitRules;
     }
 
-    Arguments asUser(CurrentUser otherUser) {
+    public Arguments asUser(CurrentUser otherUser) {
       return new Arguments(
           queryProvider,
           rewriter,
@@ -487,13 +488,14 @@
   private final Arguments args;
   protected Map<String, String> hasOperandAliases = Collections.emptyMap();
   private Map<Account.Id, DestinationList> destinationListByAccount = new HashMap<>();
+  private boolean forceAccountVisibilityCheck = false;
 
   private static final Splitter RULE_SPLITTER = Splitter.on("=");
   private static final Splitter PLUGIN_SPLITTER = Splitter.on("_");
   private static final Splitter LABEL_SPLITTER = Splitter.on(",");
 
   @Inject
-  ChangeQueryBuilder(Arguments args) {
+  protected ChangeQueryBuilder(Arguments args) {
     this(mydef, args);
     setupAliases();
   }
@@ -513,6 +515,15 @@
     return new ChangeQueryBuilder(builderDef, args.asUser(user));
   }
 
+  public Arguments getArgs() {
+    return args;
+  }
+
+  /** Whether to force account visibility check when searching for changes by account(s). */
+  public void forceAccountVisibilityCheck() {
+    forceAccountVisibilityCheck = true;
+  }
+
   @Operator
   public Predicate<ChangeData> age(String value) {
     return new AgePredicate(value);
@@ -540,14 +551,14 @@
 
   @Operator
   public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_BEFORE);
+    checkOperatorAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_BEFORE);
     return new BeforePredicate(
         ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
   }
 
   @Operator
   public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_AFTER);
+    checkOperatorAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_AFTER);
     return new AfterPredicate(
         ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
   }
@@ -630,7 +641,7 @@
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
+      checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
       return new IsAttentionPredicate();
     }
 
@@ -673,7 +684,7 @@
     }
 
     if ("uploader".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.UPLOADER_SPEC, "is:uploader");
+      checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "is:uploader");
       return ChangePredicates.uploader(self());
     }
 
@@ -689,13 +700,14 @@
 
     if ("mergeable".equalsIgnoreCase(value)) {
       if (!args.indexMergeable) {
-        throw new QueryParseException("'is:mergeable' operator is not supported by server");
+        throw new QueryParseException(
+            "'is:mergeable' operator is not supported on this gerrit host");
       }
       return new BooleanPredicate(ChangeField.MERGEABLE_SPEC);
     }
 
     if ("merge".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.MERGE_SPEC, "is:merge");
+      checkOperatorAvailable(ChangeField.MERGE_SPEC, "is:merge");
       return new BooleanPredicate(ChangeField.MERGE_SPEC);
     }
 
@@ -704,7 +716,7 @@
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
+      checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
       return new IsAttentionPredicate();
     }
 
@@ -717,7 +729,7 @@
     }
 
     if ("pure-revert".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.IS_PURE_REVERT_SPEC, "is:pure-revert");
+      checkOperatorAvailable(ChangeField.IS_PURE_REVERT_SPEC, "is:pure-revert");
       return ChangePredicates.pureRevert("1");
     }
 
@@ -733,12 +745,12 @@
             Predicate.not(new SubmittablePredicate(SubmitRecord.Status.NOT_READY)),
             Predicate.not(new SubmittablePredicate(SubmitRecord.Status.RULE_ERROR)));
       }
-      checkFieldAvailable(ChangeField.IS_SUBMITTABLE_SPEC, "is:submittable");
+      checkOperatorAvailable(ChangeField.IS_SUBMITTABLE_SPEC, "is:submittable");
       return new IsSubmittablePredicate();
     }
 
     if ("started".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.STARTED_SPEC, "is:started");
+      checkOperatorAvailable(ChangeField.STARTED_SPEC, "is:started");
       return new BooleanPredicate(ChangeField.STARTED_SPEC);
     }
 
@@ -747,7 +759,7 @@
     }
 
     if ("cherrypick".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.CHERRY_PICK_SPEC, "is:cherrypick");
+      checkOperatorAvailable(ChangeField.CHERRY_PICK_SPEC, "is:cherrypick");
       return new BooleanPredicate(ChangeField.CHERRY_PICK_SPEC);
     }
 
@@ -770,7 +782,7 @@
   @Operator
   public Predicate<ChangeData> conflicts(String value) throws QueryParseException {
     if (!args.conflictsPredicateEnabled) {
-      throw new QueryParseException("'conflicts:' operator is not supported by server");
+      throw new QueryParseException("'conflicts:' operator is not supported on this gerrit host");
     }
     List<Change> changes = parseChange(value);
     List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
@@ -882,7 +894,7 @@
       return ChangePredicates.hashtag(hashtag);
     }
 
-    checkFieldAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
+    checkOperatorAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
     return ChangePredicates.fuzzyHashtag(hashtag);
   }
 
@@ -892,7 +904,7 @@
       return ChangePredicates.hashtag(hashtag);
     }
 
-    checkFieldAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
+    checkOperatorAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
     return ChangePredicates.prefixHashtag(hashtag);
   }
 
@@ -918,7 +930,7 @@
       return ChangePredicates.exactTopic(name);
     }
 
-    checkFieldAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
+    checkOperatorAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
     return ChangePredicates.prefixTopic(name);
   }
 
@@ -984,7 +996,7 @@
 
   @Operator
   public Predicate<ChangeData> hasfooter(String footerName) throws QueryParseException {
-    checkFieldAvailable(ChangeField.FOOTER_NAME, "hasfooter");
+    checkOperatorAvailable(ChangeField.FOOTER_NAME, "hasfooter");
     return ChangePredicates.hasFooter(footerName);
   }
 
@@ -1135,7 +1147,9 @@
   @Operator
   public Predicate<ChangeData> message(String text) throws QueryParseException {
     if (text.startsWith("^")) {
-      checkFieldAvailable(ChangeField.COMMIT_MESSAGE_EXACT, "messageexact");
+      checkFieldAvailable(
+          ChangeField.COMMIT_MESSAGE_EXACT,
+          "'message' operator with regular expression is not supported on this gerrit host");
       return new RegexMessagePredicate(text);
     }
     return ChangePredicates.message(text);
@@ -1143,10 +1157,17 @@
 
   @Operator
   public Predicate<ChangeData> subject(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.SUBJECT_SPEC, ChangeQueryBuilder.FIELD_SUBJECT);
+    checkOperatorAvailable(ChangeField.SUBJECT_SPEC, ChangeQueryBuilder.FIELD_SUBJECT);
     return ChangePredicates.subject(value);
   }
 
+  @Operator
+  public Predicate<ChangeData> prefixsubject(String value) throws QueryParseException {
+    checkOperatorAvailable(
+        ChangeField.PREFIX_SUBJECT_SPEC, ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
+    return ChangePredicates.prefixSubject(value);
+  }
+
   private Predicate<ChangeData> starredBySelf() throws QueryParseException {
     return ChangePredicates.starBy(
         args.starredChangesUtil, self(), StarredChangesUtil.DEFAULT_LABEL);
@@ -1231,7 +1252,7 @@
   @Operator
   public Predicate<ChangeData> uploader(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    checkFieldAvailable(ChangeField.UPLOADER_SPEC, "uploader");
+    checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploader");
     return uploader(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1246,7 +1267,7 @@
   @Operator
   public Predicate<ChangeData> attention(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
+    checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
     return attention(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1291,7 +1312,7 @@
 
   @Operator
   public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
-    checkFieldAvailable(ChangeField.UPLOADER_SPEC, "uploaderin");
+    checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploaderin");
 
     GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
     if (g == null) {
@@ -1557,8 +1578,8 @@
 
   @Operator
   public Predicate<ChangeData> cherryPickOf(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
-    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
+    checkOperatorAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
+    checkOperatorAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
     if (Ints.tryParse(value) != null) {
       return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
     }
@@ -1630,11 +1651,16 @@
     return Predicate.or(predicates);
   }
 
-  protected void checkFieldAvailable(SchemaField<ChangeData, ?> field, String operator)
+  private void checkOperatorAvailable(SchemaField<ChangeData, ?> field, String operator)
+      throws QueryParseException {
+    checkFieldAvailable(
+        field, String.format("'%s' operator is not supported on this gerrit host", operator));
+  }
+
+  protected void checkFieldAvailable(SchemaField<ChangeData, ?> field, String errorMessage)
       throws QueryParseException {
     if (!args.index.getSchema().hasField(field)) {
-      throw new QueryParseException(
-          String.format("'%s' operator is not supported by change index version", operator));
+      throw new QueryParseException(errorMessage);
     }
   }
 
@@ -1685,7 +1711,9 @@
   private Set<Account.Id> parseAccount(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     try {
-      return args.accountResolver.resolve(who).asNonEmptyIdSet();
+      return args.accountResolver
+          .resolveAsUser(args.getUser(), who, forceAccountVisibilityCheck)
+          .asNonEmptyIdSet();
     } catch (UnresolvableAccountException e) {
       if (e.isSelf()) {
         throw new QueryRequiresAuthException(e.getMessage(), e);
@@ -1698,7 +1726,9 @@
       String who, java.util.function.Predicate<AccountState> activityFilter)
       throws QueryParseException, IOException, ConfigInvalidException {
     try {
-      return args.accountResolver.resolve(who, activityFilter).asNonEmptyIdSet();
+      return args.accountResolver
+          .resolveAsUser(args.getUser(), who, activityFilter, forceAccountVisibilityCheck)
+          .asNonEmptyIdSet();
     } catch (UnresolvableAccountException e) {
       if (e.isSelf()) {
         throw new QueryRequiresAuthException(e.getMessage(), e);
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index ccd645b..3edad69 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -26,6 +26,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -103,6 +104,7 @@
     return query(ChangePredicates.idStr(id));
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
@@ -115,15 +117,6 @@
     return query(byBranchKeyPred(branch, key));
   }
 
-  public List<ChangeData> byBranchKeyOpen(Project.NameKey project, String branch, Change.Key key) {
-    return query(and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open()));
-  }
-
-  public static Predicate<ChangeData> byBranchKeyOpenPred(
-      Project.NameKey project, String branch, Change.Key key) {
-    return and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open());
-  }
-
   private static Predicate<ChangeData> byBranchKeyPred(BranchNameKey branch, Change.Key key) {
     return and(ref(branch), project(branch.project()), change(key));
   }
diff --git a/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
index 8b4048f..a7b0743 100644
--- a/java/com/google/gerrit/server/query/project/ProjectPredicates.java
+++ b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
@@ -25,23 +25,23 @@
 /** Utility class to create predicates for project index queries. */
 public class ProjectPredicates {
   public static Predicate<ProjectData> name(Project.NameKey nameKey) {
-    return new ProjectPredicate(ProjectField.NAME, nameKey.get());
+    return new ProjectPredicate(ProjectField.NAME_SPEC, nameKey.get());
   }
 
   public static Predicate<ProjectData> parent(Project.NameKey parentNameKey) {
-    return new ProjectPredicate(ProjectField.PARENT_NAME, parentNameKey.get());
+    return new ProjectPredicate(ProjectField.PARENT_NAME_SPEC, parentNameKey.get());
   }
 
   public static Predicate<ProjectData> inname(String name) {
-    return new ProjectPredicate(ProjectField.NAME_PART, name.toLowerCase(Locale.US));
+    return new ProjectPredicate(ProjectField.NAME_PART_SPEC, name.toLowerCase(Locale.US));
   }
 
   public static Predicate<ProjectData> description(String description) {
-    return new ProjectPredicate(ProjectField.DESCRIPTION, description);
+    return new ProjectPredicate(ProjectField.DESCRIPTION_SPEC, description);
   }
 
   public static Predicate<ProjectData> state(ProjectState state) {
-    return new ProjectPredicate(ProjectField.STATE, state.name());
+    return new ProjectPredicate(ProjectField.STATE_SPEC, state.name());
   }
 
   private ProjectPredicates() {}
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index d234546..edb12ec 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2023 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,93 +14,21 @@
 
 package com.google.gerrit.server.query.project;
 
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.index.project.ProjectData;
-import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.inject.Inject;
 import java.util.List;
 
-/** Parses a query string meant to be applied to project objects. */
-public class ProjectQueryBuilder extends QueryBuilder<ProjectData, ProjectQueryBuilder> {
-  public static final String FIELD_LIMIT = "limit";
+/**
+ * Provides methods required for parsing projects queries.
+ *
+ * <p>Internally (at google), this interface has a different implementation, comparing to upstream.
+ */
+public interface ProjectQueryBuilder {
+  String FIELD_LIMIT = "limit";
 
-  private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilder> mydef =
-      new QueryBuilder.Definition<>(ProjectQueryBuilder.class);
-
-  @Inject
-  ProjectQueryBuilder() {
-    super(mydef, null);
-  }
-
-  @Operator
-  public Predicate<ProjectData> name(String name) {
-    return ProjectPredicates.name(Project.nameKey(name));
-  }
-
-  @Operator
-  public Predicate<ProjectData> parent(String parentName) {
-    return ProjectPredicates.parent(Project.nameKey(parentName));
-  }
-
-  @Operator
-  public Predicate<ProjectData> inname(String namePart) {
-    if (namePart.isEmpty()) {
-      return name(namePart);
-    }
-    return ProjectPredicates.inname(namePart);
-  }
-
-  @Operator
-  public Predicate<ProjectData> description(String description) throws QueryParseException {
-    if (Strings.isNullOrEmpty(description)) {
-      throw error("description operator requires a value");
-    }
-
-    return ProjectPredicates.description(description);
-  }
-
-  @Operator
-  public Predicate<ProjectData> state(String state) throws QueryParseException {
-    if (Strings.isNullOrEmpty(state)) {
-      throw error("state operator requires a value");
-    }
-    ProjectState parsedState;
-    try {
-      parsedState = ProjectState.valueOf(state.replace('-', '_').toUpperCase());
-    } catch (IllegalArgumentException e) {
-      throw error("state operator must be either 'active' or 'read-only'", e);
-    }
-    if (parsedState == ProjectState.HIDDEN) {
-      throw error("state operator must be either 'active' or 'read-only'");
-    }
-    return ProjectPredicates.state(parsedState);
-  }
-
-  @Override
-  protected Predicate<ProjectData> defaultField(String query) throws QueryParseException {
-    // Adapt the capacity of this list when adding more default predicates.
-    List<Predicate<ProjectData>> preds = Lists.newArrayListWithCapacity(3);
-    preds.add(name(query));
-    preds.add(inname(query));
-    if (!Strings.isNullOrEmpty(query)) {
-      preds.add(description(query));
-    }
-    return Predicate.or(preds);
-  }
-
-  @Operator
-  public Predicate<ProjectData> limit(String query) throws QueryParseException {
-    Integer limit = Ints.tryParse(query);
-    if (limit == null) {
-      throw error("Invalid limit: " + query);
-    }
-    return new LimitPredicate<>(FIELD_LIMIT, limit);
-  }
+  /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(String)}. */
+  Predicate<ProjectData> parse(String query) throws QueryParseException;
+  /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(List<String>)}. */
+  List<Predicate<ProjectData>> parse(List<String> queries) throws QueryParseException;
 }
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
new file mode 100644
index 0000000..f7135982
--- /dev/null
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.project;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.query.LimitPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.inject.Inject;
+import java.util.List;
+
+/** Parses a query string meant to be applied to project objects. */
+public class ProjectQueryBuilderImpl extends QueryBuilder<ProjectData, ProjectQueryBuilderImpl>
+    implements ProjectQueryBuilder {
+  private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilderImpl> mydef =
+      new QueryBuilder.Definition<>(ProjectQueryBuilderImpl.class);
+
+  @Inject
+  ProjectQueryBuilderImpl() {
+    super(mydef, null);
+  }
+
+  @Operator
+  public Predicate<ProjectData> name(String name) {
+    return ProjectPredicates.name(Project.nameKey(name));
+  }
+
+  @Operator
+  public Predicate<ProjectData> parent(String parentName) {
+    return ProjectPredicates.parent(Project.nameKey(parentName));
+  }
+
+  @Operator
+  public Predicate<ProjectData> inname(String namePart) {
+    if (namePart.isEmpty()) {
+      return name(namePart);
+    }
+    return ProjectPredicates.inname(namePart);
+  }
+
+  @Operator
+  public Predicate<ProjectData> description(String description) throws QueryParseException {
+    if (Strings.isNullOrEmpty(description)) {
+      throw error("description operator requires a value");
+    }
+
+    return ProjectPredicates.description(description);
+  }
+
+  @Operator
+  public Predicate<ProjectData> state(String state) throws QueryParseException {
+    if (Strings.isNullOrEmpty(state)) {
+      throw error("state operator requires a value");
+    }
+    ProjectState parsedState;
+    try {
+      parsedState = ProjectState.valueOf(state.replace('-', '_').toUpperCase());
+    } catch (IllegalArgumentException e) {
+      throw error("state operator must be either 'active' or 'read-only'", e);
+    }
+    if (parsedState == ProjectState.HIDDEN) {
+      throw error("state operator must be either 'active' or 'read-only'");
+    }
+    return ProjectPredicates.state(parsedState);
+  }
+
+  @Override
+  protected Predicate<ProjectData> defaultField(String query) throws QueryParseException {
+    // Adapt the capacity of this list when adding more default predicates.
+    List<Predicate<ProjectData>> preds = Lists.newArrayListWithCapacity(3);
+    preds.add(name(query));
+    preds.add(inname(query));
+    if (!Strings.isNullOrEmpty(query)) {
+      preds.add(description(query));
+    }
+    return Predicate.or(preds);
+  }
+
+  @Operator
+  public Predicate<ProjectData> limit(String query) throws QueryParseException {
+    Integer limit = Ints.tryParse(query);
+    if (limit == null) {
+      throw error("Invalid limit: " + query);
+    }
+    return new LimitPredicate<>(FIELD_LIMIT, limit);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 87d8cd0..f49ee7f 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -113,6 +113,7 @@
     post(CHANGE_KIND, "submit").to(Submit.CurrentRevision.class);
     get(CHANGE_KIND, "submitted_together").to(SubmittedTogether.class);
     post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
+    post(CHANGE_KIND, "rebase:chain").to(RebaseChain.class);
     post(CHANGE_KIND, "index").to(Index.class);
     post(CHANGE_KIND, "move").to(Move.class);
     post(CHANGE_KIND, "private").to(PostPrivate.class);
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 7e7892c..c192500 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -19,7 +19,6 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -42,6 +41,7 @@
 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.change.ValidationOptionsUtil;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.CommitUtil;
@@ -72,7 +72,6 @@
 import java.time.ZoneId;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -403,7 +402,8 @@
     if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
       inserter.setWorkInProgress(false);
     }
-    inserter.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
+    inserter.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
     bu.addOp(destChange.getId(), inserter);
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     // If sourceChange is not provided, reset cherryPickOf to avoid stale value.
@@ -454,7 +454,8 @@
           (sourceChange != null && sourceChange.isWorkInProgress())
               || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
     }
-    ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
+    ins.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
     BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     ins.setMessage(
@@ -500,20 +501,6 @@
     return changeId;
   }
 
-  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
-      @Nullable Map<String, String> validationOptions) {
-    if (validationOptions == null) {
-      return ImmutableListMultimap.of();
-    }
-
-    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
-        ImmutableListMultimap.builder();
-    validationOptions
-        .entrySet()
-        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
-    return validationOptionsBuilder.build();
-  }
-
   private NotifyResolver.Result resolveNotify(CherryPickInput input)
       throws BadRequestException, ConfigInvalidException, IOException {
     return notifyResolver.resolve(
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
index 432f0da..0e1a218 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -20,6 +20,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -37,7 +38,9 @@
 import com.google.gerrit.server.mail.send.DeleteVoteSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.permissions.LabelRemovalPermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DeleteVoteControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -75,6 +78,7 @@
   private final VoteDeleted voteDeleted;
   private final DeleteVoteSender.Factory deleteVoteSenderFactory;
 
+  private final DeleteVoteControl deleteVoteControl;
   private final RemoveReviewerControl removeReviewerControl;
   private final MessageIdGenerator messageIdGenerator;
 
@@ -96,8 +100,9 @@
       ChangeMessagesUtil cmUtil,
       VoteDeleted voteDeleted,
       DeleteVoteSender.Factory deleteVoteSenderFactory,
-      RemoveReviewerControl removeReviewerControl,
+      DeleteVoteControl deleteVoteControl,
       MessageIdGenerator messageIdGenerator,
+      RemoveReviewerControl removeReviewerControl,
       @Assisted Project.NameKey projectName,
       @Assisted AccountState reviewerToDeleteVoteFor,
       @Assisted String label,
@@ -109,6 +114,7 @@
     this.cmUtil = cmUtil;
     this.voteDeleted = voteDeleted;
     this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+    this.deleteVoteControl = deleteVoteControl;
     this.removeReviewerControl = removeReviewerControl;
     this.messageIdGenerator = messageIdGenerator;
 
@@ -143,12 +149,7 @@
         newApprovals.put(a.label(), a.value());
         continue;
       } else if (enforcePermissions) {
-        // For regular users, check if they are allowed to remove the vote.
-        try {
-          removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-        } catch (AuthException e) {
-          throw new AuthException("delete vote not permitted", e);
-        }
+        checkPermissions(ctx, labelTypes.byLabel(a.labelId()).get(), a);
       }
       // Set the approval to 0 if vote is being removed.
       newApprovals.put(a.label(), (short) 0);
@@ -185,18 +186,16 @@
     CurrentUser user = ctx.getUser();
     try {
       NotifyResolver.Result notify = ctx.getNotify(change.getId());
-      if (notify.shouldNotify()) {
-        ReplyToChangeSender emailSender =
-            deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
-        if (user.isIdentifiedUser()) {
-          emailSender.setFrom(user.getAccountId());
-        }
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.setNotify(notify);
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-        emailSender.send();
+      ReplyToChangeSender emailSender =
+          deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
+      if (user.isIdentifiedUser()) {
+        emailSender.setFrom(user.getAccountId());
       }
+      emailSender.setChangeMessage(mailMessage, ctx.getWhen());
+      emailSender.setNotify(notify);
+      emailSender.setMessageId(
+          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+      emailSender.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
@@ -211,4 +210,21 @@
         user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null,
         ctx.getWhen());
   }
+
+  private void checkPermissions(ChangeContext ctx, LabelType labelType, PatchSetApproval approval)
+      throws PermissionBackendException, AuthException {
+    boolean permitted =
+        removeReviewerControl.testRemoveReviewer(ctx.getNotes(), ctx.getUser(), approval)
+            || deleteVoteControl.testDeleteVotePermissions(
+                ctx.getUser(), ctx.getNotes(), approval, labelType);
+    if (!permitted) {
+      throw new AuthException(
+          "Delete vote not permitted.",
+          new AuthException(
+              "Both "
+                  + new LabelRemovalPermission.WithValue(labelType, approval.value())
+                      .describeForException()
+                  + " and remove-reviewer are not permitted"));
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 9a6b03e..22eb32c 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.groupingBy;
@@ -379,7 +379,8 @@
       // Add the review ops.
       logger.atFine().log("posting review");
       PostReviewOp postReviewOp =
-          postReviewOpFactory.create(projectState, revision.getPatchSet().id(), input);
+          postReviewOpFactory.create(
+              projectState, revision.getPatchSet().id(), input, revision.getAccountId());
       bu.addOp(revision.getChange().getId(), postReviewOp);
 
       // Adjust the attention set based on the input
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index 8a92046..29e453b 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -65,7 +65,6 @@
 import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.logging.Metadata;
@@ -98,7 +97,8 @@
 
 public class PostReviewOp implements BatchUpdateOp {
   interface Factory {
-    PostReviewOp create(ProjectState projectState, PatchSet.Id psId, ReviewInput in);
+    PostReviewOp create(
+        ProjectState projectState, PatchSet.Id psId, ReviewInput in, Account.Id reviewerId);
   }
 
   /**
@@ -193,6 +193,7 @@
   private final ProjectState projectState;
   private final PatchSet.Id psId;
   private final ReviewInput in;
+  private final Account.Id reviewerId;
   private final boolean publishPatchSetLevelComment;
 
   private IdentifiedUser user;
@@ -221,7 +222,8 @@
       PluginSetContext<OnPostReview> onPostReviews,
       @Assisted ProjectState projectState,
       @Assisted PatchSet.Id psId,
-      @Assisted ReviewInput in) {
+      @Assisted ReviewInput in,
+      @Assisted Account.Id reviewerId) {
     this.approvalCopier = approvalCopier;
     this.approvalsUtil = approvalsUtil;
     this.publishCommentUtil = publishCommentUtil;
@@ -238,6 +240,7 @@
     this.projectState = projectState;
     this.psId = psId;
     this.in = in;
+    this.reviewerId = reviewerId;
   }
 
   @Override
@@ -273,12 +276,9 @@
     if (mailMessage == null) {
       return;
     }
-    NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
-    if (notify.shouldNotify()) {
-      email
-          .create(ctx, ps, notes.getMetaId(), mailMessage, comments, in.message, labelDelta)
-          .sendAsync();
-    }
+    email
+        .create(ctx, ps, notes.getMetaId(), mailMessage, comments, in.message, labelDelta)
+        .sendAsync();
     String comment = mailMessage;
     if (publishPatchSetLevelComment) {
       // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
@@ -649,10 +649,11 @@
           del.add(c);
           update.putApproval(normName, (short) 0);
         }
-        // Only allow voting again if the vote is copied over from a past patch-set, or the
-        // values are different.
+        // Only allow voting again the values are different, if the real account differs or if the
+        // vote is copied over from a past patch-set.
       } else if (c != null
           && (c.value() != ent.getValue()
+              || !c.realAccountId().equals(reviewerId)
               || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
         PatchSetApproval.Builder b =
             c.toBuilder()
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 5e30dae..8a8d2ca 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -16,38 +16,30 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.RebaseChangeOp;
 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;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
@@ -55,13 +47,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Map;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
@@ -72,7 +60,6 @@
 
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repoManager;
-  private final RebaseChangeOp.Factory rebaseFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeJson.Factory json;
   private final PermissionBackend permissionBackend;
@@ -83,7 +70,6 @@
   public Rebase(
       BatchUpdate.Factory updateFactory,
       GitRepositoryManager repoManager,
-      RebaseChangeOp.Factory rebaseFactory,
       RebaseUtil rebaseUtil,
       ChangeJson.Factory json,
       PermissionBackend permissionBackend,
@@ -91,7 +77,6 @@
       PatchSetUtil patchSetUtil) {
     this.updateFactory = updateFactory;
     this.repoManager = repoManager;
-    this.rebaseFactory = rebaseFactory;
     this.rebaseUtil = rebaseUtil;
     this.json = json;
     this.permissionBackend = permissionBackend;
@@ -102,10 +87,8 @@
   @Override
   public Response<ChangeInfo> apply(RevisionResource rsrc, RebaseInput input)
       throws UpdateException, RestApiException, IOException, PermissionBackendException {
-    // Not allowed to rebase if the current patch set is locked.
-    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
-
     rsrc.permissions().check(ChangePermission.REBASE);
+
     projectCache
         .get(rsrc.getProject())
         .orElseThrow(illegalState(rsrc.getProject()))
@@ -118,19 +101,14 @@
         RevWalk rw = CodeReviewCommit.newRevWalk(reader);
         BatchUpdate bu =
             updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      if (!change.isNew()) {
-        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-      } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
-        throw new ResourceConflictException(
-            "cannot rebase merge commits or commit with no ancestor");
-      }
+      rebaseUtil.verifyRebasePreconditions(rw, rsrc.getNotes(), rsrc.getPatchSet());
+
       RebaseChangeOp rebaseOp =
-          rebaseFactory
-              .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
-              .setForceContentMerge(true)
-              .setAllowConflicts(input.allowConflicts)
-              .setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions))
-              .setFireRevisionCreated(true);
+          rebaseUtil.getRebaseOp(
+              rsrc,
+              input,
+              rebaseUtil.parseOrFindBaseRevision(repo, rw, permissionBackend, rsrc, input, true));
+
       // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
       bu.setNotify(NotifyResolver.Result.none());
       bu.setRepository(repo, rw, oi);
@@ -144,76 +122,6 @@
     }
   }
 
-  private ObjectId findBaseRev(
-      Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
-      throws RestApiException, IOException, NoSuchChangeException, AuthException,
-          PermissionBackendException {
-    BranchNameKey destRefKey = rsrc.getChange().getDest();
-    if (input == null || input.base == null) {
-      return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
-    }
-
-    Change change = rsrc.getChange();
-    String str = input.base.trim();
-    if (str.equals("")) {
-      // Remove existing dependency to other patch set.
-      Ref destRef = repo.exactRef(destRefKey.branch());
-      if (destRef == null) {
-        throw new ResourceConflictException(
-            "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
-      }
-      return destRef.getObjectId();
-    }
-
-    Base base;
-    try {
-      base = rebaseUtil.parseBase(rsrc, str);
-      if (base == null) {
-        throw new ResourceConflictException(
-            "base revision is missing from the destination branch: " + str);
-      }
-    } catch (NoSuchChangeException e) {
-      throw new UnprocessableEntityException(
-          String.format("Base change not found: %s", input.base), e);
-    }
-
-    PatchSet.Id baseId = base.patchSet().id();
-    if (change.getId().equals(baseId.changeId())) {
-      throw new ResourceConflictException("cannot rebase change onto itself");
-    }
-
-    permissionBackend.user(rsrc.getUser()).change(base.notes()).check(ChangePermission.READ);
-
-    Change baseChange = base.notes().getChange();
-    if (!baseChange.getProject().equals(change.getProject())) {
-      throw new ResourceConflictException(
-          "base change is in wrong project: " + baseChange.getProject());
-    } else if (!baseChange.getDest().equals(change.getDest())) {
-      throw new ResourceConflictException(
-          "base change is targeting wrong branch: " + baseChange.getDest());
-    } else if (baseChange.isAbandoned()) {
-      throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
-    } else if (isMergedInto(rw, rsrc.getPatchSet(), base.patchSet())) {
-      throw new ResourceConflictException(
-          "base change "
-              + baseChange.getKey()
-              + " is a descendant of the current change - recursion not allowed");
-    }
-    return base.patchSet().commitId();
-  }
-
-  private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
-    ObjectId baseId = base.commitId();
-    ObjectId tipId = tip.commitId();
-    return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
-  }
-
-  private boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
-    // Prevent rebase of exotic changes (merge commit, no ancestor).
-    RevCommit c = rw.parseCommit(ps.commitId());
-    return c.getParentCount() == 1;
-  }
-
   @Override
   public UiAction.Description getDescription(RevisionResource rsrc) throws IOException {
     UiAction.Description description =
@@ -241,7 +149,7 @@
     boolean enabled = false;
     try (Repository repo = repoManager.openRepository(change.getDest().project());
         RevWalk rw = new RevWalk(repo)) {
-      if (hasOneParent(rw, rsrc.getPatchSet())) {
+      if (RebaseUtil.hasOneParent(rw, rsrc.getPatchSet())) {
         enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
       }
     }
@@ -252,20 +160,6 @@
     return description;
   }
 
-  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
-      @Nullable Map<String, String> validationOptions) {
-    if (validationOptions == null) {
-      return ImmutableListMultimap.of();
-    }
-
-    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
-        ImmutableListMultimap.builder();
-    validationOptions
-        .entrySet()
-        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
-    return validationOptionsBuilder.build();
-  }
-
   public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
     private final PatchSetUtil psUtil;
     private final Rebase rebase;
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChain.java b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
new file mode 100644
index 0000000..786bba7
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
@@ -0,0 +1,277 @@
+// 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.restapi.change;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.GetRelatedChangesUtil;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.RebaseUtil;
+import com.google.gerrit.server.change.RelatedChangesSorter.PatchSetData;
+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.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.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Rest API for rebasing an ancestry chain of changes. */
+@Singleton
+public class RebaseChain
+    implements RestModifyView<ChangeResource, RebaseInput>, UiAction<ChangeResource> {
+  private static final ImmutableSet<ListChangesOption> OPTIONS =
+      Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
+
+  private final GitRepositoryManager repoManager;
+  private final RebaseUtil rebaseUtil;
+  private final GetRelatedChangesUtil getRelatedChangesUtil;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final PermissionBackend permissionBackend;
+  private final BatchUpdate.Factory updateFactory;
+  private final ChangeNotes.Factory notesFactory;
+  private final ProjectCache projectCache;
+  private final PatchSetUtil patchSetUtil;
+  private final ChangeJson.Factory json;
+
+  @Inject
+  RebaseChain(
+      GitRepositoryManager repoManager,
+      RebaseUtil rebaseUtil,
+      GetRelatedChangesUtil getRelatedChangesUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeData.Factory changeDataFactory,
+      PermissionBackend permissionBackend,
+      BatchUpdate.Factory updateFactory,
+      ChangeNotes.Factory notesFactory,
+      ProjectCache projectCache,
+      PatchSetUtil patchSetUtil,
+      ChangeJson.Factory json) {
+    this.repoManager = repoManager;
+    this.getRelatedChangesUtil = getRelatedChangesUtil;
+    this.changeDataFactory = changeDataFactory;
+    this.rebaseUtil = rebaseUtil;
+    this.changeResourceFactory = changeResourceFactory;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.notesFactory = notesFactory;
+    this.projectCache = projectCache;
+    this.patchSetUtil = patchSetUtil;
+    this.json = json;
+  }
+
+  @Override
+  public Response<RebaseChainInfo> apply(ChangeResource tipRsrc, RebaseInput input)
+      throws IOException, PermissionBackendException, RestApiException, UpdateException {
+    tipRsrc.permissions().check(ChangePermission.REBASE);
+
+    Project.NameKey project = tipRsrc.getProject();
+    projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite();
+
+    CurrentUser user = tipRsrc.getUser();
+
+    List<Change.Id> upToDateAncestors = new ArrayList<>();
+    Map<Change.Id, RebaseChangeOp> rebaseOps = new LinkedHashMap<>();
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = CodeReviewCommit.newRevWalk(reader);
+        BatchUpdate bu = updateFactory.create(project, user, TimeUtil.now())) {
+      List<PatchSetData> chain = getChainForCurrentPatchSet(tipRsrc);
+
+      boolean ancestorsAreUpToDate = true;
+      for (int i = 0; i < chain.size(); i++) {
+        ChangeData changeData = chain.get(i).data();
+        PatchSet ps = patchSetUtil.current(changeData.notes());
+        if (ps == null) {
+          throw new IllegalStateException(
+              "current revision is missing for change " + changeData.getId());
+        }
+
+        RevisionResource revRsrc =
+            new RevisionResource(changeResourceFactory.create(changeData, user), ps);
+        revRsrc.permissions().check(ChangePermission.REBASE);
+        rebaseUtil.verifyRebasePreconditions(rw, changeData.notes(), ps);
+
+        boolean isUpToDate = false;
+        RebaseChangeOp rebaseOp = null;
+        if (i == 0) {
+          ObjectId desiredBase =
+              rebaseUtil.parseOrFindBaseRevision(
+                  repo, rw, permissionBackend, revRsrc, input, false);
+          if (currentBase(rw, ps).equals(desiredBase)) {
+            isUpToDate = true;
+          } else {
+            rebaseOp = rebaseUtil.getRebaseOp(revRsrc, input, desiredBase);
+          }
+        } else {
+          if (ancestorsAreUpToDate) {
+            ObjectId latestCommittedBase =
+                PatchSetUtil.getCurrentCommittedRevCommit(
+                    project, rw, notesFactory, chain.get(i - 1).id());
+            isUpToDate = currentBase(rw, ps).equals(latestCommittedBase);
+          }
+          if (!isUpToDate) {
+            rebaseOp = rebaseUtil.getRebaseOp(revRsrc, input, chain.get(i - 1).id());
+          }
+        }
+
+        if (isUpToDate) {
+          upToDateAncestors.add(changeData.getId());
+          continue;
+        }
+        ancestorsAreUpToDate = false;
+        bu.addOp(revRsrc.getChange().getId(), rebaseOp);
+        rebaseOps.put(revRsrc.getChange().getId(), rebaseOp);
+      }
+
+      if (ancestorsAreUpToDate) {
+        throw new ResourceConflictException("The whole chain is already up to date.");
+      }
+
+      bu.setNotify(NotifyResolver.Result.none());
+      bu.setRepository(repo, rw, oi);
+      bu.execute();
+    }
+
+    RebaseChainInfo res = new RebaseChainInfo();
+    res.rebasedChanges = new ArrayList<>();
+    ChangeJson changeJson = json.create(OPTIONS);
+    for (Change.Id c : upToDateAncestors) {
+      res.rebasedChanges.add(changeJson.format(project, c));
+    }
+    for (Map.Entry<Change.Id, RebaseChangeOp> e : rebaseOps.entrySet()) {
+      Change.Id id = e.getKey();
+      RebaseChangeOp op = e.getValue();
+      ChangeInfo changeInfo = changeJson.format(project, id);
+      changeInfo.containsGitConflicts =
+          !op.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
+      res.rebasedChanges.add(changeInfo);
+    }
+    if (res.rebasedChanges.stream()
+        .anyMatch(i -> i.containsGitConflicts != null && i.containsGitConflicts)) {
+      res.containsGitConflicts = true;
+    }
+    return Response.ok(res);
+  }
+
+  @Override
+  public Description getDescription(ChangeResource tipRsrc) throws Exception {
+    UiAction.Description description =
+        new UiAction.Description()
+            .setLabel("Rebase Chain")
+            .setTitle(
+                "Rebase the ancestry chain onto the tip of the target branch. Makes you the "
+                    + "uploader of the changes which can affect validity of approvals.")
+            .setVisible(false);
+
+    Change tip = tipRsrc.getChange();
+    if (!tip.isNew()) {
+      return description;
+    }
+    if (!projectCache
+        .get(tipRsrc.getProject())
+        .orElseThrow(illegalState(tipRsrc.getProject()))
+        .statePermitsWrite()) {
+      return description;
+    }
+
+    if (patchSetUtil.isPatchSetLocked(tipRsrc.getNotes())) {
+      return description;
+    }
+
+    boolean visible = true;
+    boolean enabled = true;
+    try (Repository repo = repoManager.openRepository(tipRsrc.getProject());
+        RevWalk rw = new RevWalk(repo)) {
+      List<PatchSetData> chain = getChainForCurrentPatchSet(tipRsrc);
+      PatchSetData oldestAncestor = chain.get(0);
+      if (rebaseUtil.canRebase(
+          oldestAncestor.patchSet(), oldestAncestor.data().change().getDest(), repo, rw)) {
+        enabled = false;
+      }
+
+      for (PatchSetData ps : chain) {
+        RevisionResource psRsrc =
+            new RevisionResource(
+                changeResourceFactory.create(ps.data(), tipRsrc.getUser()), ps.patchSet());
+
+        if (!psRsrc.permissions().testOrFalse(ChangePermission.REBASE)) {
+          visible = false;
+          break;
+        }
+
+        if (patchSetUtil.isPatchSetLocked(psRsrc.getNotes())) {
+          enabled = false;
+        }
+        if (!RebaseUtil.hasOneParent(rw, psRsrc.getPatchSet())) {
+          enabled = false;
+        }
+      }
+    }
+    return description.setVisible(visible).setEnabled(enabled);
+  }
+
+  private ObjectId currentBase(RevWalk rw, PatchSet ps) throws IOException {
+    return rw.parseCommit(ps.commitId()).getParent(0);
+  }
+
+  private List<PatchSetData> getChainForCurrentPatchSet(ChangeResource rsrc)
+      throws PermissionBackendException, IOException {
+    return Lists.reverse(
+        getRelatedChangesUtil.getAncestors(
+            changeDataFactory.create(rsrc.getNotes()),
+            patchSetUtil.current(rsrc.getNotes()),
+            true));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index c39b1f4..17fc6db 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -17,8 +17,6 @@
 import static com.google.gerrit.entities.RefNames.isConfigRef;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
@@ -32,6 +30,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -48,7 +47,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Map;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -160,7 +158,7 @@
           rsrc.getName(),
           identifiedUser.get(),
           u,
-          getValidateOptionsAsMultimap(input.validationOptions));
+          ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
       RefUpdate.Result result = u.update(rw);
       switch (result) {
         case FAST_FORWARD:
@@ -220,18 +218,4 @@
   private boolean isBranchAllowed(String branch) {
     return !RefNames.isGerritRef(branch) && !branch.startsWith(RefNames.REFS_TAGS);
   }
-
-  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
-      @Nullable Map<String, String> validationOptions) {
-    if (validationOptions == null) {
-      return ImmutableListMultimap.of();
-    }
-
-    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
-        ImmutableListMultimap.builder();
-    validationOptions
-        .entrySet()
-        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
-    return validationOptionsBuilder.build();
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateChange.java b/java/com/google/gerrit/server/restapi/project/CreateChange.java
index 59efd06..2f1153e 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateChange.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectResource;
@@ -59,7 +60,8 @@
       throw new AuthException("Authentication required");
     }
 
-    if (!Strings.isNullOrEmpty(input.project) && !rsrc.getName().equals(input.project)) {
+    if (!Strings.isNullOrEmpty(input.project)
+        && !rsrc.getName().equals(ProjectUtil.sanitizeProjectName(input.project))) {
       throw new BadRequestException("project must match URL");
     }
 
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
index 491b5cd..f3c741f 100644
--- a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -37,6 +37,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
+import java.util.stream.Collectors;
 import javax.inject.Inject;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -216,12 +217,19 @@
                   Arrays.asList(
                       cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_VALUE)))
               .build();
+      ImmutableList<String> refPatterns =
+          ImmutableList.<String>builder()
+              .addAll(
+                  Arrays.asList(
+                      cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_BRANCH)))
+              .build();
       LabelAttributes attributes =
           LabelAttributes.create(
               function == null ? "MaxWithBlock" : function,
               canOverride,
               ignoreSelfApproval,
-              values);
+              values,
+              refPatterns);
       labelTypes.put(labelName, attributes);
     }
     return labelTypes;
@@ -320,6 +328,15 @@
       default:
         break;
     }
+    if (!attributes.refPatterns().isEmpty()) {
+      builder.setApplicabilityExpression(
+          SubmitRequirementExpression.of(
+              String.join(
+                  " OR ",
+                  attributes.refPatterns().stream()
+                      .map(b -> "branch:\\\"" + b + "\\\"")
+                      .collect(Collectors.toList()))));
+    }
     return builder.build();
   }
 
@@ -435,13 +452,16 @@
 
     abstract ImmutableList<String> values();
 
+    abstract ImmutableList<String> refPatterns();
+
     static LabelAttributes create(
         String function,
         boolean canOverride,
         boolean ignoreSelfApproval,
-        ImmutableList<String> values) {
+        ImmutableList<String> values,
+        ImmutableList<String> refPatterns) {
       return new AutoValue_MigrateLabelFunctionsToSubmitRequirement_LabelAttributes(
-          function, canOverride, ignoreSelfApproval, values);
+          function, canOverride, ignoreSelfApproval, values, refPatterns);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index a34aeac..14a636f 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -535,9 +535,6 @@
             // Multiply the timeout by the number of projects we're actually attempting to
             // submit. Times 2 to retry more persistently, to increase success rate.
             .defaultTimeoutMultiplier(filteredNoteDbChangeSet.projects().size() * 2)
-            // 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);
 
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index f638078..bdda3fc5 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.SubmissionId;
@@ -76,6 +77,8 @@
  * merged.
  */
 public abstract class SubmitStrategy {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static Module module() {
     return new FactoryModule() {
       @Override
@@ -275,6 +278,7 @@
       Change.Id id = c.change().getId();
       bu.addOp(id, args.setPrivateOpFactory.create(false, null));
       ImplicitIntegrateOp implicitIntegrateOp = new ImplicitIntegrateOp(args, c);
+      logger.atFine().log("Add implicit integrate op: %s", implicitIntegrateOp);
       bu.addOp(id, implicitIntegrateOp);
       maybeAddTestHelperOp(bu, id);
       this.submitStrategyOps.add(implicitIntegrateOp);
@@ -282,6 +286,7 @@
 
     // Then ops for explicitly merged changes
     for (SubmitStrategyOp op : ops) {
+      logger.atFine().log("Add explicit integrate op: %s", op);
       bu.addOp(op.getId(), args.setPrivateOpFactory.create(false, null));
       bu.addOp(op.getId(), op);
       maybeAddTestHelperOp(bu, op.getId());
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index bb2b1a4..96dc326 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -21,6 +21,7 @@
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.base.MoreObjects;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
@@ -127,7 +128,8 @@
       logger.atFine().log("No merge tip, no update to perform");
       return;
     }
-    logger.atFine().log("Moved tip from %s to %s", tipBefore, tipAfter);
+    logger.atFine().log(
+        "Moved tip from %s to %s (branch = %s)", tipBefore, tipAfter, getDest().branch());
 
     checkProjectConfig(ctx, tipAfter);
 
@@ -567,4 +569,14 @@
           e);
     }
   }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("commit", getCommit().name())
+        .add("changeId", getId())
+        .add("dest", getDest().branch())
+        .add("project", getProject())
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 32529f7..412a8ee 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -374,8 +374,13 @@
 
   /** Per-change result status from {@link #executeChangeOps}. */
   private enum ChangeResult {
+    /** Change was not modified by any of the batch update ops. */
     SKIPPED,
+
+    /** Change was inserted or updated. */
     UPSERTED,
+
+    /** Change was deleted. */
     DELETED
   }
 
@@ -669,7 +674,7 @@
             indexFutures.add(indexer.indexAsync(project, id));
             break;
           case DELETED:
-            indexFutures.add(indexer.deleteAsync(id));
+            indexFutures.add(indexer.deleteAsync(project, id));
             break;
           case SKIPPED:
             break;
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index aadf6d4..b828037 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -191,6 +192,7 @@
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
     install(new AuthModule(authConfig));
     install(new GerritApiModule());
+    install(new ProjectQueryBuilderModule());
     factory(PluginUser.Factory.class);
     install(new PluginApiModule());
     install(new DefaultPermissionBackendModule());
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index 4a97bc5..c8f89cf 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Injector;
 import java.time.ZoneId;
+import java.util.Optional;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -77,15 +78,20 @@
   }
 
   public static ChangeUpdate newUpdate(
-      Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
+      Injector injector, Change c, Optional<CurrentUser> user, boolean shouldExist)
+      throws Exception {
     injector =
         injector.createChildInjector(
             new FactoryModule() {
               @Override
               public void configure() {
-                bind(CurrentUser.class).toInstance(user);
+                if (user.isPresent()) {
+                  // user may be already bound in injector
+                  bind(CurrentUser.class).toInstance(user.get());
+                }
               }
             });
+    CurrentUser currentUser = injector.getProvider(CurrentUser.class).get();
     ChangeUpdate update =
         injector
             .getInstance(ChangeUpdate.Factory.class)
@@ -93,7 +99,7 @@
                 new ChangeNotes(
                         injector.getInstance(AbstractChangeNotes.Args.class), c, shouldExist, null)
                     .load(),
-                user,
+                currentUser,
                 TimeUtil.now(),
                 Ordering.natural());
 
@@ -109,7 +115,9 @@
     try (Repository repo = repoManager.openRepository(c.getProject());
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
       PersonIdent ident =
-          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), ZoneId.systemDefault());
+          currentUser
+              .asIdentifiedUser()
+              .newCommitterIdent(update.getWhen(), ZoneId.systemDefault());
       TestRepository<Repository>.CommitBuilder cb =
           tr.commit()
               .author(ident)
diff --git a/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java b/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java
new file mode 100644
index 0000000..27e4b17
--- /dev/null
+++ b/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.util.cli;
+
+import com.google.common.base.CaseFormat;
+import com.google.common.reflect.ClassPath;
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Utility to generate Protocol Buffers (*.proto) files from existing POJO API types.
+ *
+ * <p>Usage:
+ *
+ * <ul>
+ *   <li>Print proto representation of all API objects: {@code bazelisk run
+ *       java/com/google/gerrit/util/cli:protogen}
+ * </ul>
+ */
+public class ApiProtocolBufferGenerator {
+  private static String NOTICE =
+      "// Copyright (C) 2023 The Android Open Source Project\n"
+          + "//\n"
+          + "// Licensed under the Apache License, Version 2.0 (the \"License\");\n"
+          + "// you may not use this file except in compliance with the License.\n"
+          + "// You may obtain a copy of the License at\n"
+          + "//\n"
+          + "// http://www.apache.org/licenses/LICENSE-2.0\n"
+          + "//\n"
+          + "// Unless required by applicable law or agreed to in writing, software\n"
+          + "// distributed under the License is distributed on an \"AS IS\" BASIS,\n"
+          + "// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n"
+          + "// See the License for the specific language governing permissions and\n"
+          + "// limitations under the License.";
+
+  private static String PACKAGE = "com.google.gerrit.extensions.common";
+
+  public static void main(String[] args) {
+    try {
+      ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+          .filter(c -> c.getPackageName().equalsIgnoreCase(PACKAGE))
+          .filter(c -> c.getName().endsWith("Input") || c.getName().endsWith("Info"))
+          .map(clazz -> clazz.load())
+          .forEach(ApiProtocolBufferGenerator::exportSingleClass);
+    } catch (Exception e) {
+      System.err.println(e);
+    }
+  }
+
+  private static void exportSingleClass(Class<?> clazz) {
+    StringBuilder proto = new StringBuilder(NOTICE);
+    proto.append("\n\nsyntax = \"proto3\";");
+    proto.append("\n\npackage gerrit.api;");
+    proto.append("\n\noption java_package = \"" + PACKAGE + "\";");
+
+    int fieldNumber = 1;
+
+    proto.append("\n\n\nmessage " + clazz.getSimpleName() + " {\n");
+
+    for (Field f : clazz.getFields()) {
+      Class<?> type = f.getType();
+
+      if (type.isAssignableFrom(List.class)) {
+        ParameterizedType list = (ParameterizedType) f.getGenericType();
+        Class<?> genericType = (Class<?>) list.getActualTypeArguments()[0];
+        String protoType =
+            protoType(genericType)
+                .orElseThrow(() -> new IllegalStateException("unknown type: " + genericType));
+        proto.append(
+            String.format(
+                "repeated %s %s = %d;\n", protoType, protoName(f.getName()), fieldNumber));
+      } else if (type.isAssignableFrom(Map.class)) {
+        ParameterizedType map = (ParameterizedType) f.getGenericType();
+        Class<?> key = (Class<?>) map.getActualTypeArguments()[0];
+        if (map.getActualTypeArguments()[1] instanceof ParameterizedType) {
+          // TODO: This is list multimap which proto doesn't support. Move to
+          // it's own types.
+          proto.append(
+              "reserved "
+                  + fieldNumber
+                  + "; // TODO(hiesel): Add support for map<?,repeated <?>>\n");
+        } else {
+          Class<?> value = (Class<?>) map.getActualTypeArguments()[1];
+          String keyProtoType =
+              protoType(key).orElseThrow(() -> new IllegalStateException("unknown type: " + key));
+          String valueProtoType =
+              protoType(value)
+                  .orElseThrow(() -> new IllegalStateException("unknown type: " + value));
+          proto.append(
+              String.format(
+                  "map<%s,%s> %s = %d;\n",
+                  keyProtoType, valueProtoType, protoName(f.getName()), fieldNumber));
+        }
+      } else if (protoType(type).isPresent()) {
+        proto.append(
+            String.format(
+                "%s %s = %d;\n", protoType(type).get(), protoName(f.getName()), fieldNumber));
+      } else {
+        proto.append(
+            "reserved "
+                + fieldNumber
+                + "; // TODO(hiesel): Add support for "
+                + type.getName()
+                + "\n");
+      }
+      fieldNumber++;
+    }
+    proto.append("}");
+
+    System.out.println(proto);
+  }
+
+  private static Optional<String> protoType(Class<?> type) {
+    if (isInt(type)) {
+      return Optional.of("int32");
+    } else if (isLong(type)) {
+      return Optional.of("int64");
+    } else if (isChar(type)) {
+      return Optional.of("string");
+    } else if (isShort(type)) {
+      return Optional.of("int32");
+    } else if (isShort(type)) {
+      return Optional.of("int32");
+    } else if (isBoolean(type)) {
+      return Optional.of("bool");
+    } else if (type.isAssignableFrom(String.class)) {
+      return Optional.of("string");
+    } else if (type.isAssignableFrom(Timestamp.class)) {
+      // See https://gerrit-review.googlesource.com/Documentation/rest-api.html#timestamp
+      return Optional.of("string");
+    } else if (type.getPackageName().startsWith("com.google.gerrit.extensions")) {
+      return Optional.of("gerrit.api." + type.getSimpleName());
+    }
+    return Optional.empty();
+  }
+
+  private static boolean isInt(Class<?> type) {
+    return type.isAssignableFrom(Integer.class) || type.isAssignableFrom(int.class);
+  }
+
+  private static boolean isLong(Class<?> type) {
+    return type.isAssignableFrom(Long.class) || type.isAssignableFrom(long.class);
+  }
+
+  private static boolean isChar(Class<?> type) {
+    return type.isAssignableFrom(Character.class) || type.isAssignableFrom(char.class);
+  }
+
+  private static boolean isShort(Class<?> type) {
+    return type.isAssignableFrom(Short.class) || type.isAssignableFrom(short.class);
+  }
+
+  private static boolean isBoolean(Class<?> type) {
+    return type.isAssignableFrom(Boolean.class) || type.isAssignableFrom(boolean.class);
+  }
+
+  private static String protoName(String name) {
+    return CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, name);
+  }
+}
diff --git a/java/com/google/gerrit/util/cli/BUILD b/java/com/google/gerrit/util/cli/BUILD
index ebcc67e..b464f32 100644
--- a/java/com/google/gerrit/util/cli/BUILD
+++ b/java/com/google/gerrit/util/cli/BUILD
@@ -2,7 +2,10 @@
 
 java_library(
     name = "cli",
-    srcs = glob(["**/*.java"]),
+    srcs = glob(
+        ["**/*.java"],
+        exclude = ["ApiProtocolBufferGenerator.java"],
+    ),
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
@@ -14,3 +17,15 @@
         "//lib/guice:guice-assistedinject",
     ],
 )
+
+# Util to generate *.proto files from *Info and *Input objects
+java_binary(
+    name = "protogen",
+    srcs = ["ApiProtocolBufferGenerator.java"],
+    main_class = "com.google.gerrit.util.cli.ApiProtocolBufferGenerator",
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//lib:guava",
+        "//lib:protobuf",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 76009bf..215d1e8 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -28,6 +28,7 @@
 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.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabelRemoval;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
@@ -47,7 +48,6 @@
 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;
@@ -62,7 +62,6 @@
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
-import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheStats;
@@ -118,9 +117,7 @@
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
-import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
@@ -148,33 +145,27 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.GitPerson;
 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.AttentionSetListener;
-import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 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;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
-import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.ChangeMessageModifier;
-import com.google.gerrit.server.git.validators.CommitValidationException;
-import com.google.gerrit.server.git.validators.CommitValidationListener;
-import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -196,7 +187,6 @@
 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.text.MessageFormat;
@@ -218,7 +208,6 @@
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
@@ -241,6 +230,7 @@
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private IndexOperations.Change changeIndexOperations;
+  @Inject private AccountControl.Factory accountControlFactory;
 
   @Inject
   @Named("diff_intraline")
@@ -779,366 +769,6 @@
     assertThat(thrown).hasMessageThat().contains("Multiple changes found for " + changeId);
   }
 
-  @FunctionalInterface
-  private interface Rebase {
-    void call(String id) throws RestApiException;
-  }
-
-  @Test
-  public void rebaseViaRevisionApi() throws Exception {
-    testRebase(id -> gApi.changes().id(id).current().rebase());
-  }
-
-  @Test
-  public void rebaseViaChangeApi() throws Exception {
-    testRebase(id -> gApi.changes().id(id).rebase());
-  }
-
-  private void testRebase(Rebase rebase) throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    // Add an approval whose score should be copied on trivial rebase
-    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
-
-    String changeId = r2.getChangeId();
-    // Rebase the second change
-    rebase.call(changeId);
-
-    // Second change should have 2 patch sets and an approval
-    ChangeInfo c2 = gApi.changes().id(changeId).get(CURRENT_REVISION, DETAILED_LABELS);
-    assertThat(c2.revisions.get(c2.currentRevision)._number).isEqualTo(2);
-
-    // ...and the committer and description should be correct
-    ChangeInfo info = gApi.changes().id(changeId).get(CURRENT_REVISION, CURRENT_COMMIT);
-    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
-    assertThat(committer.name).isEqualTo(admin.fullName());
-    assertThat(committer.email).isEqualTo(admin.email());
-    String description = info.revisions.get(info.currentRevision).description;
-    assertThat(description).isEqualTo("Rebase");
-
-    // ...and the approval was copied
-    LabelInfo cr = c2.labels.get(LabelId.CODE_REVIEW);
-    assertThat(cr).isNotNull();
-    assertThat(cr.all).hasSize(1);
-    assertThat(cr.all.get(0).value).isEqualTo(1);
-
-    // Rebasing the second change again should fail
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().rebase());
-    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
-  }
-
-  @Test
-  public void rebaseAsUploaderInAttentionSet() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    TestAccount admin2 = accountCreator.admin2();
-    requestScopeOperations.setApiUser(admin2.id());
-    amendChangeWithUploader(r2, project, admin2);
-    gApi.changes()
-        .id(r2.getChangeId())
-        .addToAttentionSet(new AttentionSetInput(admin2.id().toString(), "manual update"));
-
-    gApi.changes().id(r2.getChangeId()).rebase();
-  }
-
-  @Test
-  public void rebaseOnChangeNumber() throws Exception {
-    String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
-
-    Change.Id id1 = r1.getChange().getId();
-    RebaseInput in = new RebaseInput();
-    in.base = id1.toString();
-    gApi.changes().id(r2.getChangeId()).rebase(in);
-
-    Change.Id id2 = r2.getChange().getId();
-    ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
-    List<RelatedChangeAndCommitInfo> related =
-        gApi.changes().id(id2.get()).revision(ri2._number).related().changes;
-    assertThat(related).hasSize(2);
-    assertThat(related.get(0)._changeNumber).isEqualTo(id2.get());
-    assertThat(related.get(0)._revisionNumber).isEqualTo(2);
-    assertThat(related.get(1)._changeNumber).isEqualTo(id1.get());
-    assertThat(related.get(1)._revisionNumber).isEqualTo(1);
-  }
-
-  @Test
-  public void rebaseOnClosedChange() throws Exception {
-    String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
-
-    // Submit first change.
-    Change.Id id1 = r1.getChange().getId();
-    gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id1.get()).current().submit();
-
-    // Rebase second change on first change.
-    RebaseInput in = new RebaseInput();
-    in.base = id1.toString();
-    gApi.changes().id(r2.getChangeId()).rebase(in);
-
-    Change.Id id2 = r2.getChange().getId();
-    ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
-    assertThat(gApi.changes().id(id2.get()).revision(ri2._number).related().changes).isEmpty();
-  }
-
-  @Test
-  public void rebaseOnNonExistingChange() throws Exception {
-    String changeId = createChange().getChangeId();
-    RebaseInput in = new RebaseInput();
-    in.base = "999999";
-    UnprocessableEntityException exception =
-        assertThrows(
-            UnprocessableEntityException.class, () -> gApi.changes().id(changeId).rebase(in));
-    assertThat(exception).hasMessageThat().isEqualTo("Base change not found: " + in.base);
-  }
-
-  @Test
-  public void rebaseFromRelationChainToClosedChange() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset("HEAD~1");
-
-    createChange();
-    PushOneCommit.Result r3 = createChange();
-
-    // Submit first change.
-    Change.Id id1 = r1.getChange().getId();
-    gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id1.get()).current().submit();
-
-    // Rebase third change on first change.
-    RebaseInput in = new RebaseInput();
-    in.base = id1.toString();
-    gApi.changes().id(r3.getChangeId()).rebase(in);
-
-    Change.Id id3 = r3.getChange().getId();
-    ChangeInfo ci3 = get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    RevisionInfo ri3 = ci3.revisions.get(ci3.currentRevision);
-    assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
-    assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
-  }
-
-  @Test
-  public void rebaseNotAllowedWithoutPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    requestScopeOperations.setApiUser(user.id());
-    AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
-    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
-  }
-
-  @Test
-  public void rebaseAllowedWithPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
-        .update();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).rebase();
-  }
-
-  @Test
-  public void rebaseNotAllowedWithoutPushPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
-        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
-        .update();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    requestScopeOperations.setApiUser(user.id());
-    AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
-    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
-  }
-
-  @Test
-  public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
-        .update();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
-    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
-  }
-
-  @Test
-  public void rebaseWithValidationOptions() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    RebaseInput rebaseInput = new RebaseInput();
-    rebaseInput.validationOptions = ImmutableMap.of("key", "value");
-
-    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
-      // Rebase the second change
-      gApi.changes().id(r2.getChangeId()).current().rebase(rebaseInput);
-      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
-          .containsExactly("key", "value");
-    }
-  }
-
-  @Test
-  public void rebaseOutdatedPatchSet() throws Exception {
-    String fileName1 = "a.txt";
-    String fileContent1 = "some content";
-    String fileName2 = "b.txt";
-    String fileContent2Ps1 = "foo";
-    String fileContent2Ps2 = "foo/bar";
-
-    // Create two changes both with the same parent touching disjunct files
-    PushOneCommit.Result r =
-        pushFactory
-            .create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName1, fileContent1)
-            .to("refs/for/master");
-    r.assertOkStatus();
-    String changeId1 = r.getChangeId();
-    testRepo.reset("HEAD~1");
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName2, fileContent2Ps1);
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-    r2.assertOkStatus();
-    String changeId2 = r2.getChangeId();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(changeId1).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    // Amend the second change so that it has 2 patch sets
-    amendChange(
-            changeId2,
-            "refs/for/master",
-            admin,
-            testRepo,
-            PushOneCommit.SUBJECT,
-            fileName2,
-            fileContent2Ps2)
-        .assertOkStatus();
-    ChangeInfo changeInfo2 = gApi.changes().id(changeId2).get();
-    assertThat(changeInfo2.revisions.get(changeInfo2.currentRevision)._number).isEqualTo(2);
-
-    // Rebase the first patch set of the second change
-    gApi.changes().id(changeId2).revision(1).rebase();
-
-    // Second change should have 3 patch sets
-    changeInfo2 = gApi.changes().id(changeId2).get();
-    assertThat(changeInfo2.revisions.get(changeInfo2.currentRevision)._number).isEqualTo(3);
-
-    // ... and the committer and description should be correct
-    ChangeInfo info = gApi.changes().id(changeId2).get(CURRENT_REVISION, CURRENT_COMMIT);
-    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
-    assertThat(committer.name).isEqualTo(admin.fullName());
-    assertThat(committer.email).isEqualTo(admin.email());
-    String description = info.revisions.get(info.currentRevision).description;
-    assertThat(description).isEqualTo("Rebase");
-
-    // ... and the file contents should match with patch set 1 based on change1
-    assertThat(gApi.changes().id(changeId2).current().file(fileName1).content().asString())
-        .isEqualTo(fileContent1);
-    assertThat(gApi.changes().id(changeId2).current().file(fileName2).content().asString())
-        .isEqualTo(fileContent2Ps1);
-  }
-
   @Test
   public void deleteNewChangeAsAdmin() throws Exception {
     deleteChangeAsUser(admin, admin);
@@ -1471,166 +1101,6 @@
   }
 
   @Test
-  public void rebaseUpToDateChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase());
-    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
-  }
-
-  @Test
-  public void rebaseConflict() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    gApi.changes()
-        .id(r1.getChangeId())
-        .revision(r1.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
-
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "other content",
-            "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-    r2.assertOkStatus();
-    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
   public void attentionSetListener_firesOnChange() throws Exception {
     PushOneCommit.Result r1 = createChange();
     AttentionSetInput addUser = new AttentionSetInput(user.email(), "some reason");
@@ -1663,156 +1133,6 @@
   }
 
   @Test
-  public void rebaseChangeBase() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-    PushOneCommit.Result r3 = createChange();
-    RebaseInput ri = new RebaseInput();
-
-    // rebase r3 directly onto master (break dep. towards r2)
-    ri.base = "";
-    gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).rebase(ri);
-    PatchSet ps3 = r3.getPatchSet();
-    assertThat(ps3.id().get()).isEqualTo(2);
-
-    // rebase r2 onto r3 (referenced by ref)
-    ri.base = ps3.id().toRefName();
-    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
-    PatchSet ps2 = r2.getPatchSet();
-    assertThat(ps2.id().get()).isEqualTo(2);
-
-    // rebase r1 onto r2 (referenced by commit)
-    ri.base = ps2.commitId().name();
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
-    PatchSet ps1 = r1.getPatchSet();
-    assertThat(ps1.id().get()).isEqualTo(2);
-
-    // rebase r1 onto r3 (referenced by change number)
-    ri.base = String.valueOf(r3.getChange().getId().get());
-    gApi.changes().id(r1.getChangeId()).revision(ps1.commitId().name()).rebase(ri);
-    assertThat(r1.getPatchSetId().get()).isEqualTo(3);
-  }
-
-  @Test
-  public void rebaseChangeBaseRecursion() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-
-    RebaseInput ri = new RebaseInput();
-    ri.base = r2.getCommit().name();
-    String expectedMessage =
-        "base change "
-            + r2.getChangeId()
-            + " is a descendant of the current change - recursion not allowed";
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri));
-    assertThat(thrown).hasMessageThat().contains(expectedMessage);
-  }
-
-  @Test
-  public void rebaseAbandonedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = info(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(changeId).revision(r.getCommit().name()).rebase());
-    assertThat(thrown).hasMessageThat().contains("change is abandoned");
-  }
-
-  @Test
-  public void rebaseOntoAbandonedChange() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Abandon the first change
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = info(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
-    RebaseInput ri = new RebaseInput();
-    ri.base = r.getCommit().name();
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri));
-    assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
-  }
-
-  @Test
-  public void rebaseOntoSelf() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    String commit = r.getCommit().name();
-    RebaseInput ri = new RebaseInput();
-    ri.base = commit;
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(changeId).revision(commit).rebase(ri));
-    assertThat(thrown).hasMessageThat().contains("cannot rebase change onto itself");
-  }
-
-  @Test
-  public void cannotRebaseOntoBaseThatIsNotPresentInTargetBranch() throws Exception {
-    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-
-    BranchInput branchInput = new BranchInput();
-    branchInput.revision = initial.getName();
-    gApi.projects().name(project.get()).branch("foo").create(branchInput);
-
-    PushOneCommit.Result r1 =
-        pushFactory
-            .create(admin.newIdent(), testRepo, "Change on foo branch", "a.txt", "a-content")
-            .to("refs/for/foo");
-    approve(r1.getChangeId());
-    gApi.changes().id(r1.getChangeId()).current().submit();
-
-    // reset HEAD in order to create a sibling of the first change
-    testRepo.reset(initial);
-
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(admin.newIdent(), testRepo, "Change on master branch", "b.txt", "b-content")
-            .to("refs/for/master");
-
-    RebaseInput rebaseInput = new RebaseInput();
-    rebaseInput.base = r1.getCommit().getName();
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r2.getChangeId()).current().rebase(rebaseInput));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            String.format(
-                "base change is targeting wrong branch: %s,refs/heads/foo", project.get()));
-
-    rebaseInput.base = "refs/heads/foo";
-    thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r2.getChangeId()).current().rebase(rebaseInput));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            String.format(
-                "base revision is missing from the destination branch: %s", rebaseInput.base));
-  }
-
-  @Test
   @TestProjectInput(createEmptyCommit = false)
   public void changeNoParentToOneParent() throws Exception {
     // create initial commit with no parent and push it as change, so that patch
@@ -2805,6 +2125,78 @@
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void removeNonVisibleReviewer() throws Exception {
+    // allow all users to remove reviewers
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    AccountInfo reviewerInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(changeId).get().reviewers.get(ReviewerState.REVIEWER));
+    assertThat(reviewerInfo._accountId).isEqualTo(user.id().get());
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+
+    // user2 cannot see user
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+        .isFalse();
+
+    gApi.changes().id(changeId).reviewer(user.id().toString()).remove(new DeleteReviewerInput());
+    assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void removeNonVisibleReviewerThroughPostReview() throws Exception {
+    // allow all users to remove reviewers
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    AccountInfo reviewerInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(changeId).get().reviewers.get(ReviewerState.REVIEWER));
+    assertThat(reviewerInfo._accountId).isEqualTo(user.id().get());
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+
+    // user2 cannot see user
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+        .isFalse();
+
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    reviewerInput.state = ReviewerState.REMOVED;
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewers = ImmutableList.of(reviewerInput);
+    ReviewResult reviewResult = gApi.changes().id(changeId).current().review(reviewInput);
+    assertThat(reviewResult.error).isNull();
+
+    // user is removed as a reviewer, user2 is added as a CC by doing the post review request that
+    // removed user as a reviewer
+    assertThat(gApi.changes().id(changeId).get().reviewers.get(ReviewerState.REVIEWER)).isNull();
+    reviewerInfo =
+        Iterables.getOnlyElement(gApi.changes().id(changeId).get().reviewers.get(ReviewerState.CC));
+    assertThat(reviewerInfo._accountId).isEqualTo(user2.id().get());
+  }
+
+  @Test
   public void removeReviewerNotPermitted() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -3016,7 +2408,68 @@
                     .id(r.getChangeId())
                     .reviewer(admin.id().toString())
                     .deleteVote(LabelId.CODE_REVIEW));
-    assertThat(thrown).hasMessageThat().contains("delete vote not permitted");
+    assertThat(thrown).hasMessageThat().contains("Delete vote not permitted");
+  }
+
+  @Test
+  public void deleteVoteAlwaysPermittedForSelfVotes() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
+  }
+
+  @Test
+  public void deleteVoteAlwaysPermittedForAdmin() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
   }
 
   @Test
@@ -3312,6 +2765,40 @@
   }
 
   @Test
+  public void queryChangesDefaultFieldMatchesOwner() throws Exception {
+    // We have to create a new user since changes are not deleted between tests, which means
+    // querying the standard users will lead to dirty results.
+    TestAccount changeOwner = accountCreator.createValid("changeOwner");
+    requestScopeOperations.setApiUser(changeOwner.id());
+    // Creating a change through the API since PushOneCommit changes are always owned by admin().
+    ChangeInput in = new ChangeInput();
+    in.branch = Constants.MASTER;
+    in.subject = "subject";
+    in.project = project.get();
+    ChangeInfo info = gApi.changes().createAsInfo(in);
+    assertThat(info.owner._accountId).isEqualTo(changeOwner.id().get());
+    requestScopeOperations.setApiUser(user.id());
+    List<ChangeInfo> results = query(changeOwner.email());
+    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(info.changeId);
+  }
+
+  @Test
+  public void queryChangesDefaultFieldMatchesReviewer() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    // We have to create a new user since changes are not deleted between tests, which means
+    // querying the standard users will lead to dirty results.
+    TestAccount changeReviewer = accountCreator.createValid("changeReviewer");
+    gApi.changes().id(r.getChangeId()).addReviewer(changeReviewer.email());
+    requestScopeOperations.setApiUser(user.id());
+    List<ChangeInfo> results = query(changeReviewer.email());
+    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r.getChangeId());
+  }
+
+  @Test
   public void checkReviewedFlagBeforeAndAfterReview() throws Exception {
     PushOneCommit.Result r = createChange();
     ReviewerInput in = new ReviewerInput();
@@ -3572,7 +3059,11 @@
         .review(ReviewInput.approve());
     gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
 
-    createChange();
+    PushOneCommit.Result change = createChange();
+    // Populate change with a reasonable set of fields. We can't exhaustively
+    // test all possible variations, but can try to cover a reasonable set.
+    approve(change.getChangeId());
+    gApi.changes().id(change.getChangeId()).addReviewer(user.email());
 
     requestScopeOperations.setApiUser(user.id());
     try (AutoCloseable ignored = disableNoteDb()) {
@@ -3587,6 +3078,34 @@
   }
 
   @Test
+  public void nonLazyloadQueryOptionsDoNotTouchDatabase() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r1 = createChange();
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+    PushOneCommit.Result change = createChange();
+    // Populate change with a reasonable set of fields. We can't exhaustively
+    // test all possible variations, but can try to cover a reasonable set.
+    approve(change.getChangeId());
+    gApi.changes().id(change.getChangeId()).addReviewer(user.email());
+
+    requestScopeOperations.setApiUser(user.id());
+    try (AutoCloseable ignored = disableNoteDb()) {
+      assertThat(
+              gApi.changes()
+                  .query()
+                  .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+                  .withOptions(EnumSet.complementOf(EnumSet.copyOf(ChangeJson.REQUIRE_LAZY_LOAD)))
+                  .get())
+          .hasSize(2);
+    }
+  }
+
+  @Test
   public void votable() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -3851,6 +3370,7 @@
     assertThat(change.status).isEqualTo(ChangeStatus.NEW);
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.removableLabels).isEmpty();
 
     // add new label and assert that it's returned for existing changes
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
@@ -3880,6 +3400,9 @@
         .id(r.getChangeId())
         .revision(r.getCommit().name())
         .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertPermitted(change, LabelId.VERIFIED, -1, 0, 1);
+    assertOnlyRemovableLabel(change, LabelId.VERIFIED, "+1", admin);
 
     try (ProjectConfigUpdate u = updateProject(project)) {
       // remove label and assert that it's no longer returned for existing
@@ -3899,6 +3422,7 @@
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.removableLabels).isEmpty();
 
     // abandon the change and see that the returned labels stay the same
     // while all permitted labels disappear.
@@ -3907,6 +3431,7 @@
     assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels).isEmpty();
+    assertThat(change.removableLabels).isEmpty();
   }
 
   @Test
@@ -4023,6 +3548,7 @@
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
     assertPermitted(change, LabelId.VERIFIED, 1);
+    assertThat(change.removableLabels).isEmpty();
 
     // remove label and assert that it's no longer returned for existing
     // changes, even if there is an approval for it
@@ -4040,6 +3566,7 @@
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
+    assertThat(change.removableLabels).isEmpty();
   }
 
   @Test
@@ -4136,6 +3663,7 @@
         .containsExactly(LabelId.CODE_REVIEW, "Non-Author-Code-Review");
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
+    assertThat(change.removableLabels).isEmpty();
   }
 
   @Test
@@ -4151,6 +3679,7 @@
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
+    assertThat(change.removableLabels).isEmpty();
   }
 
   @Test
@@ -4857,8 +4386,12 @@
             ListChangesOption.SKIP_DIFFSTAT);
 
     PushOneCommit.Result change = createChange();
-    int number = gApi.changes().id(change.getChangeId()).get()._number;
+    // Populate change with a reasonable set of fields. We can't exhaustively
+    // test all possible variations, but can try to cover a reasonable set.
+    approve(change.getChangeId());
+    gApi.changes().id(change.getChangeId()).addReviewer(user.email());
 
+    int number = gApi.changes().id(change.getChangeId()).get()._number;
     try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
       assertThat(gApi.changes().id(project.get(), number).get(options).changeId)
           .isEqualTo(change.getChangeId());
@@ -5030,19 +4563,6 @@
     void call(String changeId, String reviewer) throws RestApiException;
   }
 
-  private static class TestWorkInProgressStateChangedListener
-      implements WorkInProgressStateChangedListener {
-    boolean invoked;
-    Boolean wip;
-
-    @Override
-    public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event event) {
-      this.invoked = true;
-      this.wip =
-          event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
-    }
-  }
-
   public static class TestAttentionSetListenerModule extends AbstractModule {
     @Override
     public void configure() {
@@ -5067,15 +4587,4 @@
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
-
-  private static class TestCommitValidationListener implements CommitValidationListener {
-    public CommitReceivedEvent receiveEvent;
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      this.receiveEvent = receiveEvent;
-      return ImmutableList.of();
-    }
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
new file mode 100644
index 0000000..522013e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -0,0 +1,1142 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+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.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+  RebaseIT.RebaseViaRevisionApi.class, //
+  RebaseIT.RebaseViaChangeApi.class, //
+  RebaseIT.RebaseChain.class, //
+})
+public class RebaseIT {
+  public abstract static class Base extends AbstractDaemonTest {
+    @Inject protected RequestScopeOperations requestScopeOperations;
+    @Inject protected ProjectOperations projectOperations;
+    @Inject protected ExtensionRegistry extensionRegistry;
+
+    @FunctionalInterface
+    protected interface RebaseCall {
+      void call(String id) throws RestApiException;
+    }
+
+    @FunctionalInterface
+    protected interface RebaseCallWithInput {
+      void call(String id, RebaseInput in) throws RestApiException;
+    }
+
+    protected RebaseCall rebaseCall;
+    protected RebaseCallWithInput rebaseCallWithInput;
+
+    protected void init(RebaseCall call, RebaseCallWithInput callWithInput) {
+      this.rebaseCall = call;
+      this.rebaseCallWithInput = callWithInput;
+    }
+
+    @Test
+    public void rebaseChange() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Add an approval whose score should be copied on trivial rebase
+      gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+
+      // Rebase the second change
+      rebaseCall.call(r2.getChangeId());
+
+      verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), true, 2);
+
+      // Rebasing the second change again should fail
+      verifyChangeIsUpToDate(r2);
+    }
+
+    @Test
+    public void rebaseAbandonedChange() throws Exception {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo info = info(changeId);
+      assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("Change " + r.getChange().getId() + " is abandoned");
+    }
+
+    @Test
+    public void rebaseOntoAbandonedChange() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Abandon the first change
+      String changeId = r.getChangeId();
+      assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo info = info(changeId);
+      assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+      RebaseInput ri = new RebaseInput();
+      ri.base = r.getCommit().name();
+
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r2.getChangeId(), ri));
+      assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
+    }
+
+    @Test
+    public void rebaseOntoSelf() throws Exception {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String commit = r.getCommit().name();
+      RebaseInput ri = new RebaseInput();
+      ri.base = commit;
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class, () -> rebaseCallWithInput.call(changeId, ri));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("cannot rebase change " + r.getChange().getId() + " onto itself");
+    }
+
+    @Test
+    public void rebaseChangeBaseRecursion() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      PushOneCommit.Result r2 = createChange();
+
+      RebaseInput ri = new RebaseInput();
+      ri.base = r2.getCommit().name();
+      String expectedMessage =
+          "base change "
+              + r2.getChangeId()
+              + " is a descendant of the current change - recursion not allowed";
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r1.getChangeId(), ri));
+      assertThat(thrown).hasMessageThat().contains(expectedMessage);
+    }
+
+    @Test
+    public void cannotRebaseOntoBaseThatIsNotPresentInTargetBranch() throws Exception {
+      ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+      BranchInput branchInput = new BranchInput();
+      branchInput.revision = initial.getName();
+      gApi.projects().name(project.get()).branch("foo").create(branchInput);
+
+      PushOneCommit.Result r1 =
+          pushFactory
+              .create(admin.newIdent(), testRepo, "Change on foo branch", "a.txt", "a-content")
+              .to("refs/for/foo");
+      approve(r1.getChangeId());
+      gApi.changes().id(r1.getChangeId()).current().submit();
+
+      // reset HEAD in order to create a sibling of the first change
+      testRepo.reset(initial);
+
+      PushOneCommit.Result r2 =
+          pushFactory
+              .create(admin.newIdent(), testRepo, "Change on master branch", "b.txt", "b-content")
+              .to("refs/for/master");
+
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.base = r1.getCommit().getName();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r2.getChangeId(), rebaseInput));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format(
+                  "base change is targeting wrong branch: %s,refs/heads/foo", project.get()));
+
+      rebaseInput.base = "refs/heads/foo";
+      thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r2.getChangeId(), rebaseInput));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format(
+                  "base revision is missing from the destination branch: %s", rebaseInput.base));
+    }
+
+    @Test
+    public void rebaseUpToDateChange() throws Exception {
+      PushOneCommit.Result r = createChange();
+      verifyChangeIsUpToDate(r);
+    }
+
+    @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();
+
+      rebaseCall.call(r.getChangeId());
+
+      // 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();
+
+      rebaseCall.call(r.getChangeId());
+
+      // change is still work in progress after rebase
+      assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
+    }
+
+    @Test
+    public void rebaseAsUploaderInAttentionSet() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      TestAccount admin2 = accountCreator.admin2();
+      requestScopeOperations.setApiUser(admin2.id());
+      amendChangeWithUploader(r2, project, admin2);
+      gApi.changes()
+          .id(r2.getChangeId())
+          .addToAttentionSet(new AttentionSetInput(admin2.id().toString(), "manual update"));
+
+      rebaseCall.call(r2.getChangeId());
+    }
+
+    @Test
+    public void rebaseOnChangeNumber() throws Exception {
+      String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
+
+      Change.Id id1 = r1.getChange().getId();
+      RebaseInput in = new RebaseInput();
+      in.base = id1.toString();
+      rebaseCallWithInput.call(r2.getChangeId(), in);
+
+      Change.Id id2 = r2.getChange().getId();
+      ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      ri2 = ci2.revisions.get(ci2.currentRevision);
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+      List<RelatedChangeAndCommitInfo> related =
+          gApi.changes().id(id2.get()).revision(ri2._number).related().changes;
+      assertThat(related).hasSize(2);
+      assertThat(related.get(0)._changeNumber).isEqualTo(id2.get());
+      assertThat(related.get(0)._revisionNumber).isEqualTo(2);
+      assertThat(related.get(1)._changeNumber).isEqualTo(id1.get());
+      assertThat(related.get(1)._revisionNumber).isEqualTo(1);
+    }
+
+    @Test
+    public void rebaseOnClosedChange() throws Exception {
+      String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
+
+      // Submit first change.
+      Change.Id id1 = r1.getChange().getId();
+      gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+      gApi.changes().id(id1.get()).current().submit();
+
+      // Rebase second change on first change.
+      RebaseInput in = new RebaseInput();
+      in.base = id1.toString();
+      rebaseCallWithInput.call(r2.getChangeId(), in);
+
+      Change.Id id2 = r2.getChange().getId();
+      ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      ri2 = ci2.revisions.get(ci2.currentRevision);
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+      assertThat(gApi.changes().id(id2.get()).revision(ri2._number).related().changes).isEmpty();
+    }
+
+    @Test
+    public void rebaseOnNonExistingChange() throws Exception {
+      String changeId = createChange().getChangeId();
+      RebaseInput in = new RebaseInput();
+      in.base = "999999";
+      UnprocessableEntityException exception =
+          assertThrows(
+              UnprocessableEntityException.class, () -> rebaseCallWithInput.call(changeId, in));
+      assertThat(exception).hasMessageThat().contains("Base change not found: " + in.base);
+    }
+
+    @Test
+    public void rebaseNotAllowedWithoutPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      requestScopeOperations.setApiUser(user.id());
+      AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                  + " permission can rebase if they have the 'Push' permission)");
+    }
+
+    @Test
+    public void rebaseAllowedWithPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      requestScopeOperations.setApiUser(user.id());
+      rebaseCall.call(changeId);
+    }
+
+    @Test
+    public void rebaseNotAllowedWithoutPushPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+          .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      requestScopeOperations.setApiUser(user.id());
+      AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                  + " permission can rebase if they have the 'Push' permission)");
+    }
+
+    @Test
+    public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+                  + " permission can rebase if they have the 'Push' permission)");
+    }
+
+    @Test
+    public void rebaseWithValidationOptions() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.validationOptions = ImmutableMap.of("key", "value");
+
+      TestCommitValidationListener testCommitValidationListener =
+          new TestCommitValidationListener();
+      try (ExtensionRegistry.Registration unusedRegistration =
+          extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+        // Rebase the second change
+        rebaseCallWithInput.call(r2.getChangeId(), rebaseInput);
+        assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+            .containsExactly("key", "value");
+      }
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId, Change.Id baseChangeId, boolean shouldHaveApproval)
+        throws RestApiException {
+      verifyRebaseForChange(changeId, baseChangeId, shouldHaveApproval, 2);
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId,
+        Change.Id baseChangeId,
+        boolean shouldHaveApproval,
+        int expectedNumRevisions)
+        throws RestApiException {
+      ChangeInfo baseInfo = gApi.changes().id(baseChangeId.get()).get(CURRENT_REVISION);
+      verifyRebaseForChange(
+          changeId, baseInfo.currentRevision, shouldHaveApproval, expectedNumRevisions);
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId, String baseCommit, boolean shouldHaveApproval, int expectedNumRevisions)
+        throws RestApiException {
+      ChangeInfo info =
+          gApi.changes().id(changeId.get()).get(CURRENT_REVISION, CURRENT_COMMIT, DETAILED_LABELS);
+
+      RevisionInfo r = info.revisions.get(info.currentRevision);
+      assertThat(r._number).isEqualTo(expectedNumRevisions);
+
+      // ...and the base should be correct
+      assertThat(r.commit.parents).hasSize(1);
+      assertWithMessage("base commit for change " + changeId)
+          .that(r.commit.parents.get(0).commit)
+          .isEqualTo(baseCommit);
+
+      // ...and the committer and description should be correct
+      GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+      assertThat(committer.name).isEqualTo(admin.fullName());
+      assertThat(committer.email).isEqualTo(admin.email());
+      String description = info.revisions.get(info.currentRevision).description;
+      assertThat(description).isEqualTo("Rebase");
+
+      if (shouldHaveApproval) {
+        // ...and the approval was copied
+        LabelInfo cr = info.labels.get(LabelId.CODE_REVIEW);
+        assertThat(cr).isNotNull();
+        assertThat(cr.all).isNotNull();
+        assertThat(cr.all).hasSize(1);
+        assertThat(cr.all.get(0).value).isEqualTo(1);
+      }
+    }
+
+    protected void verifyChangeIsUpToDate(PushOneCommit.Result r) {
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r.getChangeId()));
+      assertThat(thrown).hasMessageThat().contains("Change is already up to date");
+    }
+
+    protected static class TestCommitValidationListener implements CommitValidationListener {
+      public CommitReceivedEvent receiveEvent;
+
+      @Override
+      public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+          throws CommitValidationException {
+        this.receiveEvent = receiveEvent;
+        return ImmutableList.of();
+      }
+    }
+
+    protected static class TestWorkInProgressStateChangedListener
+        implements WorkInProgressStateChangedListener {
+      boolean invoked;
+      Boolean wip;
+
+      @Override
+      public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event event) {
+        this.invoked = true;
+        this.wip =
+            event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+      }
+    }
+  }
+
+  public abstract static class Rebase extends Base {
+    @Test
+    public void rebaseChangeBase() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+      RebaseInput ri = new RebaseInput();
+
+      // rebase r3 directly onto master (break dep. towards r2)
+      ri.base = "";
+      rebaseCallWithInput.call(r3.getChangeId(), ri);
+      PatchSet ps3 = r3.getPatchSet();
+      assertThat(ps3.id().get()).isEqualTo(2);
+
+      // rebase r2 onto r3 (referenced by ref)
+      ri.base = ps3.id().toRefName();
+      rebaseCallWithInput.call(r2.getChangeId(), ri);
+      PatchSet ps2 = r2.getPatchSet();
+      assertThat(ps2.id().get()).isEqualTo(2);
+
+      // rebase r1 onto r2 (referenced by commit)
+      ri.base = ps2.commitId().name();
+      rebaseCallWithInput.call(r1.getChangeId(), ri);
+      PatchSet ps1 = r1.getPatchSet();
+      assertThat(ps1.id().get()).isEqualTo(2);
+
+      // rebase r1 onto r3 (referenced by change number)
+      ri.base = String.valueOf(r3.getChange().getId().get());
+      rebaseCallWithInput.call(r1.getChangeId(), ri);
+      assertThat(r1.getPatchSetId().get()).isEqualTo(3);
+    }
+
+    @Test
+    public void rebaseWithConflict_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 (ExtensionRegistry.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
+    public void rebaseWithConflict_conflictsForbidden() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              PushOneCommit.SUBJECT,
+              PushOneCommit.FILE_NAME,
+              "other content",
+              "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+      ResourceConflictException exception =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r2.getChangeId()));
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Change %s could not be rebased due to a conflict during merge.\n\n"
+                      + "merge conflict(s):\n%s",
+                  r2.getChange().getId(), PushOneCommit.FILE_NAME));
+    }
+
+    @Test
+    public void rebaseFromRelationChainToClosedChange() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+
+      createChange();
+      PushOneCommit.Result r3 = createChange();
+
+      // Submit first change.
+      Change.Id id1 = r1.getChange().getId();
+      gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+      gApi.changes().id(id1.get()).current().submit();
+
+      // Rebase third change on first change.
+      RebaseInput in = new RebaseInput();
+      in.base = id1.toString();
+      rebaseCallWithInput.call(r3.getChangeId(), in);
+
+      Change.Id id3 = r3.getChange().getId();
+      ChangeInfo ci3 = get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      RevisionInfo ri3 = ci3.revisions.get(ci3.currentRevision);
+      assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+      assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
+    }
+  }
+
+  public static class RebaseViaRevisionApi extends Rebase {
+    @Before
+    public void setUp() throws Exception {
+      init(
+          id -> gApi.changes().id(id).current().rebase(),
+          (id, in) -> gApi.changes().id(id).current().rebase(in));
+    }
+
+    @Test
+    public void rebaseOutdatedPatchSet() throws Exception {
+      String fileName1 = "a.txt";
+      String fileContent1 = "some content";
+      String fileName2 = "b.txt";
+      String fileContent2Ps1 = "foo";
+      String fileContent2Ps2 = "foo/bar";
+
+      // Create two changes both with the same parent touching disjunct files
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName1, fileContent1)
+              .to("refs/for/master");
+      r.assertOkStatus();
+      String changeId1 = r.getChangeId();
+      testRepo.reset("HEAD~1");
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName2, fileContent2Ps1);
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+      String changeId2 = r2.getChangeId();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(changeId1).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Amend the second change so that it has 2 patch sets
+      amendChange(
+              changeId2,
+              "refs/for/master",
+              admin,
+              testRepo,
+              PushOneCommit.SUBJECT,
+              fileName2,
+              fileContent2Ps2)
+          .assertOkStatus();
+      ChangeInfo changeInfo2 = gApi.changes().id(changeId2).get();
+      assertThat(changeInfo2.revisions.get(changeInfo2.currentRevision)._number).isEqualTo(2);
+
+      // Rebase the first patch set of the second change
+      gApi.changes().id(changeId2).revision(1).rebase();
+
+      // Second change should have 3 patch sets
+      changeInfo2 = gApi.changes().id(changeId2).get();
+      assertThat(changeInfo2.revisions.get(changeInfo2.currentRevision)._number).isEqualTo(3);
+
+      // ... and the committer and description should be correct
+      ChangeInfo info = gApi.changes().id(changeId2).get(CURRENT_REVISION, CURRENT_COMMIT);
+      GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+      assertThat(committer.name).isEqualTo(admin.fullName());
+      assertThat(committer.email).isEqualTo(admin.email());
+      String description = info.revisions.get(info.currentRevision).description;
+      assertThat(description).isEqualTo("Rebase");
+
+      // ... and the file contents should match with patch set 1 based on change1
+      assertThat(gApi.changes().id(changeId2).current().file(fileName1).content().asString())
+          .isEqualTo(fileContent1);
+      assertThat(gApi.changes().id(changeId2).current().file(fileName2).content().asString())
+          .isEqualTo(fileContent2Ps1);
+    }
+  }
+
+  public static class RebaseViaChangeApi extends Rebase {
+    @Before
+    public void setUp() throws Exception {
+      init(id -> gApi.changes().id(id).rebase(), (id, in) -> gApi.changes().id(id).rebase(in));
+    }
+  }
+
+  public static class RebaseChain extends Base {
+    @Before
+    public void setUp() throws Exception {
+      init(
+          id -> {
+            @SuppressWarnings("unused")
+            Object unused = gApi.changes().id(id).rebaseChain();
+          },
+          (id, in) -> {
+            @SuppressWarnings("unused")
+            Object unused = gApi.changes().id(id).rebaseChain(in);
+          });
+    }
+
+    @Override
+    protected void verifyChangeIsUpToDate(PushOneCommit.Result r) {
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r.getChangeId()));
+      assertThat(thrown).hasMessageThat().contains("The whole chain is already up to date.");
+    }
+
+    @Test
+    public void rebaseChain() throws Exception {
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3
+      //       * r4
+      //         *r5
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+      PushOneCommit.Result r4 = createChange();
+      PushOneCommit.Result r5 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Add an approval whose score should be copied on trivial rebase
+      gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+      gApi.changes().id(r3.getChangeId()).current().review(ReviewInput.recommend());
+
+      // Rebase the chain through r4.
+      verifyRebaseChainResponse(
+          gApi.changes().id(r4.getChangeId()).rebaseChain(), false, r2, r3, r4);
+
+      // Only r2, r3 and r4 are rebased.
+      verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), true, 2);
+      verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), true);
+      verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false);
+
+      verifyChangeIsUpToDate(r2);
+      verifyChangeIsUpToDate(r3);
+      verifyChangeIsUpToDate(r4);
+
+      // r5 wasn't rebased.
+      ChangeInfo r5info = gApi.changes().id(r5.getChangeId()).get(CURRENT_REVISION);
+      assertThat(r5info.revisions.get(r5info.currentRevision)._number).isEqualTo(1);
+
+      // Rebasing r5
+      verifyRebaseChainResponse(
+          gApi.changes().id(r5.getChangeId()).rebaseChain(), false, r2, r3, r4, r5);
+
+      verifyRebaseForChange(r5.getChange().getId(), r4.getChange().getId(), false);
+    }
+
+    @Test
+    public void rebasePartlyOutdatedChain() throws Exception {
+      final String file = "modified_file.txt";
+      final String oldContent = "old content";
+      final String newContent = "new content";
+      // Create changes with the following revision hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3/1    r3/2
+      //       * r4
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange("original patch-set", file, oldContent);
+      PushOneCommit.Result r4 = createChange();
+      gApi.changes()
+          .id(r3.getChangeId())
+          .edit()
+          .modifyFile(file, RawInputUtil.create(newContent.getBytes(UTF_8)));
+      gApi.changes().id(r3.getChangeId()).edit().publish();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the chain through r4.
+      rebaseCall.call(r4.getChangeId());
+
+      verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), false, 2);
+      verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), false, 3);
+      verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false);
+
+      assertThat(gApi.changes().id(r3.getChangeId()).current().file(file).content().asString())
+          .isEqualTo(newContent);
+
+      verifyChangeIsUpToDate(r2);
+      verifyChangeIsUpToDate(r3);
+      verifyChangeIsUpToDate(r4);
+    }
+
+    @Test
+    public void rebaseChainWithConflicts_conflictsForbidden() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              PushOneCommit.SUBJECT,
+              PushOneCommit.FILE_NAME,
+              "other content",
+              "I0020020020020020020020020020020020020002");
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+      PushOneCommit.Result r3 = createChange("refs/for/master");
+      r3.assertOkStatus();
+      ResourceConflictException exception =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.changes().id(r3.getChangeId()).rebaseChain());
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Change %s could not be rebased due to a conflict during merge.\n\n"
+                      + "merge conflict(s):\n%s",
+                  r2.getChange().getId(), PushOneCommit.FILE_NAME));
+    }
+
+    @Test
+    public void rebaseChainWithConflicts_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 changeWithConflictId = r2.getChangeId();
+      RevCommit patchSet = r2.getCommit();
+      RevCommit base = r1.getCommit();
+      PushOneCommit.Result r3 = createChange("refs/for/master");
+      r3.assertOkStatus();
+
+      TestWorkInProgressStateChangedListener wipStateChangedListener =
+          new TestWorkInProgressStateChangedListener();
+      try (ExtensionRegistry.Registration registration =
+          extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+        RebaseInput rebaseInput = new RebaseInput();
+        rebaseInput.allowConflicts = true;
+        Response<RebaseChainInfo> res =
+            gApi.changes().id(r3.getChangeId()).rebaseChain(rebaseInput);
+        verifyRebaseChainResponse(res, true, r2, r3);
+        RebaseChainInfo rebaseChainInfo = res.value();
+        ChangeInfo changeWithConflictInfo = rebaseChainInfo.rebasedChanges.get(0);
+        assertThat(changeWithConflictInfo.changeId).isEqualTo(r2.getChangeId());
+        assertThat(changeWithConflictInfo.containsGitConflicts).isTrue();
+        assertThat(changeWithConflictInfo.workInProgress).isTrue();
+        ChangeInfo childChangeInfo = rebaseChainInfo.rebasedChanges.get(1);
+        assertThat(childChangeInfo.changeId).isEqualTo(r3.getChangeId());
+        assertThat(childChangeInfo.containsGitConflicts).isTrue();
+        assertThat(childChangeInfo.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(changeWithConflictId)
+              .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(changeWithConflictId).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(changeWithConflictId).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
+    public void rebaseOntoMidChain() throws Exception {
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3
+      //       * r4
+      PushOneCommit.Result r = createChange();
+      r.assertOkStatus();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      r2.assertOkStatus();
+      PushOneCommit.Result r3 = createChange();
+      r3.assertOkStatus();
+      PushOneCommit.Result r4 = createChange();
+
+      RebaseInput ri = new RebaseInput();
+      ri.base = r3.getCommit().name();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r4.getChangeId(), ri));
+      assertThat(thrown).hasMessageThat().contains("recursion not allowed");
+    }
+
+    private void verifyRebaseChainResponse(
+        Response<RebaseChainInfo> res,
+        boolean shouldHaveConflicts,
+        PushOneCommit.Result... changes) {
+      assertThat(res.statusCode()).isEqualTo(200);
+      RebaseChainInfo info = res.value();
+      assertThat(info.rebasedChanges.stream().map(c -> c._number).collect(Collectors.toList()))
+          .containsExactlyElementsIn(
+              Arrays.stream(changes)
+                  .map(c -> c.getChange().getId().get())
+                  .collect(Collectors.toList()))
+          .inOrder();
+      assertThat(info.containsGitConflicts).isEqualTo(shouldHaveConflicts ? true : null);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index d630296..04bdf15 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -601,6 +601,20 @@
   }
 
   @Test
+  public void getGroupFromMetaId() throws Exception {
+    AccountGroup.UUID uuid = groupOperations.newGroup().create();
+    InternalGroup preUpdateState = groupCache.get(uuid).get();
+    gApi.groups().id(uuid.toString()).description("New description");
+
+    InternalGroup postUpdateState = groupCache.get(uuid).get();
+    assertThat(postUpdateState).isNotEqualTo(preUpdateState);
+    assertThat(groupCache.getFromMetaId(uuid, preUpdateState.getRefState()))
+        .isEqualTo(preUpdateState);
+    assertThat(groupCache.getFromMetaId(uuid, postUpdateState.getRefState()))
+        .isEqualTo(postUpdateState);
+  }
+
+  @Test
   @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
   public void getSystemGroupByConfiguredName() throws Exception {
     GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index a625a70..93f91dd 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -54,7 +54,7 @@
   @Inject private IndexOperations.Project projectIndexOperations;
 
   private static final ImmutableSet<String> FIELDS =
-      ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+      ImmutableSet.of(ProjectField.NAME_SPEC.getName(), ProjectField.REF_STATE.getName());
 
   @Test
   public void indexProject_indexesRefStateOfProjectAndParents() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
index 74bfe0f..9d37497 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -260,6 +261,96 @@
   }
 
   @Test
+  public void migrateBlockingLabel_withBranchAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withMultipleBranchAttributes() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master", "refs/heads/develop"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
+            + "OR branch:\\\"refs/heads/develop\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withRegexBranchAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("^refs/heads/main-.*"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"^refs/heads/main-.*\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withRegexAndNonRegexBranchAttributes() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master", "^refs/heads/main-.*"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
+            + "OR branch:\\\"^refs/heads/main-.*\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
   public void migrationIsIdempotent() throws Exception {
     String oldRefsConfigId;
     try (Repository repo = repoManager.openRepository(project)) {
@@ -381,6 +472,21 @@
     gApi.projects().name(project.get()).label(labelName).create(input);
   }
 
+  private void createLabelWithBranch(
+      String labelName,
+      String function,
+      boolean ignoreSelfApproval,
+      ImmutableList<String> refPatterns)
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = labelName;
+    input.function = function;
+    input.ignoreSelfApproval = ignoreSelfApproval;
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.branches = refPatterns;
+    gApi.projects().name(project.get()).label(labelName).create(input);
+  }
+
   @CanIgnoreReturnValue
   private SubmitRequirementApi createSubmitRequirement(
       String name, String submitExpression, boolean canOverride) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 64e3762..9710bf4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -43,6 +43,7 @@
 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;
@@ -801,7 +802,7 @@
 
   @Test
   @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
+  public void autoRetryWithTrace() throws Exception {
     String changeId = createChange().getChangeId();
     approve(changeId);
 
@@ -811,6 +812,49 @@
       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();
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index eb827c0..3531d1c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -20,6 +20,8 @@
 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.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.entities.RefNames.patchSetRef;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -28,11 +30,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -43,8 +47,10 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -55,6 +61,7 @@
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -71,8 +78,15 @@
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
 import org.apache.http.Header;
 import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -105,28 +119,246 @@
   }
 
   @Test
+  @UseLocalDisk
   public void voteOnBehalfOf() throws Exception {
     allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = user;
     PushOneCommit.Result r = createChange();
     RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
 
+    try (Repository repo = repoManager.openRepository(project)) {
+      String changeMetaRef = changeMetaRef(r.getChange().getId());
+      createRefLogFileIfMissing(repo, changeMetaRef);
+
+      ReviewInput in = ReviewInput.recommend();
+      in.onBehalfOf = impersonatedUser.id().toString();
+      in.message = "Message on behalf of";
+      revision.review(in);
+
+      PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+      assertThat(psa.patchSetId().get()).isEqualTo(1);
+      assertThat(psa.label()).isEqualTo("Code-Review");
+      assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+      assertThat(psa.value()).isEqualTo(1);
+      assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+      ChangeData cd = r.getChange();
+      ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+      assertThat(m.getMessage()).endsWith(in.message);
+      assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+      assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+
+      // The change meta commit is created by the server and has the impersonated user as the
+      // author.
+      // Person idents of users in NoteDb commits are obfuscated due to privacy reasons.
+      RevCommit changeMetaCommit = projectOperations.project(project).getHead(changeMetaRef);
+      assertThat(changeMetaCommit.getCommitterIdent().getEmailAddress())
+          .isEqualTo(serverIdent.get().getEmailAddress());
+      assertThat(changeMetaCommit.getAuthorIdent().getEmailAddress())
+          .isEqualTo(changeNoteUtil.getAccountIdAsEmailAddress(impersonatedUser.id()));
+
+      // The ref log for the change meta ref records the impersonated user.
+      ReflogEntry changeMetaRefLogEntry = repo.getReflogReader(changeMetaRef).getLastEntry();
+      assertThat(changeMetaRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+    }
+  }
+
+  @Test
+  public void overrideImpersonatedVoteWithOtherImpersonatedVote_sameValue() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount realUser2 = admin2;
+    TestAccount impersonatedUser = user;
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    // realUser votes Code-Review+1 on behalf of impersonatedUser
     ReviewInput in = ReviewInput.recommend();
-    in.onBehalfOf = user.id().toString();
+    in.onBehalfOf = impersonatedUser.id().toString();
     in.message = "Message on behalf of";
     revision.review(in);
 
     PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
     assertThat(psa.patchSetId().get()).isEqualTo(1);
     assertThat(psa.label()).isEqualTo("Code-Review");
-    assertThat(psa.accountId()).isEqualTo(user.id());
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
     assertThat(psa.value()).isEqualTo(1);
-    assertThat(psa.realAccountId()).isEqualTo(admin.id());
+    assertThat(psa.realAccountId()).isEqualTo(realUser.id());
 
     ChangeData cd = r.getChange();
     ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
     assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(user.id());
-    assertThat(m.getRealAuthor()).isEqualTo(admin.id());
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+
+    // realUser2 votes Code-Review+1 on behalf of impersonatedUser, this should override the
+    // impersonated Code-Review+1 of realUser with an impersonated Code-Review+1 of realUser2
+    requestScopeOperations.setApiUser(realUser2.id());
+    in = ReviewInput.recommend();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Another message on behalf of";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser2.id());
+
+    cd = r.getChange();
+    m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(realUser2.id());
+  }
+
+  @Test
+  public void overrideImpersonatedVoteWithOtherImpersonatedVote_differentValue() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount realUser2 = admin2;
+    TestAccount impersonatedUser = user;
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    // realUser votes Code-Review+1 on behalf of impersonatedUser
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+
+    // realUser2 votes Code-Review-1 on behalf of impersonatedUser, this should override the
+    // impersonated Code-Review+1 of realUser with an impersonated Code-Review-1 of realUser2
+    requestScopeOperations.setApiUser(realUser2.id());
+    in = ReviewInput.dislike();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Another message on behalf of";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(-1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser2.id());
+
+    cd = r.getChange();
+    m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(realUser2.id());
+  }
+
+  @Test
+  public void overrideImpersonatedVoteWithNonImpersonatedVote_sameValue() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = user;
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    // realUser votes Code-Review+1 on behalf of impersonatedUser
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+
+    // impersonatedUser votes Code-Review+1 themselves, this should override the impersonated
+    // Code-Review+1 with a non-impersonated Code-Review+1
+    requestScopeOperations.setApiUser(impersonatedUser.id());
+    in = ReviewInput.recommend();
+    in.message = "Message";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(impersonatedUser.id());
+
+    cd = r.getChange();
+    m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(impersonatedUser.id());
+  }
+
+  @Test
+  public void overrideImpersonatedVoteWithNonImpersonatedVote_differentValue() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = user;
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    // realUser votes Code-Review+1 on behalf of impersonatedUser
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+
+    // impersonatedUser votes Code-Review-1 themselves, this should override the impersonated
+    // Code-Review+1 with a non-impersonated Code-Review-1
+    requestScopeOperations.setApiUser(impersonatedUser.id());
+    in = ReviewInput.dislike();
+    in.message = "Message";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(-1);
+    assertThat(psa.realAccountId()).isEqualTo(impersonatedUser.id());
+
+    cd = r.getChange();
+    m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(impersonatedUser.id());
   }
 
   @Test
@@ -342,21 +574,120 @@
   }
 
   @Test
-  public void submitOnBehalfOf() throws Exception {
-    allowSubmitOnBehalfOf();
-    PushOneCommit.Result r = createChange();
+  @UseLocalDisk
+  public void submitOnBehalfOf_mergeAlways() throws Exception {
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = admin2;
+
+    // Create a project with MERGE_ALWAYS submit strategy so that a merge commit is created on
+    // submit and we can verify its committer and author and the ref log for the update of the
+    // target branch.
+    Project.NameKey project =
+        projectOperations.newProject().submitType(SubmitType.MERGE_ALWAYS).create();
+
+    testSubmitOnBehalfOf(project, realUser, impersonatedUser);
+
+    // The merge commit is created by the server and has the impersonated user as the author.
+    RevCommit mergeCommit = projectOperations.project(project).getHead("refs/heads/master");
+    assertThat(mergeCommit.getCommitterIdent().getEmailAddress())
+        .isEqualTo(serverIdent.get().getEmailAddress());
+    assertThat(mergeCommit.getAuthorIdent().getEmailAddress()).isEqualTo(impersonatedUser.email());
+
+    // The ref log for the target branch records the impersonated user.
+    try (Repository repo = repoManager.openRepository(project)) {
+      ReflogEntry targetBranchRefLogEntry =
+          repo.getReflogReader("refs/heads/master").getLastEntry();
+      assertThat(targetBranchRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+    }
+  }
+
+  @Test
+  @UseLocalDisk
+  public void submitOnBehalfOf_rebaseAlways() throws Exception {
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = admin2;
+
+    // Create a project with REBASE_ALWAYS submit strategy so that a new patch set is created on
+    // submit and we can verify its committer and author and the ref log for the update of the
+    // patch set ref and the target branch.
+    Project.NameKey project =
+        projectOperations.newProject().submitType(SubmitType.REBASE_ALWAYS).create();
+
+    ChangeData cd = testSubmitOnBehalfOf(project, realUser, impersonatedUser);
+
+    // Rebase on submit is expected to create a new patch set.
+    assertThat(cd.currentPatchSet().id().get()).isEqualTo(2);
+
+    // The patch set commit is created by the impersonated user and has the real user as the author.
+    // Recording the real user as the author seems to a bug, we would expect the author to be the
+    // impersonated user.
+    RevCommit newPatchSetCommit =
+        projectOperations.project(project).getHead(cd.currentPatchSet().refName());
+    assertThat(newPatchSetCommit.getCommitterIdent().getEmailAddress())
+        .isEqualTo(impersonatedUser.email());
+    assertThat(newPatchSetCommit.getAuthorIdent().getEmailAddress()).isEqualTo(realUser.email());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // The ref log for the patch set ref records the impersonated user.
+      ReflogEntry patchSetRefLogEntry =
+          repo.getReflogReader(cd.currentPatchSet().refName()).getLastEntry();
+      assertThat(patchSetRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+
+      // The ref log for the target branch records the impersonated user.
+      ReflogEntry targetBranchRefLogEntry =
+          repo.getReflogReader("refs/heads/master").getLastEntry();
+      assertThat(targetBranchRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+    }
+  }
+
+  @CanIgnoreReturnValue
+  private ChangeData testSubmitOnBehalfOf(
+      Project.NameKey project, TestAccount realUser, TestAccount impersonatedUser)
+      throws Exception {
+    allowSubmitOnBehalfOf(project);
+
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, realUser);
+
+    PushOneCommit.Result r = createChange(testRepo);
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email();
-    gApi.changes().id(changeId).current().submit(in);
+    in.onBehalfOf = impersonatedUser.email();
 
-    ChangeData cd = r.getChange();
-    assertThat(cd.change().isMerged()).isTrue();
-    PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(cd.notes(), cd.change().currentPatchSetId());
-    assertThat(submitter.accountId()).isEqualTo(admin2.id());
-    assertThat(submitter.realAccountId()).isEqualTo(admin.id());
+    try (Repository repo = repoManager.openRepository(project)) {
+      String changeMetaRef = changeMetaRef(r.getChange().getId());
+      createRefLogFileIfMissing(repo, changeMetaRef);
+      createRefLogFileIfMissing(repo, "refs/heads/master");
+      createRefLogFileIfMissing(repo, patchSetRef(PatchSet.id(r.getChange().getId(), 2)));
+
+      gApi.changes().id(changeId).current().submit(in);
+
+      ChangeData cd = r.getChange();
+      assertThat(cd.change().isMerged()).isTrue();
+      PatchSetApproval submitter =
+          approvalsUtil.getSubmitter(cd.notes(), cd.change().currentPatchSetId());
+      assertThat(submitter.accountId()).isEqualTo(impersonatedUser.id());
+      assertThat(submitter.realAccountId()).isEqualTo(realUser.id());
+
+      // The change meta commit is created by the server and has the impersonated user as the
+      // author.
+      // Person idents of users in NoteDb commits are obfuscated due to privacy reasons.
+      RevCommit changeMetaCommit = projectOperations.project(project).getHead(changeMetaRef);
+      assertThat(changeMetaCommit.getCommitterIdent().getEmailAddress())
+          .isEqualTo(serverIdent.get().getEmailAddress());
+      assertThat(changeMetaCommit.getAuthorIdent().getEmailAddress())
+          .isEqualTo(changeNoteUtil.getAccountIdAsEmailAddress(impersonatedUser.id()));
+
+      // The ref log for the change meta ref records the impersonated user.
+      ReflogEntry changeMetaRefLogEntry = repo.getReflogReader(changeMetaRef).getLastEntry();
+      assertThat(changeMetaRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+
+      return cd;
+    }
   }
 
   @Test
@@ -591,6 +922,10 @@
   }
 
   private void allowSubmitOnBehalfOf() throws Exception {
+    allowSubmitOnBehalfOf(project);
+  }
+
+  private void allowSubmitOnBehalfOf(Project.NameKey project) throws Exception {
     String heads = "refs/heads/*";
     projectOperations
         .project(project)
@@ -630,4 +965,12 @@
   private static Header runAsHeader(Object user) {
     return new BasicHeader("X-Gerrit-RunAs", user.toString());
   }
+
+  private void createRefLogFileIfMissing(Repository repo, String ref) throws IOException {
+    File log = new File(repo.getDirectory(), "logs/" + ref);
+    if (!log.exists()) {
+      log.getParentFile().mkdirs();
+      assertThat(log.createNewFile()).isTrue();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 81c098f..aeebc10 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -234,11 +234,11 @@
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
         change2.getChangeId(),
-        "Cannot rebase "
-            + change2.getCommit().name()
-            + ": The change could not be rebased due to a conflict during merge.\n\n"
-            + "merge conflict(s):\n"
-            + "a.txt");
+        String.format(
+            "Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.\n\n"
+                + "merge conflict(s):\n"
+                + "a.txt",
+            change2.getCommit().name(), change2.getChange().getId()));
     RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
@@ -362,12 +362,11 @@
 
     submitWithConflict(
         change2.getChangeId(),
-        "Cannot rebase "
-            + change2.getCommit().getName()
-            + ": "
-            + "The change could not be rebased due to a conflict during merge.\n\n"
-            + "merge conflict(s):\n"
-            + "fileName 2");
+        String.format(
+            "Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.\n\n"
+                + "merge conflict(s):\n"
+                + "fileName 2",
+            change2.getCommit().name(), change2.getChange().getId()));
     assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterChange1);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index c57d285..016b1e6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -15,16 +15,24 @@
 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.allowLabelRemoval;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabelRemoval;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableSet;
 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.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -39,56 +47,141 @@
 import org.junit.Test;
 
 public class DeleteVoteIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
-  public void deleteVoteOnChange() throws Exception {
-    deleteVote(false);
+  public void deleteVoteOnChange_withRemoveLabelPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyDeleteVote(false);
   }
 
   @Test
-  public void deleteVoteOnRevision() throws Exception {
-    deleteVote(true);
+  public void deleteVoteOnChange_withRemoveReviewerPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyDeleteVote(false);
   }
 
-  private void deleteVote(boolean onRevisionLevel) throws Exception {
+  @Test
+  public void deleteVoteOnChange_noPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyCannotDeleteVote(false);
+  }
+
+  @Test
+  public void deleteVoteOnRevision_withRemoveLabelPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyDeleteVote(true);
+  }
+
+  @Test
+  public void deleteVoteOnRevision_withRemoveReviewerPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyDeleteVote(true);
+  }
+
+  @Test
+  public void deleteVoteOnRevision_noPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyCannotDeleteVote(true);
+  }
+
+  private void verifyDeleteVote(boolean onRevisionLevel) throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     PushOneCommit.Result r2 = amendChange(r.getChangeId());
 
-    requestScopeOperations.setApiUser(user.id());
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(r.getChangeId());
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
     recommend(r.getChangeId());
 
     sender.clear();
-    String endPoint =
+    String deleteAdminVoteEndPoint =
         "/changes/"
             + r.getChangeId()
             + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
             + "/reviewers/"
-            + user.id().toString()
+            + admin.id().toString()
             + "/votes/Code-Review";
 
-    RestResponse response = adminRestSession.delete(endPoint);
+    RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
     response.assertNoContent();
 
     List<FakeEmailSender.Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     FakeEmailSender.Message msg = messages.get(0);
-    assertThat(msg.rcpt()).containsExactly(user.getNameEmail());
-    assertThat(msg.body()).contains(admin.fullName() + " has removed a vote from this change.");
+    assertThat(msg.rcpt()).containsExactly(admin.getNameEmail(), user2.getNameEmail());
+    assertThat(msg.body()).contains(user.fullName() + " has removed a vote from this change.");
     assertThat(msg.body())
-        .contains("Removed Code-Review+1 by " + user.fullName() + " <" + user.email() + ">\n");
+        .contains("Removed Code-Review+1 by " + admin.fullName() + " <" + admin.email() + ">\n");
 
-    endPoint =
+    String viewVotesEndPoint =
         "/changes/"
             + r.getChangeId()
             + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
             + "/reviewers/"
-            + user.id().toString()
+            + admin.id().toString()
             + "/votes";
 
-    response = adminRestSession.get(endPoint);
+    response = userRestSession.get(viewVotesEndPoint);
     response.assertOK();
 
     Map<String, Short> m =
@@ -99,14 +192,38 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
     ChangeMessageInfo message = Iterables.getLast(c.messages);
-    assertThat(message.author._accountId).isEqualTo(admin.id().get());
+    assertThat(message.author._accountId).isEqualTo(user.id().get());
     assertThat(message.message)
         .isEqualTo(
             String.format(
                 "Removed Code-Review+1 by %s\n",
-                AccountTemplateUtil.getAccountTemplate(user.id())));
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
+        .containsExactlyElementsIn(ImmutableSet.of(user2.id(), admin.id()));
+  }
+
+  private void verifyCannotDeleteVote(boolean onRevisionLevel) throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    PushOneCommit.Result r2 = amendChange(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(r.getChangeId());
+
+    sender.clear();
+    String deleteAdminVoteEndPoint =
+        "/changes/"
+            + r.getChangeId()
+            + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
+            + "/reviewers/"
+            + admin.id().toString()
+            + "/votes/Code-Review";
+
+    RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
+    response.assertForbidden();
+
+    assertThat(sender.getMessages()).isEmpty();
   }
 
   private Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
index 0c221aa..7b42d93 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.entities.RefNames.REFS_HEADS;
 
@@ -21,6 +22,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.IdString;
 import org.junit.Test;
 
 public class CreateChangeIT extends AbstractDaemonTest {
@@ -43,7 +45,44 @@
     ChangeInput input = new ChangeInput();
     input.branch = "foo";
     input.subject = "subject";
-    RestResponse cr = adminRestSession.post("/projects/" + project.get() + "/create.change", input);
-    cr.assertCreated();
+    RestResponse response =
+        adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+    response.assertCreated();
+  }
+
+  @Test
+  public void nonMatchingProjectIsRejected() throws Exception {
+    ChangeInput input = new ChangeInput();
+    input.project = "non-matching-project";
+    input.branch = "master";
+    input.subject = "subject";
+    RestResponse response =
+        adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+    response.assertBadRequest();
+    assertThat(response.getEntityContent()).isEqualTo("project must match URL");
+  }
+
+  @Test
+  public void matchingProjectIsAccepted() throws Exception {
+    ChangeInput input = new ChangeInput();
+    input.project = project.get();
+    input.branch = "master";
+    input.subject = "subject";
+    RestResponse response =
+        adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+    response.assertCreated();
+  }
+
+  @Test
+  public void matchingProjectWithTrailingSlashIsAccepted() throws Exception {
+    ChangeInput input = new ChangeInput();
+    input.project = project.get() + "/";
+    input.branch = "master";
+    input.subject = "subject";
+    RestResponse response =
+        adminRestSession.post(
+            "/projects/" + IdString.fromDecoded(project.get() + "/").encoded() + "/create.change",
+            input);
+    response.assertCreated();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 2123ac2..15baa78 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1262,7 +1262,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(0).id
-                + " \n"
+                + " :\n"
                 + "PS1, Line 1: initial\n"
                 + "what happened to this?\n"
                 + "\n"
@@ -1274,7 +1274,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(1).id
-                + " \n"
+                + " :\n"
                 + "PS1, Line 1: boring\n"
                 + "Is it that bad?\n"
                 + "\n"
@@ -1288,7 +1288,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(0).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 1: initial content\n"
                 + "comment 1 on base\n"
                 + "\n"
@@ -1300,7 +1300,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(1).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 2: \n"
                 + "comment 2 on base\n"
                 + "\n"
@@ -1312,7 +1312,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(2).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 1: interesting\n"
                 + "better now\n"
                 + "\n"
@@ -1324,7 +1324,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(3).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
                 + "\n"
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
index b2a0ded..e011ffc 100644
--- a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -68,10 +68,7 @@
       values = {"UiFeature__patchset_comments", "UiFeature__submit_requirements_ui"})
   public void configOverride_defaultFeatureDisabled() {
     assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
-    assertThat(
-            experimentFeatures.isFeatureEnabled(
-                ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS))
-        .isFalse();
+    assertThat(experimentFeatures.isFeatureEnabled("UiFeature__patchset_comments")).isFalse();
     assertThat(experimentFeatures.getEnabledExperimentFeatures()).containsExactly("enabledFeature");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 1900158..cf1eee0 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -22,6 +22,7 @@
 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.AccountGroup;
@@ -348,6 +349,163 @@
   }
 
   @Test
+  public void noNotificationForWatchKeywordWhenKeywordMatchesChangeOwner() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // watch keyword in project as user
+    watch(watchedProject, admin.email());
+
+    // push a change with owner=keyword -> should not trigger email notification
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForWatchKeywordWhenKeywordMatchesChangeReviewer() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // watch keyword in project as user
+    watch(watchedProject, user2.email());
+
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+    sender.clear();
+
+    // Add reviewer=keyword -> should trigger email notification only to new reviewer
+    gApi.changes().id(r.getChangeId()).addReviewer(user2.email());
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertNotifyTo(user2);
+    assertThat(m.body()).contains("Change subject: subject\n");
+  }
+
+  @Test
+  public void watchOwner() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // watch keyword in project as user
+    watch(watchedProject, "owner:admin");
+
+    // push a change with keyword -> should trigger email notification
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+    assertThat(m.body()).contains("Change subject: subject\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void watchNonVisibleOwner() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // watch keyword in project as user
+    watch(watchedProject, "owner:admin");
+
+    // Verify that 'user' can't see 'admin'
+    assertThatAccountIsNotVisible(admin);
+
+    // push a change with keyword -> should trigger email notification
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert no email notifications for user
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchChangesCommentedBySelf() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // user watches all changes that have a comment by themselves
+    watch(watchedProject, "commentby:self");
+
+    // pushing a change as admin should not trigger an email to user
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(sender.getMessages()).isEmpty();
+
+    // commenting by admin should not trigger an email to user
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.message = "A Comment";
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    assertThat(sender.getMessages()).isEmpty();
+
+    // commenting by user matches the project watch, but doesn't send an email to user because
+    // CC_ON_OWN_COMMENTS is false by default, so the user is removed from the TO list, but an email
+    // is sent to the admin user
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(admin.getNameEmail());
+    assertThat(m.body()).contains("Change subject: subject\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // commenting by admin now triggers an email to user because the change has a comment by user
+    // and hence matches the project watch
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+    assertThat(m.body()).contains("Change subject: subject\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+  }
+
+  @Test
   public void watchAllProjects() throws Exception {
     String anyProject = projectOperations.newProject().create().get();
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index c1a7627..661802e 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -18,11 +18,14 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabelRemoval;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabelRemoval;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelRemovalPermissionKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
@@ -443,6 +446,73 @@
   }
 
   @Test
+  public void addAllowLabelRemovalPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access", "submit");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("removeLabel-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addBlockLabelRemovalPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(blockLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access", "submit");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("removeLabel-Code-Review", "block -1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowExclusiveLabelRemovalPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .setExclusiveGroup(labelRemovalPermissionKey("Code-Review").ref("refs/foo"), true)
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access", "submit");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "removeLabel-Code-Review", "-1..+2 group global:Registered-Users",
+            "exclusiveGroupPermissions", "removeLabel-Code-Review");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(labelRemovalPermissionKey("Code-Review").ref("refs/foo"), false)
+        .update();
+
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access", "submit");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("removeLabel-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
   public void addAllowCapability() throws Exception {
     Config config = projectOperations.project(allProjects).getConfig();
     assertThat(config)
@@ -540,6 +610,31 @@
   }
 
   @Test
+  public void removeLabelRemovalPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(PROJECT_OWNERS).range(-2, 1))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "removeLabel-Code-Review", "-1..+2 group global:Registered-Users",
+            "removeLabel-Code-Review", "-2..+1 group global:Project-Owners");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .remove(labelRemovalPermissionKey("Code-Review").ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("removeLabel-Code-Review", "-2..+1 group global:Project-Owners");
+  }
+
+  @Test
   public void removeCapability() throws Exception {
     projectOperations
         .allProjectsForUpdate()
diff --git a/javatests/com/google/gerrit/entities/PermissionTest.java b/javatests/com/google/gerrit/entities/PermissionTest.java
index 3175671..d25d833 100644
--- a/javatests/com/google/gerrit/entities/PermissionTest.java
+++ b/javatests/com/google/gerrit/entities/PermissionTest.java
@@ -36,6 +36,7 @@
 
     assertThat(Permission.isPermission(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
     assertThat(Permission.isPermission(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isPermission(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isTrue();
     assertThat(Permission.isPermission(LabelId.CODE_REVIEW)).isFalse();
   }
 
@@ -56,6 +57,7 @@
 
     assertThat(Permission.isLabel(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
     assertThat(Permission.isLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isLabel(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isFalse();
     assertThat(Permission.isLabel(LabelId.CODE_REVIEW)).isFalse();
   }
 
@@ -66,10 +68,22 @@
 
     assertThat(Permission.isLabelAs(Permission.LABEL + LabelId.CODE_REVIEW)).isFalse();
     assertThat(Permission.isLabelAs(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isLabelAs(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isFalse();
     assertThat(Permission.isLabelAs(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
+  public void isRemoveLabel() {
+    assertThat(Permission.isRemoveLabel(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isRemoveLabel("no-permission")).isFalse();
+
+    assertThat(Permission.isRemoveLabel(Permission.LABEL + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isRemoveLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isRemoveLabel(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isRemoveLabel(LabelId.CODE_REVIEW)).isFalse();
+  }
+
+  @Test
   public void forLabel() {
     assertThat(Permission.forLabel(LabelId.CODE_REVIEW))
         .isEqualTo(Permission.LABEL + LabelId.CODE_REVIEW);
@@ -82,11 +96,19 @@
   }
 
   @Test
+  public void forRemoveLabel() {
+    assertThat(Permission.forRemoveLabel(LabelId.CODE_REVIEW))
+        .isEqualTo(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW);
+  }
+
+  @Test
   public void extractLabel() {
     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(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW))
+        .isEqualTo(LabelId.CODE_REVIEW);
     assertThat(Permission.extractLabel(LabelId.CODE_REVIEW)).isNull();
     assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
   }
@@ -103,6 +125,10 @@
             Permission.canBeOnAllProjects(
                 AccessSection.ALL, Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isTrue();
+    assertThat(
+            Permission.canBeOnAllProjects(
+                AccessSection.ALL, Permission.REMOVE_LABEL + LabelId.CODE_REVIEW))
+        .isTrue();
 
     assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
     assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
@@ -113,6 +139,10 @@
             Permission.canBeOnAllProjects(
                 "refs/heads/*", Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isTrue();
+    assertThat(
+            Permission.canBeOnAllProjects(
+                "refs/heads/*", Permission.REMOVE_LABEL + LabelId.CODE_REVIEW))
+        .isTrue();
   }
 
   @Test
@@ -126,6 +156,8 @@
         .isEqualTo(LabelId.CODE_REVIEW);
     assertThat(Permission.create(Permission.LABEL_AS + LabelId.CODE_REVIEW).getLabel())
         .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.create(Permission.REMOVE_LABEL + 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/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
index 7ed236a..f45d33b 100644
--- a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -48,6 +48,7 @@
     assertThat(diff.added().messages).isNull();
     assertThat(diff.added().reviewers).isNull();
     assertThat(diff.added().hashtags).isNull();
+    assertThat(diff.added().removableLabels).isNull();
     assertThat(diff.removed()._number).isNull();
     assertThat(diff.removed().branch).isNull();
     assertThat(diff.removed().project).isNull();
@@ -56,6 +57,7 @@
     assertThat(diff.removed().messages).isNull();
     assertThat(diff.removed().reviewers).isNull();
     assertThat(diff.removed().hashtags).isNull();
+    assertThat(diff.removed().removableLabels).isNull();
   }
 
   @Test
@@ -315,6 +317,295 @@
   }
 
   @Test
+  public void getDiff_removableLabelsEmpty_returnsNullRemovableLabels() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    oldChangeInfo.removableLabels = ImmutableMap.of();
+    newChangeInfo.removableLabels = ImmutableMap.of();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isNull();
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsNullAndEmpty_returnsEmptyRemovableLabels() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    newChangeInfo.removableLabels = ImmutableMap.of();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isEmpty();
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsEmptyAndNull_returnsEmptyRemovableLabels() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    oldChangeInfo.removableLabels = ImmutableMap.of();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isNull();
+    assertThat(diff.removed().removableLabels).isEmpty();
+  }
+
+  @Test
+  public void getDiff_removableLabelsLabelAdded() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "Cow";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "Pig";
+    AccountInfo acc3 = new AccountInfo();
+    acc3.name = "Cat";
+    AccountInfo acc4 = new AccountInfo();
+    acc4.name = "Dog";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)),
+            "Verified",
+            ImmutableMap.of("-1", ImmutableList.of(acc4)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc4))));
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsLabelRemoved() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "Cow";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "Pig";
+    AccountInfo acc3 = new AccountInfo();
+    acc3.name = "Cat";
+    AccountInfo acc4 = new AccountInfo();
+    acc4.name = "Dog";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)),
+            "Verified",
+            ImmutableMap.of("-1", ImmutableList.of(acc4)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isNull();
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc4))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsVoteAdded() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+    AccountInfo acc3 = new AccountInfo();
+    acc3.name = "acc3";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc2, acc3))));
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsVoteRemoved() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+    AccountInfo acc3 = new AccountInfo();
+    acc3.name = "acc3";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isNull();
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc2, acc3))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsAccountAdded() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1, acc2)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2))));
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsAccountRemoved() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1, acc2)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2))));
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsAccountChanged() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2))));
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsScoreChanged() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc1)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc1))));
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsLabelChanged() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Verified", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Verified", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsLabelScoreAndAccountChanged() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc2)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc2))));
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+  }
+
+  @Test
   public void getDiff_assertCanConstructAllChangeInfoReferences() throws Exception {
     buildObjectWithFullFields(ChangeInfo.class);
   }
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index f65e823..06ea8b6 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -60,15 +60,13 @@
     String testCdnPath = "bar-cdn";
     String testFaviconURL = "zaz-url";
 
-    // Pick any known experiment enabled by default;
-    String disabledDefault = ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS;
-    assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).contains(disabledDefault);
+    assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).isEmpty();
 
     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));
+        "experiments", null, "disabled", ImmutableList.of("DisabledFeature"));
     ExperimentFeatures experimentFeatures = new ConfigExperimentFeatures(serverConfig);
     IndexServlet servlet =
         new IndexServlet(
@@ -97,7 +95,6 @@
                 + "\\x5b\\x5d\\x7d');");
     ImmutableSet<String> enabledDefaults =
         ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.stream()
-            .filter(e -> !e.equals(disabledDefault))
             .collect(ImmutableSet.toImmutableSet());
     List<String> expectedEnabled = new ArrayList<>();
     expectedEnabled.add("NewFeature");
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java b/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
index 7f98a9d..affaadf 100644
--- a/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
+++ b/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
@@ -37,17 +37,17 @@
     IndexUpgradeValidator.assertValid(
         schema(
             1,
-            ImmutableList.of(ChangeField.CHANGE_ID_FIELD),
-            ImmutableList.of(ChangeField.CHANGE_ID_SPEC)),
+            ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)),
         schema(
             2,
-            ImmutableList.of(ChangeField.CHANGE_ID_FIELD),
-            ImmutableList.of(ChangeField.CHANGE_ID_SPEC)));
+            ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)));
     IndexUpgradeValidator.assertValid(
         schema(
             1,
-            ImmutableList.of(ChangeField.CHANGE_ID_FIELD),
-            ImmutableList.of(ChangeField.CHANGE_ID_SPEC)),
+            ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)),
         schema(
             2,
             ImmutableList.<FieldDef<ChangeData, ?>>of(),
@@ -58,8 +58,8 @@
     IndexUpgradeValidator.assertValid(
         schema(
             1,
-            ImmutableList.of(ChangeField.CHANGE_ID_FIELD),
-            ImmutableList.of(ChangeField.CHANGE_ID_SPEC)),
+            ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)),
         schema(
             2,
             ImmutableList.<IndexedField<ChangeData, ?>>of(
@@ -81,8 +81,9 @@
                 IndexUpgradeValidator.assertValid(
                     schema(
                         1,
-                        ImmutableList.of(ChangeField.CHANGE_ID_FIELD),
-                        ImmutableList.of(ChangeField.CHANGE_ID_SPEC)),
+                        ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+                        ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+                            ChangeField.CHANGE_ID_SPEC)),
                     schema(
                         2,
                         ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.OWNER_FIELD),
@@ -105,9 +106,13 @@
                 IndexUpgradeValidator.assertValid(
                     schema(
                         1,
-                        ImmutableList.of(ChangeField.CHANGE_ID_FIELD),
-                        ImmutableList.of(ChangeField.CHANGE_ID_SPEC)),
-                    schema(2, ImmutableList.of(ID_MODIFIED), ImmutableList.of())));
+                        ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+                        ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+                            ChangeField.CHANGE_ID_SPEC)),
+                    schema(
+                        2,
+                        ImmutableList.<IndexedField<ChangeData, ?>>of(ID_MODIFIED),
+                        ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of())));
     assertThat(e).hasMessageThat().contains("Fields may not be modified");
     assertThat(e).hasMessageThat().contains(ChangeField.CHANGE_ID_FIELD.name());
   }
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
index 3658834..37728f7 100644
--- a/javatests/com/google/gerrit/server/account/AccountResolverTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -23,6 +23,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResolver.Result;
 import com.google.gerrit.server.account.AccountResolver.Searcher;
 import com.google.gerrit.server.account.AccountResolver.StringSearcher;
@@ -35,6 +37,8 @@
 import org.junit.Test;
 
 public class AccountResolverTest {
+  private final CurrentUser user = new AnonymousUser();
+
   private static class TestSearcher extends StringSearcher {
     private final String pattern;
     private final boolean shortCircuit;
@@ -282,7 +286,7 @@
     AccountResolver resolver = newAccountResolver();
     assertThat(
             new UnresolvableAccountException(
-                resolver.new Result("foo", ImmutableList.of(), ImmutableList.of())))
+                resolver.new Result("foo", ImmutableList.of(), ImmutableList.of(), user)))
         .hasMessageThat()
         .isEqualTo("Account 'foo' not found");
   }
@@ -292,7 +296,7 @@
     AccountResolver resolver = newAccountResolver();
     UnresolvableAccountException e =
         new UnresolvableAccountException(
-            resolver.new Result("self", ImmutableList.of(), ImmutableList.of()));
+            resolver.new Result("self", ImmutableList.of(), ImmutableList.of(), user));
     assertThat(e.isSelf()).isTrue();
     assertThat(e).hasMessageThat().isEqualTo("Resolving account 'self' requires login");
   }
@@ -302,7 +306,7 @@
     AccountResolver resolver = newAccountResolver();
     UnresolvableAccountException e =
         new UnresolvableAccountException(
-            resolver.new Result("me", ImmutableList.of(), ImmutableList.of()));
+            resolver.new Result("me", ImmutableList.of(), ImmutableList.of(), user));
     assertThat(e.isSelf()).isTrue();
     assertThat(e).hasMessageThat().isEqualTo("Resolving account 'me' requires login");
   }
@@ -314,7 +318,10 @@
             new UnresolvableAccountException(
                 resolver
                 .new Result(
-                    "foo", ImmutableList.of(newAccount(3), newAccount(1)), ImmutableList.of())))
+                    "foo",
+                    ImmutableList.of(newAccount(3), newAccount(1)),
+                    ImmutableList.of(),
+                    user)))
         .hasMessageThat()
         .isEqualTo(
             "Account 'foo' is ambiguous (at most 3 shown):\n1: Anonymous Name (1)\n3: Anonymous Name (3)");
@@ -329,7 +336,8 @@
                 .new Result(
                     "foo",
                     ImmutableList.of(),
-                    ImmutableList.of(newInactiveAccount(3), newInactiveAccount(1)))))
+                    ImmutableList.of(newInactiveAccount(3), newInactiveAccount(1)),
+                    user)))
         .hasMessageThat()
         .isEqualTo(
             "Account 'foo' only matches inactive accounts. To use an inactive account, retry"
@@ -352,10 +360,11 @@
       Supplier<Predicate<AccountState>> visibilitySupplier,
       Predicate<AccountState> activityPredicate)
       throws Exception {
-    return newAccountResolver().searchImpl(input, searchers, visibilitySupplier, activityPredicate);
+    return newAccountResolver()
+        .searchImpl(input, searchers, user, visibilitySupplier, activityPredicate);
   }
 
-  private static AccountResolver newAccountResolver() {
+  private AccountResolver newAccountResolver() {
     return new AccountResolver(null, null, null, null, null, null, null, null, "Anonymous Name");
   }
 
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index 65eb3b8..a40afe8 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -40,8 +40,7 @@
     String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
     account.setMetaId(metaId);
     Iterable<byte[]> refStates =
-        (Iterable<byte[]>)
-            AccountField.REF_STATE_SPEC.get(AccountState.forAccount(account.build()));
+        AccountField.REF_STATE_SPEC.get(AccountState.forAccount(account.build()));
     List<String> values = toStrings(refStates);
     String expectedValue =
         allUsersName.get() + ":" + RefNames.refsUsers(account.id()) + ":" + metaId;
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index 95e8aa5..15adcf8 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -98,6 +98,11 @@
   }
 
   @Override
+  public void deleteByValue(ChangeData value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
   public void delete(Change.Id id) {
     throw new UnsupportedOperationException();
   }
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 37d8468..be8f1f9 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -76,6 +76,7 @@
 import com.google.inject.TypeLiteral;
 import java.time.Instant;
 import java.time.ZoneId;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -276,7 +277,7 @@
 
   protected ChangeUpdate newUpdate(
       Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
-    ChangeUpdate update = TestChanges.newUpdate(injector, c, user, shouldExist);
+    ChangeUpdate update = TestChanges.newUpdate(injector, c, Optional.of(user), shouldExist);
     update.setPatchSetId(c.currentPatchSetId());
     update.setAllowWriteToNewRef(true);
     return update;
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 61b5e55..9cd002e 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -825,7 +825,8 @@
         ImmutableList.of(", ", ":\"", ",", "!@#$%^\0&*):\" \n: \r\"#$@,. :");
     for (String strangeTag : strangeTags) {
       Change c = newChange();
-      CurrentUser otherUserAsOwner = userFactory.runAs(null, changeOwner.getAccountId(), otherUser);
+      CurrentUser otherUserAsOwner =
+          userFactory.runAs(/* remotePeer= */ null, changeOwner.getAccountId(), otherUser);
       ChangeUpdate update = newUpdate(c, otherUserAsOwner);
       update.putApproval(LabelId.CODE_REVIEW, (short) 2);
       update.setTag(strangeTag);
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index b53de89..25f2f98 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -354,7 +354,8 @@
   @Test
   public void realUser() throws Exception {
     Change c = newChange();
-    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
+    CurrentUser ownerAsOtherUser =
+        userFactory.runAs(/* remotePeer= */ null, otherUserId, changeOwner);
     ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
     update.setChangeMessage("Message on behalf of other user");
     update.commit();
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 5e6803e..527e78e 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -399,7 +399,9 @@
 
     IdentifiedUser impersonatedChangeOwner =
         this.userFactory.runAs(
-            null, changeOwner.getAccountId(), requireNonNull(otherUser).getRealUser());
+            /* remotePeer= */ null,
+            changeOwner.getAccountId(),
+            requireNonNull(otherUser).getRealUser());
     ChangeUpdate impersonatedChangeMessageUpdate = newUpdate(c, impersonatedChangeOwner);
     impersonatedChangeMessageUpdate.setChangeMessage("Other comment on behalf of");
     impersonatedChangeMessageUpdate.commit();
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 23f33fc..fbf9c87 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -46,6 +46,7 @@
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.FakeSubmitRule;
@@ -109,12 +110,14 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.account.VersionedAccountQueries;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -122,6 +125,7 @@
 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.ChangeUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -133,8 +137,7 @@
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.GerritServerTests;
-import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testing.TestChanges;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -158,7 +161,7 @@
 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.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.SystemReader;
@@ -183,7 +186,7 @@
   @Inject protected ChangeIndexer indexer;
   @Inject protected ExtensionRegistry extensionRegistry;
   @Inject protected IndexConfig indexConfig;
-  @Inject protected InMemoryRepositoryManager repoManager;
+  @Inject protected GitRepositoryManager repoManager;
   @Inject protected Provider<AnonymousUser> anonymousUserProvider;
   @Inject protected Provider<InternalChangeQuery> queryProvider;
   @Inject protected ChangeNotes.Factory notesFactory;
@@ -206,11 +209,20 @@
 
   protected Injector injector;
   protected LifecycleManager lifecycle;
+
+  /**
+   * Index tests should not use username in query assert, since some backends do not use {@link
+   * ExternalId#SCHEME_USERNAME}
+   */
   protected Account.Id userId;
+
   protected CurrentUser user;
+  protected Account userAccount;
 
   private String systemTimeZone;
 
+  protected TestRepository<Repository> repo;
+
   protected abstract Injector createInjector();
 
   @Before
@@ -226,6 +238,10 @@
 
   @After
   public void cleanUp() {
+    if (repo != null) {
+      repo.close();
+      repo = null;
+    }
     lifecycle.stop();
   }
 
@@ -252,8 +268,9 @@
     return () -> requestUser;
   }
 
-  protected void resetUser() {
+  protected void resetUser() throws ConfigInvalidException, IOException {
     user = userFactory.create(userId);
+    userAccount = accounts.get(userId).get().account();
     requestContext.setContext(newRequestContext(userId));
   }
 
@@ -289,9 +306,9 @@
 
   @Test
   public void byId() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     assertQuery("12345");
     assertQuery(change1.getId().get(), change1);
@@ -300,8 +317,8 @@
 
   @Test
   public void byKey() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
     String key = change.getKey().get();
 
     assertQuery("I0000000000000000000000000000000000000000");
@@ -313,8 +330,8 @@
 
   @Test
   public void byTriplet() throws Exception {
-    TestRepository<Repo> repo = createProject("iabcde");
-    Change change = insert(repo, newChangeForBranch(repo, "branch"));
+    repo = createAndOpenProject("iabcde");
+    Change change = insert("iabcde", newChangeForBranch(repo, "branch"));
     String k = change.getKey().get();
 
     assertQuery("iabcde~branch~" + k, change);
@@ -336,11 +353,11 @@
 
   @Test
   public void byStatus() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change2 = insert(repo, ins2);
+    Change change2 = insert("repo", ins2);
 
     assertQuery("status:new", change1);
     assertQuery("status:NEW", change1);
@@ -355,11 +372,11 @@
 
   @Test
   public void byStatusOr() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change2 = insert(repo, ins2);
+    Change change2 = insert("repo", ins2);
 
     assertQuery("status:new OR status:merged", change2, change1);
     assertQuery("status:new or status:merged", change2, change1);
@@ -367,10 +384,10 @@
 
   @Test
   public void byStatusOpen() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
-    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change1 = insert("repo", ins1);
+    insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
     Change[] expected = new Change[] {change1};
     assertQuery("status:open", expected);
@@ -389,12 +406,12 @@
 
   @Test
   public void byStatusClosed() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
-    Change change2 = insert(repo, ins2);
-    insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+    Change change2 = insert("repo", ins2);
+    insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
 
     Change[] expected = new Change[] {change2, change1};
     assertQuery("status:closed", expected);
@@ -410,12 +427,12 @@
 
   @Test
   public void byStatusAbandoned() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
-    insert(repo, ins1);
+    insert("repo", ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
-    Change change1 = insert(repo, ins2);
-    insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+    Change change1 = insert("repo", ins2);
+    insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
 
     assertQuery("status:abandoned", change1);
     assertQuery("status:ABANDONED", change1);
@@ -424,10 +441,10 @@
 
   @Test
   public void byStatusPrefix() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
-    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change1 = insert("repo", ins1);
+    Change change2 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
     assertQuery("status:n", change1);
     assertQuery("status:ne", change1);
@@ -435,6 +452,7 @@
     assertQuery("status:N", change1);
     assertQuery("status:nE", change1);
     assertQuery("status:neW", change1);
+    assertQuery("status:m", change2);
     Exception thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:newx"));
     assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: newx");
     thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:nx"));
@@ -443,11 +461,11 @@
 
   @Test
   public void byPrivate() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
+    Change change2 = insert("repo", newChange(repo), user2);
 
     // No private changes.
     assertQuery("is:open", change2, change1);
@@ -467,8 +485,8 @@
 
   @Test
   public void byWip() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
 
     assertQuery("is:open", change1);
     assertQuery("is:wip");
@@ -485,8 +503,8 @@
   @Test
   public void excludeWipChangeFromReviewersDashboards() throws Exception {
     Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWorkInProgress(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWorkInProgress(repo), userId);
 
     assertQuery("is:wip", change1);
     assertQuery("reviewer:" + user1);
@@ -502,8 +520,8 @@
 
   @Test
   public void byStarted() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWorkInProgress(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWorkInProgress(repo));
 
     assertQuery("is:started");
 
@@ -538,11 +556,11 @@
   @Test
   public void restorePendingReviewers() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
+    repo = createAndOpenProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
-    Change change1 = insert(repo, newChangeWorkInProgress(repo));
+    Change change1 = insert("repo", newChangeWorkInProgress(repo));
     Account.Id user1 = createAccount("user1");
     Account.Id user2 = createAccount("user2");
     String email1 = "email1@example.com";
@@ -595,9 +613,9 @@
 
   @Test
   public void byCommit() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
-    Change change = insert(repo, ins);
+    Change change = insert("repo", ins);
     String sha = ins.getCommitId().name();
 
     assertQuery("0000000000000000000000000000000000000000");
@@ -611,11 +629,11 @@
 
   @Test
   public void byOwner() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
+    Change change2 = insert("repo", newChange(repo), user2);
 
     assertQuery("is:owner", change1);
     assertQuery("owner:" + userId.get(), change1);
@@ -628,15 +646,16 @@
   @Test
   public void byUploader() throws Exception {
     assume().that(getSchema().hasField(ChangeField.UPLOADER_SPEC)).isTrue();
-    Account.Id user2 =
-        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    CurrentUser user2CurrentUser = userFactory.create(user2);
 
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     assertQuery("is:uploader", change1);
     assertQuery("uploader:" + userId.get(), change1);
-    change1 = newPatchSet(repo, change1, user2CurrentUser, /* message= */ Optional.empty());
+
+    Account.Id user2 = createAccount("anotheruser");
+    CurrentUser user2CurrentUser = userFactory.create(user2);
+
+    change1 = newPatchSet("repo", change1, user2CurrentUser, /* message= */ Optional.empty());
     // Uploader has changed
     assertQuery("uploader:" + userId.get());
     assertQuery("uploader:" + user2.get(), change1);
@@ -669,7 +688,7 @@
   }
 
   private void byAuthorOrCommitterExact(String searchOperator) throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    createProject("repo");
     PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
     PersonIdent john = new PersonIdent("John", "john@example.com");
     PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
@@ -677,10 +696,10 @@
     PersonIdent myself = new PersonIdent("I Am", ua.preferredEmail());
     PersonIdent selfName = new PersonIdent("My Self", "my.self@example.com");
 
-    Change change1 = createChange(repo, johnDoe);
-    Change change2 = createChange(repo, john);
-    Change change3 = createChange(repo, doeSmith);
-    createChange(repo, selfName);
+    Change change1 = createChange("repo", johnDoe);
+    Change change2 = createChange("repo", john);
+    Change change3 = createChange("repo", doeSmith);
+    createChange("repo", selfName);
 
     // Only email address.
     assertQuery(searchOperator + "john.doe@example.com", change1);
@@ -705,19 +724,19 @@
     assertQuery(searchOperator + "self");
 
     // ':self' matches a change created with the current user's email address
-    Change change5 = createChange(repo, myself);
+    Change change5 = createChange("repo", myself);
     assertQuery(searchOperator + "me", change5);
     assertQuery(searchOperator + "self", change5);
   }
 
   private void byAuthorOrCommitterFullText(String searchOperator) throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    createProject("repo");
     PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
     PersonIdent john = new PersonIdent("John", "john@example.com");
     PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
-    Change change1 = createChange(repo, johnDoe);
-    Change change2 = createChange(repo, john);
-    Change change3 = createChange(repo, doeSmith);
+    Change change1 = createChange("repo", johnDoe);
+    Change change2 = createChange("repo", john);
+    Change change3 = createChange("repo", doeSmith);
 
     // By exact name.
     assertQuery(searchOperator + "\"John Doe\"", change1);
@@ -738,20 +757,25 @@
     assertThat(thrown).hasMessageThat().contains("invalid value");
   }
 
-  protected Change createChange(TestRepository<Repo> repo, PersonIdent person) throws Exception {
-    RevCommit commit =
-        repo.parseBody(repo.commit().message("message").author(person).committer(person).create());
-    return insert(repo, newChangeForCommit(repo, commit), null);
+  @CanIgnoreReturnValue
+  protected Change createChange(String repoName, PersonIdent person) throws Exception {
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+      RevCommit commit =
+          repo.parseBody(
+              repo.commit().message("message").author(person).committer(person).create());
+      return insert("repo", newChangeForCommit(repo, commit), null);
+    }
   }
 
   @Test
   public void byOwnerIn() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
-    Change change3 = insert(repo, newChange(repo), user2);
+    Change change2 = insert("repo", newChange(repo), user2);
+    Change change3 = insert("repo", newChange(repo), user2);
     gApi.changes().id(change3.getId().get()).current().review(ReviewInput.approve());
     gApi.changes().id(change3.getId().get()).current().submit();
 
@@ -764,24 +788,25 @@
   @Test
   public void byUploaderIn() throws Exception {
     assume().that(getSchema().hasField(ChangeField.UPLOADER_SPEC)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
+
     assertQuery("uploaderin:Administrators", change1);
 
-    Account.Id user2 =
-        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    Account.Id user2 = createAccount("anotheruser");
     CurrentUser user2CurrentUser = userFactory.create(user2);
-    change1 = newPatchSet(repo, change1, user2CurrentUser, /* message= */ Optional.empty());
+    change1 = newPatchSet("repo", change1, user2CurrentUser, /* message= */ Optional.empty());
+
     assertQuery("uploaderin:Administrators");
     assertQuery("uploaderin:\"Registered Users\"", change1);
   }
 
   @Test
   public void byProject() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("project:foo");
     assertQuery("project:repo");
@@ -791,16 +816,16 @@
 
   @Test
   public void byProjectWithHidden() throws Exception {
-    TestRepository<Repo> hiddenProject = createProject("hiddenProject");
-    insert(hiddenProject, newChange(hiddenProject));
+    createProject("hiddenProject");
+    insert("hiddenProject", newChange("hiddenProject"));
     projectOperations
         .project(Project.nameKey("hiddenProject"))
         .forUpdate()
         .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
-    TestRepository<Repo> visibleProject = createProject("visibleProject");
-    Change visibleChange = insert(visibleProject, newChange(visibleProject));
+    createProject("visibleProject");
+    Change visibleChange = insert("visibleProject", newChange("visibleProject"));
     assertQuery("project:visibleProject", visibleChange);
     assertQuery("project:hiddenProject");
     assertQuery("project:visibleProject OR project:hiddenProject", visibleChange);
@@ -808,13 +833,13 @@
 
   @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));
+    repo = createAndOpenProject("repo1");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("message").create());
+    Change change1 = insert("repo1", newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit(commit1));
+    Change change2 = insert("repo1", newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit(commit1, commit2));
+    Change change3 = insert("repo1", newChangeForCommit(repo, commit3));
 
     assertQuery("parentof:" + change1.getId().get());
     assertQuery("parentof:" + change1.getKey().get());
@@ -826,10 +851,10 @@
 
   @Test
   public void byParentProject() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2", "repo1");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("parentproject:repo1", change2, change1);
     assertQuery("parentproject:repo2", change2);
@@ -837,10 +862,10 @@
 
   @Test
   public void byProjectPrefix() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("projects:foo");
     assertQuery("projects:repo1", change1);
@@ -850,10 +875,10 @@
 
   @Test
   public void byRepository() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("repository:foo");
     assertQuery("repository:repo");
@@ -863,10 +888,10 @@
 
   @Test
   public void byParentRepository() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2", "repo1");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("parentrepository:repo1", change2, change1);
     assertQuery("parentrepository:repo2", change2);
@@ -874,10 +899,10 @@
 
   @Test
   public void byRepositoryPrefix() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("repositories:foo");
     assertQuery("repositories:repo1", change1);
@@ -887,10 +912,10 @@
 
   @Test
   public void byRepo() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("repo:foo");
     assertQuery("repo:repo");
@@ -900,10 +925,10 @@
 
   @Test
   public void byParentRepo() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2", "repo1");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("parentrepo:repo1", change2, change1);
     assertQuery("parentrepo:repo2", change2);
@@ -911,10 +936,10 @@
 
   @Test
   public void byRepoPrefix() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("repos:foo");
     assertQuery("repos:repo1", change1);
@@ -924,9 +949,9 @@
 
   @Test
   public void byBranchAndRef() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeForBranch(repo, "master"));
-    Change change2 = insert(repo, newChangeForBranch(repo, "branch"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeForBranch(repo, "master"));
+    Change change2 = insert("repo", newChangeForBranch(repo, "branch"));
 
     assertQuery("branch:foo");
     assertQuery("branch:master", change1);
@@ -943,26 +968,26 @@
   @Test
   public void byTopic() throws Exception {
 
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
 
     ChangeInserter ins2 = newChangeWithTopic(repo, "feature2");
-    Change change2 = insert(repo, ins2);
+    Change change2 = insert("repo", ins2);
 
     ChangeInserter ins3 = newChangeWithTopic(repo, "Cherrypick-feature2");
-    Change change3 = insert(repo, ins3);
+    Change change3 = insert("repo", ins3);
 
     ChangeInserter ins4 = newChangeWithTopic(repo, "feature2-fixup");
-    Change change4 = insert(repo, ins4);
+    Change change4 = insert("repo", ins4);
 
     ChangeInserter ins5 = newChangeWithTopic(repo, "https://gerrit.local");
-    Change change5 = insert(repo, ins5);
+    Change change5 = insert("repo", ins5);
 
     ChangeInserter ins6 = newChangeWithTopic(repo, "git_gerrit_training");
-    Change change6 = insert(repo, ins6);
+    Change change6 = insert("repo", ins6);
 
-    Change change_no_topic = insert(repo, newChange(repo));
+    Change changeNoTopic = insert("repo", newChange(repo));
 
     assertQuery("intopic:foo");
     assertQuery("intopic:feature1", change1);
@@ -971,8 +996,8 @@
     assertQuery("intopic:feature2", change4, change3, change2);
     assertQuery("intopic:fixup", change4);
     assertQuery("intopic:gerrit", change6, change5);
-    assertQuery("topic:\"\"", change_no_topic);
-    assertQuery("intopic:\"\"", change_no_topic);
+    assertQuery("topic:\"\"", changeNoTopic);
+    assertQuery("intopic:\"\"", changeNoTopic);
 
     assume().that(getSchema().hasField(ChangeField.PREFIX_TOPIC)).isTrue();
     assertQuery("prefixtopic:feature", change4, change2, change1);
@@ -982,16 +1007,16 @@
 
   @Test
   public void byTopicRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
     ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
 
     ChangeInserter ins2 = newChangeWithTopic(repo, "Cherrypick-feature1");
-    Change change2 = insert(repo, ins2);
+    Change change2 = insert("repo", ins2);
 
     ChangeInserter ins3 = newChangeWithTopic(repo, "feature1-fixup");
-    Change change3 = insert(repo, ins3);
+    Change change3 = insert("repo", ins3);
 
     assertQuery("intopic:^feature1.*", change3, change1);
     assertQuery("intopic:{^.*feature1$}", change2, change1);
@@ -999,13 +1024,13 @@
 
   @Test
   public void byMessageExact() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("one").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("two").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("A great \"fix\" to my bug").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     assertQuery("message:foo");
     assertQuery("message:one", change1);
@@ -1016,16 +1041,16 @@
   @Test
   public void byMessageRegEx() throws Exception {
     assume().that(getSchema().hasField(ChangeField.COMMIT_MESSAGE_EXACT)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("aaaabcc").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("aaaacc").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("Title\n\nHELLO WORLD").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
     RevCommit commit4 =
         repo.parseBody(repo.commit().message("Title\n\nfoobar hello WORLD").create());
-    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+    Change change4 = insert("repo", newChangeForCommit(repo, commit4));
 
     assertQuery("message:\"^aaaa(b|c)*\"", change2, change1);
     assertQuery("message:\"^aaaa(c)*c.*\"", change2);
@@ -1037,7 +1062,7 @@
   @Test
   public void bySubject() throws Exception {
     assume().that(getSchema().hasField(ChangeField.SUBJECT_SPEC)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 =
         repo.parseBody(
             repo.commit()
@@ -1046,7 +1071,7 @@
                         + "Message body\n\n"
                         + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b920")
                 .create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 =
         repo.parseBody(
             repo.commit()
@@ -1055,7 +1080,7 @@
                         + "Message body for another commit\n\n"
                         + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
                 .create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
     RevCommit commit3 =
         repo.parseBody(
             repo.commit()
@@ -1064,7 +1089,7 @@
                         + "Last message body\n\n"
                         + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
                 .create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     assertQuery("subject:First", change1);
     assertQuery("subject:Second", change2);
@@ -1074,7 +1099,7 @@
     assertQuery("subject:body");
     change1 =
         newPatchSet(
-            repo,
+            "repo",
             change1,
             user,
             Optional.of("Rework of commit with test subject\n\n" + "Message body\n\n"));
@@ -1084,12 +1109,60 @@
   }
 
   @Test
+  public void bySubjectPrefix() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.PREFIX_SUBJECT_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
+    RevCommit commit1 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "[FOO123] First commit with test subject\n\n"
+                        + "Message body\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b920")
+                .create());
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    RevCommit commit2 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "[BAR45] Second commit with test subject\n\n"
+                        + "Message body for another commit\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+                .create());
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    RevCommit commit3 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "[FOO99] Third commit with test subject\n\n"
+                        + "Last message body\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+                .create());
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+
+    assertQuery("prefixsubject:\"[FOO\"", change3, change1);
+    assertQuery("prefixsubject:\"[BAR\"", change2);
+    assertQuery("prefixsubject:\"[FOO1\"", change1);
+    assertQuery("prefixsubject:\"[FOO123]\"", change1);
+    assertQuery("prefixsubject:\"[\"", change3, change2, change1);
+    assertQuery("prefixsubject:FOO");
+    change1 =
+        newPatchSet(
+            "repo",
+            change1,
+            user,
+            Optional.of("[BAR123] Rework of commit with test subject\n\n" + "Message body\n\n"));
+    assertQuery("prefixsubject:\"[FOO\"", change3);
+    assertQuery("prefixsubject:\"[BAR\"", change1, change2);
+  }
+
+  @Test
   public void fullTextWithNumbers() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("12345 67890").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("12346 67891").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("message:1234");
     assertQuery("message:12345", change1);
@@ -1098,13 +1171,13 @@
 
   @Test
   public void fullTextMultipleTerms() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Signed-off: owner").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Signed by owner").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("This change is off").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     assertQuery("message:\"Signed-off: owner\"", change1);
     assertQuery("message:\"Signed\"", change2, change1);
@@ -1113,11 +1186,11 @@
 
   @Test
   public void byMessageMixedCase() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Hello gerrit").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Hello Gerrit").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("message:gerrit", change2, change1);
     assertQuery("message:Gerrit", change2, change1);
@@ -1125,16 +1198,16 @@
 
   @Test
   public void byMessageSubstring() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("https://gerrit.local").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     assertQuery("message:gerrit", change1);
   }
 
   @Test
   public void byLabel() throws Exception {
-    accountManager.authenticate(authRequestFactory.createForUser("anotheruser"));
-    TestRepository<Repo> repo = createProject("repo");
+    Account.Id anotherUser = createAccount("anotheruser");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
     ChangeInserter ins2 = newChange(repo);
     ChangeInserter ins3 = newChange(repo);
@@ -1142,24 +1215,24 @@
     ChangeInserter ins5 = newChange(repo);
     ChangeInserter ins6 = newChange(repo);
 
-    Change reviewMinus2Change = insert(repo, ins);
+    Change reviewMinus2Change = insert("repo", ins);
     gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
 
-    Change reviewMinus1Change = insert(repo, ins2);
+    Change reviewMinus1Change = insert("repo", ins2);
     gApi.changes().id(reviewMinus1Change.getId().get()).current().review(ReviewInput.dislike());
 
-    Change noLabelChange = insert(repo, ins3);
+    Change noLabelChange = insert("repo", ins3);
 
-    Change reviewPlus1Change = insert(repo, ins4);
+    Change reviewPlus1Change = insert("repo", ins4);
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
 
-    Change reviewTwoPlus1Change = insert(repo, ins5);
+    Change reviewTwoPlus1Change = insert("repo", ins5);
     gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
     requestContext.setContext(newRequestContext(createAccount("user1")));
     gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
     requestContext.setContext(newRequestContext(userId));
 
-    Change reviewPlus2Change = insert(repo, ins6);
+    Change reviewPlus2Change = insert("repo", ins6);
     gApi.changes().id(reviewPlus2Change.getId().get()).current().review(ReviewInput.approve());
 
     Map<String, Short> m =
@@ -1223,9 +1296,15 @@
     assertQuery("label:Code-Review<=-2", reviewMinus2Change);
     assertQuery("label:Code-Review<-2");
 
-    assertQuery("label:Code-Review=+1,anotheruser");
-    assertQuery("label:Code-Review=+1,user", reviewTwoPlus1Change, reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=user", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1," + anotherUser);
+    assertQuery(
+        String.format("label:Code-Review=+1,%s", userAccount.preferredEmail()),
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
+    assertQuery(
+        String.format("label:Code-Review=+1,user=%s", userAccount.preferredEmail()),
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
     assertQuery("label:Code-Review=+1,Administrators", reviewTwoPlus1Change, reviewPlus1Change);
     assertQuery(
         "label:Code-Review=+1,group=Administrators", reviewTwoPlus1Change, reviewPlus1Change);
@@ -1292,9 +1371,8 @@
 
   @Test
   public void byLabelMulti() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Project.NameKey project =
-        Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project.get());
 
     LabelType verified =
         label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
@@ -1321,25 +1399,25 @@
     ChangeInserter ins5 = newChange(repo);
 
     // CR+1
-    Change reviewCRplus1 = insert(repo, ins);
+    Change reviewCRplus1 = insert(project.get(), ins);
     gApi.changes().id(reviewCRplus1.getId().get()).current().review(ReviewInput.recommend());
 
     // CR+2
-    Change reviewCRplus2 = insert(repo, ins2);
+    Change reviewCRplus2 = insert(project.get(), ins2);
     gApi.changes().id(reviewCRplus2.getId().get()).current().review(ReviewInput.approve());
 
     // CR+1 VR+1
-    Change reviewCRplus1VRplus1 = insert(repo, ins3);
+    Change reviewCRplus1VRplus1 = insert(project.get(), ins3);
     gApi.changes().id(reviewCRplus1VRplus1.getId().get()).current().review(ReviewInput.recommend());
     gApi.changes().id(reviewCRplus1VRplus1.getId().get()).current().review(reviewVerified);
 
     // CR+2 VR+1
-    Change reviewCRplus2VRplus1 = insert(repo, ins4);
+    Change reviewCRplus2VRplus1 = insert(project.get(), ins4);
     gApi.changes().id(reviewCRplus2VRplus1.getId().get()).current().review(ReviewInput.approve());
     gApi.changes().id(reviewCRplus2VRplus1.getId().get()).current().review(reviewVerified);
 
     // VR+1
-    Change reviewVRplus1 = insert(repo, ins5);
+    Change reviewVRplus1 = insert(project.get(), ins5);
     gApi.changes().id(reviewVRplus1.getId().get()).current().review(reviewVerified);
 
     assertQuery("label:Code-Review=+1", reviewCRplus1VRplus1, reviewCRplus1);
@@ -1358,28 +1436,28 @@
 
   @Test
   public void byLabelNotOwner() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
     Account.Id user1 = createAccount("user1");
 
-    Change reviewPlus1Change = insert(repo, ins);
+    Change reviewPlus1Change = insert("repo", ins);
 
     // post a review with user1
     requestContext.setContext(newRequestContext(user1));
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
 
-    assertQuery("label:Code-Review=+1,user=user1", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=" + user1, reviewPlus1Change);
     assertQuery("label:Code-Review=+1,owner");
   }
 
   @Test
   public void byLabelNonUploader() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
     Account.Id user1 = createAccount("user1");
 
     // create a change with "user"
-    Change reviewPlus1Change = insert(repo, ins);
+    Change reviewPlus1Change = insert("repo", ins);
 
     // add a +1 vote with "user". Query doesn't match since voter is the uploader.
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
@@ -1418,8 +1496,8 @@
   @Test
   public void byLabelGroup() throws Exception {
     Account.Id user1 = createAccount("user1");
-    createAccount("user2");
-    TestRepository<Repo> repo = createProject("repo");
+    Account.Id user2 = createAccount("user2");
+    repo = createAndOpenProject("repo");
 
     // create group and add users
     String g1 = createGroup("group1", "Administrators");
@@ -1428,7 +1506,7 @@
     gApi.groups().id(g2).addMembers("user2");
 
     // create a change
-    Change change1 = insert(repo, newChange(repo), user1);
+    Change change1 = insert("repo", newChange(repo), user1);
 
     // post a review with user1
     requestContext.setContext(newRequestContext(user1));
@@ -1441,8 +1519,8 @@
     requestContext.setContext(newRequestContext(userId));
     assertQuery("label:Code-Review=+1,group1", change1);
     assertQuery("label:Code-Review=+1,group=group1", change1);
-    assertQuery("label:Code-Review=+1,user=user1", change1);
-    assertQuery("label:Code-Review=+1,user=user2");
+    assertQuery("label:Code-Review=+1,user=" + user1, change1);
+    assertQuery("label:Code-Review=+1,user=" + user2);
     assertQuery("label:Code-Review=+1,group=group2");
   }
 
@@ -1450,7 +1528,7 @@
   public void byLabelExternalGroup() throws Exception {
     Account.Id user1 = createAccount("user1");
     Account.Id user2 = createAccount("user2");
-    TestRepository<InMemoryRepositoryManager.Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
     // create group and add users
     AccountGroup.UUID external_group1 = AccountGroup.uuid("testbackend:group1");
@@ -1462,8 +1540,8 @@
     testGroupBackend.setMembershipsOf(
         user2, new ListGroupMembership(ImmutableList.of(external_group2)));
 
-    Change change1 = insert(repo, newChange(repo), user1);
-    Change change2 = insert(repo, newChange(repo), user1);
+    Change change1 = insert("repo", newChange(repo), user1);
+    Change change2 = insert("repo", newChange(repo), user1);
 
     // post a review with user1 and other_user
     requestContext.setContext(newRequestContext(user1));
@@ -1479,18 +1557,18 @@
 
     assertQuery("label:Code-Review=+1," + external_group1.get(), change1);
     assertQuery("label:Code-Review=+1,group=" + external_group1.get(), change1);
-    assertQuery("label:Code-Review=+1,user=user1", change1);
-    assertQuery("label:Code-Review=+1,user=user2");
+    assertQuery("label:Code-Review=+1,user=" + user1, change1);
+    assertQuery("label:Code-Review=+1,user=" + user2);
     assertQuery("label:Code-Review=+1,group=" + external_group2.get());
   }
 
   @Test
   public void limit() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Change last = null;
     int n = 5;
     for (int i = 0; i < n; i++) {
-      last = insert(repo, newChange(repo));
+      last = insert("repo", newChange(repo));
     }
 
     for (int i = 1; i <= n + 2; i++) {
@@ -1515,10 +1593,10 @@
 
   @Test
   public void start() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 2; i++) {
-      changes.add(insert(repo, newChange(repo)));
+      changes.add(insert("repo", newChange(repo)));
     }
 
     assertQuery("status:new", changes.get(1), changes.get(0));
@@ -1535,10 +1613,10 @@
 
   @Test
   public void startWithLimit() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 3; i++) {
-      changes.add(insert(repo, newChange(repo)));
+      changes.add(insert("repo", newChange(repo)));
     }
 
     assertQuery("status:new limit:2", changes.get(2), changes.get(1));
@@ -1549,8 +1627,8 @@
 
   @Test
   public void maxPages() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
 
     QueryRequest query = newQuery("status:new").withLimit(10);
     assertQuery(query, change);
@@ -1565,12 +1643,12 @@
   @Test
   public void updateOrder() throws Exception {
     resetTimeWithClockStep(2, MINUTES);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     List<ChangeInserter> inserters = new ArrayList<>();
     List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 5; i++) {
       inserters.add(newChange(repo));
-      changes.add(insert(repo, inserters.get(i)));
+      changes.add(insert("repo", inserters.get(i)));
     }
 
     for (int i : ImmutableList.of(2, 0, 1, 4, 3)) {
@@ -1592,10 +1670,10 @@
   @Test
   public void updatedOrder() throws Exception {
     resetTimeWithClockStep(1, SECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChange(repo);
-    Change change1 = insert(repo, ins1);
-    Change change2 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", ins1);
+    Change change2 = insert("repo", newChange(repo));
 
     assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
     assertQuery("status:new", change2, change1);
@@ -1613,12 +1691,12 @@
 
   @Test
   public void filterOutMoreThanOnePageOfResults() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
-      insert(repo, newChange(repo), user2);
+      insert("repo", newChange(repo), user2);
     }
 
     assertQuery("status:new ownerin:Administrators", change);
@@ -1627,11 +1705,11 @@
 
   @Test
   public void filterOutAllResults() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
-      insert(repo, newChange(repo), user2);
+      insert("repo", newChange(repo), user2);
     }
 
     assertQuery("status:new ownerin:Administrators");
@@ -1640,8 +1718,8 @@
 
   @Test
   public void byFileExact() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("file:file");
     assertQuery("file:dir", change);
@@ -1653,8 +1731,8 @@
 
   @Test
   public void byFileRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("file:.*file.*");
     assertQuery("file:^file.*"); // Whole path only.
@@ -1663,8 +1741,8 @@
 
   @Test
   public void byPathExact() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("path:file");
     assertQuery("path:dir");
@@ -1676,8 +1754,8 @@
 
   @Test
   public void byPathRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("path:.*file.*");
     assertQuery("path:^dir.file.*", change);
@@ -1685,12 +1763,12 @@
 
   @Test
   public void byExtension() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc"));
-    Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC"));
-    Change change3 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
-    Change change4 = insert(repo, newChangeWithFiles(repo, "Quux.java", "foo"));
-    Change change5 = insert(repo, newChangeWithFiles(repo, "foo"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithFiles(repo, "foo.h", "foo.cc"));
+    Change change2 = insert("repo", newChangeWithFiles(repo, "bar.H", "bar.CC"));
+    Change change3 = insert("repo", newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+    Change change4 = insert("repo", newChangeWithFiles(repo, "Quux.java", "foo"));
+    Change change5 = insert("repo", newChangeWithFiles(repo, "foo"));
 
     assertQuery("extension:java", change4);
     assertQuery("ext:java", change4);
@@ -1706,14 +1784,14 @@
 
   @Test
   public void byOnlyExtensions() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
-    Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
-    Change change3 = insert(repo, newChangeWithFiles(repo, "foo.CC", "bar.cc"));
-    Change change4 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
-    Change change5 = insert(repo, newChangeWithFiles(repo, "Quux.java"));
-    Change change6 = insert(repo, newChangeWithFiles(repo, "foo.txt", "foo"));
-    Change change7 = insert(repo, newChangeWithFiles(repo, "foo"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
+    Change change2 = insert("repo", newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
+    Change change3 = insert("repo", newChangeWithFiles(repo, "foo.CC", "bar.cc"));
+    Change change4 = insert("repo", newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+    Change change5 = insert("repo", newChangeWithFiles(repo, "Quux.java"));
+    Change change6 = insert("repo", newChangeWithFiles(repo, "foo.txt", "foo"));
+    Change change7 = insert("repo", newChangeWithFiles(repo, "foo"));
 
     // case doesn't matter
     assertQuery("onlyextensions:cc,h", change4, change2, change1);
@@ -1753,23 +1831,23 @@
 
   @Test
   public void byFooter() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nfoo: baz").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar\nfoo:baz").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
     RevCommit commit4 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar=baz").create());
-    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+    Change change4 = insert("repo", newChangeForCommit(repo, commit4));
 
     // create a changes with lines that look like footers, but which are not
     RevCommit commit5 =
         repo.parseBody(
             repo.commit().message("Test\n\nfoo: bar\n\nfoo=bar").insertChangeId().create());
-    Change change5 = insert(repo, newChangeForCommit(repo, commit5));
+    Change change5 = insert("repo", newChangeForCommit(repo, commit5));
     RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
-    insert(repo, newChangeForCommit(repo, commit6));
+    insert("repo", newChangeForCommit(repo, commit6));
 
     // matching by 'key=value' works
     assertQuery("footer:foo=bar", change3, change1);
@@ -1803,15 +1881,15 @@
   @Test
   public void byFooterName() throws Exception {
     assume().that(getSchema().hasField(ChangeField.FOOTER_NAME)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nBaR: baz").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     // create a changes with lines that look like footers, but which are not
     RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
-    insert(repo, newChangeForCommit(repo, commit6));
+    insert("repo", newChangeForCommit(repo, commit6));
 
     // matching by 'key=value' works
     assertQuery("hasfooter:foo", change1);
@@ -1823,14 +1901,14 @@
 
   @Test
   public void byDirectory() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
-    Change change2 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
+    Change change2 = insert("repo", newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
     Change change3 =
-        insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
-    Change change4 = insert(repo, newChangeWithFiles(repo, "a.txt"));
-    Change change5 = insert(repo, newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
-    Change change6 = insert(repo, newChangeWithFiles(repo, "all/caps/DIRECTORY/file.txt"));
+        insert("repo", newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+    Change change4 = insert("repo", newChangeWithFiles(repo, "a.txt"));
+    Change change5 = insert("repo", newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
+    Change change6 = insert("repo", newChangeWithFiles(repo, "all/caps/DIRECTORY/file.txt"));
 
     // matching by directory prefix works
     assertQuery("directory:src", change2, change1);
@@ -1891,10 +1969,10 @@
 
   @Test
   public void byDirectoryRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
     Change change2 =
-        insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+        insert("repo", newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
 
     // match by regexp
     assertQuery("directory:^.*va.*", change1);
@@ -1904,9 +1982,9 @@
 
   @Test
   public void byComment() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
-    Change change = insert(repo, ins);
+    Change change = insert("repo", ins);
 
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
@@ -1934,11 +2012,11 @@
   public void byAge() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
     Change change2 =
-        insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
+        insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
 
     // Stop time so age queries use the same endpoint.
     TestTimeUtil.setClockStep(0, MILLISECONDS);
@@ -1975,11 +2053,11 @@
   public void byBeforeUntil() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
     Change change2 =
-        insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
+        insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     // Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -2027,11 +2105,11 @@
   public void byAfterSince() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
     Change change2 =
-        insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
+        insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     // Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -2071,12 +2149,12 @@
 
     // Stop the clock, will set time to specific test values.
     resetTimeWithClockStep(0, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("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));
+    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);
@@ -2131,12 +2209,12 @@
 
     // Stop the clock, will set time to specific test values.
     resetTimeWithClockStep(0, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("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));
+    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));
@@ -2201,13 +2279,13 @@
 
     // Stop the clock, will set time to specific test values.
     resetTimeWithClockStep(0, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("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));
+    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);
@@ -2234,15 +2312,15 @@
 
   @Test
   public void bySize() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
     // added = 3, deleted = 0, delta = 3
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "foo\n\foo\nfoo").create());
     // added = 0, deleted = 2, delta = 2
     RevCommit commit2 = repo.parseBody(repo.commit().parent(commit1).add("file1", "foo").create());
 
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("added:>4");
     assertQuery("-added:<=4");
@@ -2290,9 +2368,9 @@
   }
 
   private List<Change> setUpHashtagChanges() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     addHashtags(change1.getId(), "foo", "aaa-bbb-ccc");
     addHashtags(change2.getId(), "foo", "bar", "a tag", "ACamelCaseTag");
@@ -2339,10 +2417,10 @@
 
   @Test
   public void byHashtagRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
     addHashtags(change1.getId(), "feature1");
     addHashtags(change1.getId(), "trending");
     addHashtags(change2.getId(), "Cherrypick-feature1");
@@ -2355,27 +2433,27 @@
 
   @Test
   public void byDefault() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
-    Change change1 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
 
     RevCommit commit2 = repo.parseBody(repo.commit().message("foosubject").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     RevCommit commit3 = repo.parseBody(repo.commit().add("Foo.java", "foo contents").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     ChangeInserter ins4 = newChange(repo);
-    Change change4 = insert(repo, ins4);
+    Change change4 = insert("repo", ins4);
     ReviewInput ri4 = new ReviewInput();
     ri4.message = "toplevel";
     ri4.labels = ImmutableMap.of("Code-Review", (short) 1);
     gApi.changes().id(change4.getId().get()).current().review(ri4);
 
     ChangeInserter ins5 = newChangeWithTopic(repo, "feature5");
-    Change change5 = insert(repo, ins5);
+    Change change5 = insert("repo", ins5);
 
-    Change change6 = insert(repo, newChangeForBranch(repo, "branch6"));
+    Change change6 = insert("repo", newChangeForBranch(repo, "branch6"));
 
     assertQuery(change1.getId().get(), change1);
     assertQuery(ChangeTriplet.format(change1), change1);
@@ -2396,18 +2474,18 @@
 
   @Test
   public void byDefaultWithCommitPrefix() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit = repo.parseBody(repo.commit().message("message").create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
+    Change change = insert("repo", newChangeForCommit(repo, commit));
 
     assertQuery(commit.getId().getName().substring(0, 6), change);
   }
 
   @Test
   public void visible() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChangePrivate(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChangePrivate(repo));
 
     String q = "project:repo";
 
@@ -2461,8 +2539,8 @@
 
     // Switch to user3
     requestContext.setContext(newRequestContext(user3));
-    Change change3 = insert(repo, newChange(repo), user3);
-    Change change4 = insert(repo, newChangePrivate(repo), user3);
+    Change change3 = insert("repo", newChange(repo), user3);
+    Change change4 = insert("repo", newChangePrivate(repo), user3);
 
     // User3 can see both their changes and the first user's change
     assertQuery(q + " visibleto:" + user3.get(), change4, change3, change1);
@@ -2502,9 +2580,9 @@
 
   @Test
   public void visibleToSelf() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     gApi.changes().id(change2.getChangeId()).setPrivate(true, "private");
 
@@ -2520,16 +2598,12 @@
 
   @Test
   public void byCommentBy() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
 
-    int user2 =
-        accountManager
-            .authenticate(authRequestFactory.createForUser("anotheruser"))
-            .getAccountId()
-            .get();
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
+    Account.Id user2 = createAccount("anotheruser");
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
     ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
@@ -2550,8 +2624,8 @@
   public void bySubmitRuleResult() throws Exception {
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
-      TestRepository<Repo> repo = createProject("repo");
-      Change change = insert(repo, newChange(repo));
+      repo = createAndOpenProject("repo");
+      Change change = insert("repo", newChange(repo));
       // The fake submit rule exports its ruleName as "FakeSubmitRule"
       assertQuery("rule:FakeSubmitRule");
 
@@ -2570,17 +2644,17 @@
   public void byNonExistingSubmitRule_returnsEmpty() throws Exception {
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
-      TestRepository<Repo> repo = createProject("repo");
-      insert(repo, newChange(repo));
+      repo = createAndOpenProject("repo");
+      insert("repo", newChange(repo));
       assertQuery("rule:non-existent-rule");
     }
   }
 
   @Test
   public void byHasDraft() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     assertQuery("has:draft");
 
@@ -2612,8 +2686,8 @@
    */
   public void byHasDraftExcludesZombieDrafts() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject(project.get());
+    Change change = insert("repo", newChange(repo));
     Change.Id id = change.getId();
 
     DraftInput in = new DraftInput();
@@ -2625,7 +2699,7 @@
     assertQuery("has:draft", change);
     assertQuery("commentby:" + userId);
 
-    try (TestRepository<Repo> allUsers =
+    try (TestRepository<Repository> allUsers =
         new TestRepository<>(repoManager.openRepository(allUsersName))) {
       Ref draftsRef = allUsers.getRepository().exactRef(RefNames.refsDraftComments(id, userId));
       assertThat(draftsRef).isNotNull();
@@ -2649,15 +2723,15 @@
 
   @Test
   public void byHasDraftWithManyDrafts() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Change[] changesWithDrafts = new Change[30];
 
     // unrelated change not shown in the result.
-    insert(repo, newChange(repo));
+    insert("repo", newChange(repo));
 
     for (int i = 0; i < changesWithDrafts.length; i++) {
       // put the changes in reverse order since this is the order we receive them from the index.
-      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert("repo", newChange(repo));
       DraftInput in = new DraftInput();
       in.line = 1;
       in.message = "nit: trailing whitespace";
@@ -2677,10 +2751,10 @@
 
   @Test
   public void byStarredBy() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    insert("repo", newChange(repo));
 
     gApi.accounts().self().starChange(change1.getId().toString());
     gApi.accounts().self().starChange(change2.getId().toString());
@@ -2696,8 +2770,8 @@
 
   @Test
   public void byStar() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
@@ -2712,11 +2786,11 @@
 
   @Test
   public void byStarWithManyStars() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Change[] changesWithDrafts = new Change[30];
     for (int i = 0; i < changesWithDrafts.length; i++) {
       // put the changes in reverse order since this is the order we receive them from the index.
-      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert("repo", newChange(repo));
 
       // star the change
       gApi.accounts()
@@ -2730,12 +2804,12 @@
 
   @Test
   public void byFrom() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
+    Change change2 = insert("repo", newChange(repo), user2);
 
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
@@ -2751,7 +2825,7 @@
 
   @Test
   public void conflicts() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 =
         repo.parseBody(
             repo.commit()
@@ -2763,10 +2837,10 @@
     RevCommit commit3 =
         repo.parseBody(repo.commit().add("dir/file2", "contents2 different").create());
     RevCommit commit4 = repo.parseBody(repo.commit().add("file4", "contents4").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
-    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+    Change change4 = insert("repo", newChangeForCommit(repo, commit4));
 
     assertQuery("conflicts:" + change1.getId().get(), change3);
     assertQuery("conflicts:" + change2.getId().get());
@@ -2779,11 +2853,12 @@
       name = "change.mergeabilityComputationBehavior",
       value = "API_REF_UPDATED_AND_CHANGE_REINDEX")
   public void mergeable() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    assume().that(getSchema().hasField(ChangeField.MERGEABLE_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
     RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents2").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("conflicts:" + change1.getId().get(), change2);
     assertQuery("conflicts:" + change2.getId().get(), change1);
@@ -2807,9 +2882,9 @@
   @Test
   public void cherrypick() throws Exception {
     assume().that(getSchema().hasField(ChangeField.CHERRY_PICK_SPEC)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newCherryPickChange(repo, "foo", change1.currentPatchSetId()));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newCherryPickChange(repo, "foo", change1.currentPatchSetId()));
 
     assertQuery("is:cherrypick", change2);
     assertQuery("-is:cherrypick", change1);
@@ -2818,14 +2893,14 @@
   @Test
   public void merge() throws Exception {
     assume().that(getSchema().hasField(ChangeField.MERGE_SPEC)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("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));
+    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()
@@ -2834,7 +2909,7 @@
             .parent(commit3)
             .insertChangeId()
             .create();
-    Change mergeChange = insert(repo, newChangeForCommit(repo, mergeCommit));
+    Change mergeChange = insert("repo", newChangeForCommit(repo, mergeCommit));
 
     assertQuery("status:open is:merge", mergeChange);
     assertQuery("status:open -is:merge", change3, change2, change1);
@@ -2844,10 +2919,10 @@
   @Test
   public void reviewedBy() throws Exception {
     resetTimeWithClockStep(2, MINUTES);
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
 
     gApi.changes().id(change1.getId().get()).current().review(new ReviewInput().message("comment"));
 
@@ -2858,7 +2933,7 @@
     gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
 
     PatchSet.Id ps3_1 = change3.currentPatchSetId();
-    change3 = newPatchSet(repo, change3, user, /* message= */ Optional.empty());
+    change3 = newPatchSet("repo", change3, user, /* message= */ Optional.empty());
     assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
     // Response to previous patch set still counts as reviewing.
     gApi.changes()
@@ -2885,11 +2960,11 @@
   @Test
   public void reviewerAndCc() throws Exception {
     Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
+    insert("repo", newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
@@ -2916,11 +2991,11 @@
 
   @Test
   public void byReviewed() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherUser =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     assertQuery("is:reviewed");
     assertQuery("status:reviewed");
@@ -2944,11 +3019,11 @@
         accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId();
     Account.Id user3 =
         accountManager.authenticate(authRequestFactory.createForUser("user3")).getAccountId();
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
@@ -2988,7 +3063,7 @@
   @Test
   public void reviewerAndCcByEmail() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
+    repo = createAndOpenProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
@@ -2996,9 +3071,9 @@
     String userByEmail = "un.registered@reviewer.com";
     String userByEmailWithName = "John Doe <" + userByEmail + ">";
 
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    insert("repo", newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmailWithName;
@@ -3021,16 +3096,16 @@
   @Test
   public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
+    repo = createAndOpenProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
 
     String userByEmail = "John Doe <un.registered@reviewer.com>";
 
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    insert("repo", newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmail;
@@ -3049,9 +3124,9 @@
   @Test
   public void submitRecords() throws Exception {
     Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     gApi.changes().id(change1.getId().get()).current().review(ReviewInput.approve());
     requestContext.setContext(newRequestContext(user1));
@@ -3062,7 +3137,7 @@
     assertQuery("-is:submittable", change2);
 
     assertQuery("label:CodE-RevieW=ok", change1);
-    assertQuery("label:CodE-RevieW=ok,user=user", change1);
+    assertQuery("label:CodE-RevieW=ok,user=" + userAccount.preferredEmail(), change1);
     assertQuery("label:CodE-RevieW=ok,Administrators", change1);
     assertQuery("label:CodE-RevieW=ok,group=Administrators", change1);
     assertQuery("label:CodE-RevieW=ok,owner", change1);
@@ -3081,10 +3156,10 @@
   public void hasEdit() throws Exception {
     Account.Id user1 = createAccount("user1");
     Account.Id user2 = createAccount("user2");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
     String changeId1 = change1.getKey().get();
-    Change change2 = insert(repo, newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
     String changeId2 = change2.getKey().get();
 
     requestContext.setContext(newRequestContext(user1));
@@ -3105,10 +3180,10 @@
 
   @Test
   public void byUnresolved() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
 
     // Change1 has one resolved comment (unresolvedcount = 0)
     // Change2 has one unresolved comment (unresolvedcount = 1)
@@ -3136,13 +3211,13 @@
 
   @Test
   public void byCommitsOnBranchNotMerged() throws Exception {
-    TestRepository<Repo> tr = createProject("repo");
-    testByCommitsOnBranchNotMerged(tr, ImmutableSet.of());
+    createProject("repo");
+    testByCommitsOnBranchNotMerged("repo", ImmutableSet.of());
   }
 
   @Test
   public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ObjectId missing =
         repo.branch(PatchSet.id(Change.id(987654), 1).toRefName())
             .commit()
@@ -3150,72 +3225,75 @@
             .insertChangeId()
             .create()
             .copy();
-    testByCommitsOnBranchNotMerged(repo, ImmutableSet.of(missing));
+    testByCommitsOnBranchNotMerged("repo", ImmutableSet.of(missing));
   }
 
-  private void testByCommitsOnBranchNotMerged(TestRepository<Repo> repo, Collection<ObjectId> extra)
+  private void testByCommitsOnBranchNotMerged(String repo, Collection<ObjectId> extra)
       throws Exception {
     int n = 10;
     List<String> shas = new ArrayList<>(n + extra.size());
     extra.forEach(i -> shas.add(i.name()));
     List<Integer> expectedIds = new ArrayList<>(n);
     BranchNameKey dest = null;
-    for (int i = 0; i < n; i++) {
-      ChangeInserter ins = newChange(repo);
-      insert(repo, ins);
-      if (dest == null) {
-        dest = ins.getChange().getDest();
+    try (TestRepository<Repository> repository =
+        new TestRepository<>(repoManager.openRepository(Project.nameKey(repo)))) {
+      for (int i = 0; i < n; i++) {
+        ChangeInserter ins = newChange(repository);
+        insert("repo", ins);
+        if (dest == null) {
+          dest = ins.getChange().getDest();
+        }
+        shas.add(ins.getCommitId().name());
+        expectedIds.add(ins.getChange().getId().get());
       }
-      shas.add(ins.getCommitId().name());
-      expectedIds.add(ins.getChange().getId().get());
     }
-
-    for (int i = 1; i <= 11; i++) {
-      Iterable<ChangeData> cds =
-          queryProvider.get().byCommitsOnBranchNotMerged(repo.getRepository(), dest, shas, i);
-      Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
-      String name = "limit " + i;
-      assertWithMessage(name).that(ids).hasSize(n);
-      assertWithMessage(name).that(ids).containsExactlyElementsIn(expectedIds);
+    try (Repository repository = repoManager.openRepository(Project.nameKey(repo))) {
+      for (int i = 1; i <= 11; i++) {
+        Iterable<ChangeData> cds =
+            queryProvider.get().byCommitsOnBranchNotMerged(repository, dest, shas, i);
+        Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
+        String name = "limit " + i;
+        assertWithMessage(name).that(ids).hasSize(n);
+        assertWithMessage(name).that(ids).containsExactlyElementsIn(expectedIds);
+      }
     }
   }
 
   @Test
   public void reindexIfStale() throws Exception {
-    Account.Id user = createAccount("user");
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject(project.get());
+    Change change = insert("repo", newChange(repo));
     String changeId = change.getKey().get();
-    ChangeNotes notes = notesFactory.create(change.getProject(), change.getId());
-    PatchSet ps = psUtil.get(notes, change.currentPatchSetId());
 
-    requestContext.setContext(newRequestContext(user));
-    gApi.changes().id(changeId).edit().create();
-    assertQuery("has:edit", change);
+    Account.Id anotherUser = createAccount("another-user");
+    requestContext.setContext(newRequestContext(anotherUser));
+    gApi.changes().id(changeId).addReviewer(anotherUser.toString());
+
+    assertQuery("reviewer:self", change);
     assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
 
-    // Delete edit ref behind index's back.
-    RefUpdate ru = repo.getRepository().updateRef(RefNames.refsEdit(user, change.getId(), ps.id()));
-    ru.setForceUpdate(true);
-    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    // Remove reviewer behind index's back.
+    ChangeUpdate update = newUpdate(change);
+    update.removeReviewer(anotherUser);
+    update.commit();
 
     // Index is stale.
-    assertQuery("has:edit", change);
+    assertQuery("reviewer:self", change);
     assertThat(indexer.reindexIfStale(project, change.getId()).get()).isTrue();
-    assertQuery("has:edit");
+    assertQuery("reviewer:self");
   }
 
   @Test
   public void watched() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
+    createProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus("repo", Change.Status.NEW);
+    Change change1 = insert("repo", ins1);
 
-    TestRepository<Repo> repo2 = createProject("repo2");
+    createProject("repo2");
 
-    ChangeInserter ins2 = newChangeWithStatus(repo2, Change.Status.NEW);
-    insert(repo2, ins2);
+    ChangeInserter ins2 = newChangeWithStatus("repo2", Change.Status.NEW);
+    insert("repo2", ins2);
 
     assertQuery("is:watched");
 
@@ -3235,17 +3313,17 @@
 
   @Test
   public void trackingid() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 =
         repo.parseBody(repo.commit().message("Change one\n\nBug:QUERY123").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 =
         repo.parseBody(repo.commit().message("Change two\n\nIssue: Issue 16038\n").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     RevCommit commit3 =
         repo.parseBody(repo.commit().message("Change two\n\nGoogle-Bug-Id: b/16039\n").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     assertQuery("tr:QUERY123", change1);
     assertQuery("bug:QUERY123", change1);
@@ -3271,9 +3349,9 @@
 
   @Test
   public void revertOf() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     // Create two commits and revert second commit (initial commit can't be reverted)
-    Change initial = insert(repo, newChange(repo));
+    Change initial = insert("repo", newChange(repo));
     gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(initial.getChangeId()).current().submit();
 
@@ -3288,10 +3366,10 @@
 
   @Test
   public void submissionId() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
     // create irrelevant change
-    insert(repo, newChange(repo));
+    insert("repo", newChange(repo));
     gApi.changes().id(change.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(change.getChangeId()).current().submit();
     String submissionId = gApi.changes().id(change.getChangeId()).get().submissionId;
@@ -3361,9 +3439,9 @@
       return this;
     }
 
-    DashboardChangeState create(TestRepository<Repo> repo) throws Exception {
+    DashboardChangeState create(TestRepository<Repository> repo) throws Exception {
       requestContext.setContext(newRequestContext(ownerId));
-      Change change = insert(repo, newChange(repo), ownerId);
+      Change change = insert("repo", newChange(repo), ownerId);
       id = change.getId();
       ChangeApi cApi = gApi.changes().id(change.getChangeId());
       if (assigneeId != null) {
@@ -3431,7 +3509,7 @@
 
   @Test
   public void dashboardHasUnpublishedDrafts() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState hasUnpublishedDraft =
         new DashboardChangeState(otherAccountId).draftCommentBy(user.getAccountId()).create(repo);
@@ -3449,7 +3527,7 @@
 
   @Test
   public void dashboardAssignedReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState otherOpenWip =
         new DashboardChangeState(otherAccountId).wip().assignTo(user.getAccountId()).create(repo);
@@ -3471,12 +3549,12 @@
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
-        user.getUserName().get(), IndexPreloadingUtil.DASHBOARD_ASSIGNED_QUERY, otherOpenWip);
+        userId.toString(), IndexPreloadingUtil.DASHBOARD_ASSIGNED_QUERY, otherOpenWip);
   }
 
   @Test
   public void dashboardWorkInProgressReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     DashboardChangeState ownedOpenWip =
         new DashboardChangeState(user.getAccountId()).wip().create(repo);
 
@@ -3491,7 +3569,7 @@
 
   @Test
   public void dashboardOutgoingReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState ownedOpenReviewable =
         new DashboardChangeState(user.getAccountId()).create(repo);
@@ -3506,14 +3584,12 @@
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
-        user.getUserName().get(),
-        IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY,
-        ownedOpenReviewable);
+        userId.toString(), IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY, ownedOpenReviewable);
   }
 
   @Test
   public void dashboardIncomingReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState reviewingReviewable =
         new DashboardChangeState(otherAccountId).addReviewer(user.getAccountId()).create(repo);
@@ -3539,7 +3615,7 @@
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
-        user.getUserName().get(),
+        userId.toString(),
         IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY,
         assignedReviewable,
         reviewingReviewable);
@@ -3547,7 +3623,7 @@
 
   @Test
   public void dashboardRecentlyClosedReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState mergedOwned =
         new DashboardChangeState(user.getAccountId()).mergeBy(user.getAccountId()).create(repo);
@@ -3610,7 +3686,7 @@
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
-        user.getUserName().get(),
+        userId.toString(),
         IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
         abandonedAssignedWip,
         abandonedAssigned,
@@ -3626,9 +3702,9 @@
   public void attentionSetIndexed() throws Exception {
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS_COUNT)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "some reason");
     gApi.changes().id(change1.getChangeId()).addToAttentionSet(input);
@@ -3637,7 +3713,7 @@
     assertQuery("-is:attention", change2);
     assertQuery("has:attention", change1);
     assertQuery("-has:attention", change2);
-    assertQuery("attention:" + user.getUserName().get(), change1);
+    assertQuery("attention:" + userAccount.preferredEmail(), change1);
     assertQuery("-attention:" + userId.toString(), change2);
 
     gApi.changes()
@@ -3650,8 +3726,8 @@
   @Test
   public void attentionSetStored() throws Exception {
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
 
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "reason 1");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
@@ -3679,29 +3755,29 @@
 
   @Test
   public void assignee() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     AssigneeInput input = new AssigneeInput();
-    input.assignee = user.getUserName().get();
+    input.assignee = user.getAccountId().toString();
     gApi.changes().id(change1.getChangeId()).setAssignee(input);
 
     assertQuery("is:assigned", change1);
     assertQuery("-is:assigned", change2);
     assertQuery("is:unassigned", change2);
     assertQuery("-is:unassigned", change1);
-    assertQuery("assignee:" + user.getUserName().get(), change1);
-    assertQuery("-assignee:" + user.getUserName().get(), change2);
+    assertQuery("assignee:" + user.getAccountId(), change1);
+    assertQuery("-assignee:" + user.getAccountId(), change2);
   }
 
   @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
   public void userDestination() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    Change change1 = insert(repo1, newChange(repo1));
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    Change change1 = insert("repo1", newChange("repo1"));
+    createProject("repo2");
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertThatQueryException("destination:foo")
         .hasMessageThat()
@@ -3715,7 +3791,7 @@
     String destination4 = "refs/heads/master\trepo3";
     String destination5 = "refs/heads/other\trepo1";
 
-    try (TestRepository<Repo> allUsers =
+    try (TestRepository<Repository> allUsers =
         new TestRepository<>(repoManager.openRepository(allUsersName))) {
       String refsUsers = RefNames.refsUsers(userId);
       allUsers.branch(refsUsers).commit().add("destinations/destination1", destination1).create();
@@ -3766,26 +3842,25 @@
     assertThatQueryException("destination:destination3,user=" + anotherUserId)
         .hasMessageThat()
         .isEqualTo("Unknown named destination: destination3");
-    assertThatQueryException("destination:destination3,user=test")
+    assertThatQueryException("destination:destination3,user=non-existent")
         .hasMessageThat()
-        .isEqualTo("Account 'test' not found");
+        .isEqualTo("Account 'non-existent' not found");
 
     requestContext.setContext(newRequestContext(anotherUserId));
-    // account 1000000 is not visible to 'anotheruser' as they are not an admin
+    // account userId is not visible to 'anotheruser' as they are not an admin
     assertThatQueryException("destination:destination3,user=" + userId)
         .hasMessageThat()
-        .isEqualTo("Account '1000000' not found");
+        .isEqualTo(String.format("Account '%s' not found", userId));
   }
 
   @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"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChangeForBranch(repo, "stable"));
 
-    Account.Id anotherUserId =
-        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    Account.Id anotherUserId = createAccount("anotheruser");
     String queryListText =
         "query1\tproject:repo\n"
             + "query2\tproject:repo status:open\n"
@@ -3797,7 +3872,7 @@
             + "query7\tproject:repo branch:stable\n"
             + "query8\tproject:repo branch:other";
 
-    try (TestRepository<Repo> allUsers =
+    try (TestRepository<Repository> allUsers =
             new TestRepository<>(repoManager.openRepository(allUsersName));
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName);
         MetaDataUpdate anotherMd = metaDataUpdateFactory.create(allUsersName)) {
@@ -3811,19 +3886,20 @@
       anotherQueries.commit(anotherMd);
     }
 
+    assertThat(gApi.accounts().self().get()._accountId).isEqualTo(userId.get());
     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")
+    assertThatQueryException("query:query1,user=non-existent")
         .hasMessageThat()
-        .isEqualTo("Account 'test' not found");
+        .isEqualTo("Account 'non-existent' 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");
+        .isEqualTo(String.format("Account '%s' not found", userId));
     requestContext.setContext(newRequestContext(userId));
 
     assertQuery("query:query1", change2, change1);
@@ -3842,16 +3918,16 @@
 
   @Test
   public void byOwnerInvalidQuery() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    insert("repo", newChange(repo), userId);
     String nameEmail = user.asIdentifiedUser().getNameEmail();
     assertQuery("owner: \"" + nameEmail + "\"\\");
   }
 
   @Test
   public void byDeletedChange() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
 
     String query = "change:" + change.getId();
     assertQuery(query, change);
@@ -3862,8 +3938,8 @@
 
   @Test
   public void byUrlEncodedProject() throws Exception {
-    TestRepository<Repo> repo = createProject("repo+foo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo+foo");
+    Change change = insert("repo+foo", newChange(repo));
     assertQuery("project:repo+foo", change);
   }
 
@@ -3896,9 +3972,9 @@
   @Test
   public void isPureRevert() throws Exception {
     assume().that(getSchema().hasField(ChangeField.IS_PURE_REVERT_SPEC)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     // Create two commits and revert second commit (initial commit can't be reverted)
-    Change initial = insert(repo, newChange(repo));
+    Change initial = insert("repo", newChange(repo));
     gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(initial.getChangeId()).current().submit();
 
@@ -3942,8 +4018,8 @@
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
     gApi.changes().id(change.getId().get()).addReviewer(user2.toString());
 
     RequestContext adminContext = requestContext.setContext(newRequestContext(user2));
@@ -3958,8 +4034,8 @@
 
   @Test
   public void none() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
 
     assertQuery(ChangeIndexPredicate.none());
 
@@ -3979,27 +4055,24 @@
   @Test
   @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
   public void mergeableFailsWhenNotIndexed() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    assume().that(getSchema().hasField(ChangeField.MERGE_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
-    insert(repo, newChangeForCommit(repo, commit1));
+    insert("repo", newChangeForCommit(repo, commit1));
 
     Throwable thrown = assertThrows(Throwable.class, () -> assertQuery("status:open is:mergeable"));
     assertThat(thrown.getCause()).isInstanceOf(QueryParseException.class);
     assertThat(thrown)
         .hasMessageThat()
-        .contains("'is:mergeable' operator is not supported by server");
+        .contains("'is:mergeable' operator is not supported on this gerrit host");
   }
 
-  protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
-    return newChange(repo, null, null, null, null, null, false, false);
-  }
-
-  protected ChangeInserter newChangeForCommit(TestRepository<Repo> repo, RevCommit commit)
+  protected ChangeInserter newChangeForCommit(TestRepository<Repository> repo, RevCommit commit)
       throws Exception {
     return newChange(repo, commit, null, null, null, null, false, false);
   }
 
-  protected ChangeInserter newChangeWithFiles(TestRepository<Repo> repo, String... paths)
+  protected ChangeInserter newChangeWithFiles(TestRepository<Repository> repo, String... paths)
       throws Exception {
     TestRepository<?>.CommitBuilder b = repo.commit().message("Change with files");
     for (String path : paths) {
@@ -4008,36 +4081,67 @@
     return newChangeForCommit(repo, repo.parseBody(b.create()));
   }
 
-  protected ChangeInserter newChangeForBranch(TestRepository<Repo> repo, String branch)
+  protected ChangeInserter newChangeForBranch(TestRepository<Repository> repo, String branch)
       throws Exception {
     return newChange(repo, null, branch, null, null, null, false, false);
   }
 
-  protected ChangeInserter newChangeWithStatus(TestRepository<Repo> repo, Change.Status status)
-      throws Exception {
+  protected ChangeInserter newChangeWithStatus(
+      TestRepository<Repository> repo, Change.Status status) throws Exception {
     return newChange(repo, null, null, status, null, null, false, false);
   }
 
-  protected ChangeInserter newChangeWithTopic(TestRepository<Repo> repo, String topic)
+  protected ChangeInserter newChangeWithStatus(String repoName, Change.Status status)
+      throws Exception {
+    return newChange(repoName, null, null, status, null, null, false, false);
+  }
+
+  protected ChangeInserter newChangeWithTopic(TestRepository<Repository> repo, String topic)
       throws Exception {
     return newChange(repo, null, null, null, topic, null, false, false);
   }
 
-  protected ChangeInserter newChangeWorkInProgress(TestRepository<Repo> repo) throws Exception {
+  protected ChangeInserter newChangeWorkInProgress(TestRepository<Repository> repo)
+      throws Exception {
     return newChange(repo, null, null, null, null, null, true, false);
   }
 
-  protected ChangeInserter newChangePrivate(TestRepository<Repo> repo) throws Exception {
+  protected ChangeInserter newChangePrivate(TestRepository<Repository> repo) throws Exception {
     return newChange(repo, null, null, null, null, null, false, true);
   }
 
   protected ChangeInserter newCherryPickChange(
-      TestRepository<Repo> repo, String branch, PatchSet.Id cherryPickOf) throws Exception {
+      TestRepository<Repository> repo, String branch, PatchSet.Id cherryPickOf) throws Exception {
     return newChange(repo, null, branch, null, null, cherryPickOf, false, true);
   }
 
+  protected ChangeInserter newChange(String repoName) throws Exception {
+    return newChange(repoName, null, null, null, null, null, false, false);
+  }
+
   protected ChangeInserter newChange(
-      TestRepository<Repo> repo,
+      String repoName,
+      @Nullable RevCommit commit,
+      @Nullable String branch,
+      @Nullable Change.Status status,
+      @Nullable String topic,
+      @Nullable PatchSet.Id cherryPickOf,
+      boolean workInProgress,
+      boolean isPrivate)
+      throws Exception {
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+      return newChange(
+          repo, commit, branch, status, topic, cherryPickOf, workInProgress, isPrivate);
+    }
+  }
+
+  protected ChangeInserter newChange(TestRepository<Repository> repo) throws Exception {
+    return newChange(repo, null, null, null, null, null, false, false);
+  }
+
+  protected ChangeInserter newChange(
+      TestRepository<Repository> repo,
       @Nullable RevCommit commit,
       @Nullable String branch,
       @Nullable Change.Status status,
@@ -4047,7 +4151,7 @@
       boolean isPrivate)
       throws Exception {
     if (commit == null) {
-      commit = repo.parseBody(repo.commit().message("message").create());
+      commit = repo.parseBody(repo.commit().message("initial message").create());
     }
 
     branch = MoreObjects.firstNonNull(branch, "refs/heads/master");
@@ -4056,32 +4160,32 @@
     }
 
     Change.Id id = Change.id(seq.nextChangeId());
-    ChangeInserter ins =
-        changeFactory
-            .create(id, commit, branch)
-            .setValidate(false)
-            .setStatus(status)
-            .setTopic(topic)
-            .setWorkInProgress(workInProgress)
-            .setPrivate(isPrivate)
-            .setCherryPickOf(cherryPickOf);
-    return ins;
+    return changeFactory
+        .create(id, commit, branch)
+        .setValidate(false)
+        .setStatus(status)
+        .setTopic(topic)
+        .setWorkInProgress(workInProgress)
+        .setPrivate(isPrivate)
+        .setCherryPickOf(cherryPickOf);
   }
 
-  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins) throws Exception {
-    return insert(repo, ins, null, TimeUtil.now());
-  }
-
-  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner)
+  @CanIgnoreReturnValue
+  protected Change insert(String repoName, ChangeInserter ins, @Nullable Account.Id owner)
       throws Exception {
-    return insert(repo, ins, owner, TimeUtil.now());
+    return insert(repoName, ins, owner, TimeUtil.now());
   }
 
+  @CanIgnoreReturnValue
+  protected Change insert(String repoName, ChangeInserter ins) throws Exception {
+    return insert(repoName, ins, null, TimeUtil.now());
+  }
+
+  @CanIgnoreReturnValue
   protected Change insert(
-      TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner, Instant createdOn)
+      String repoName, ChangeInserter ins, @Nullable Account.Id owner, Instant createdOn)
       throws Exception {
-    Project.NameKey project =
-        Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
+    Project.NameKey project = Project.nameKey(repoName);
     Account.Id ownerId = owner != null ? owner : userId;
     IdentifiedUser user = userFactory.create(ownerId);
     try (BatchUpdate bu = updateFactory.create(project, user, createdOn)) {
@@ -4092,34 +4196,36 @@
   }
 
   protected Change newPatchSet(
-      TestRepository<Repo> repo, Change c, CurrentUser user, Optional<String> message)
-      throws Exception {
-    // Add a new file so the patch set is not a trivial rebase, to avoid default
-    // Code-Review label copying.
-    int n = c.currentPatchSetId().get() + 1;
-    RevCommit commit =
-        repo.parseBody(
-            repo.commit()
-                .message(message.orElse("message"))
-                .add("file" + n, "contents " + n)
-                .create());
+      String repoName, Change c, CurrentUser user, Optional<String> message) throws Exception {
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+      // Add a new file so the patch set is not a trivial rebase, to avoid default
+      // Code-Review label copying.
+      int n = c.currentPatchSetId().get() + 1;
+      RevCommit commit =
+          repo.parseBody(
+              repo.commit()
+                  .message(message.orElse("updated message"))
+                  .add("file" + n, "contents " + n)
+                  .create());
 
-    PatchSetInserter inserter =
-        patchSetFactory
-            .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
-            .setFireRevisionCreated(false)
-            .setValidate(false);
-    try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.now());
-        ObjectInserter oi = repo.getRepository().newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-      bu.setRepository(repo.getRepository(), rw, oi);
-      bu.setNotify(NotifyResolver.Result.none());
-      bu.addOp(c.getId(), inserter);
-      bu.execute();
+      PatchSetInserter inserter =
+          patchSetFactory
+              .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
+              .setFireRevisionCreated(false)
+              .setValidate(false);
+      try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.now());
+          ObjectInserter oi = repo.getRepository().newObjectInserter();
+          ObjectReader reader = oi.newReader();
+          RevWalk rw = new RevWalk(reader)) {
+        bu.setRepository(repo.getRepository(), rw, oi);
+        bu.setNotify(NotifyResolver.Result.none());
+        bu.addOp(c.getId(), inserter);
+        bu.execute();
+      }
+
+      return inserter.getChange();
     }
-
-    return inserter.getChange();
   }
 
   protected ThrowableSubject assertThatQueryException(Object query) throws Exception {
@@ -4144,17 +4250,27 @@
     }
   }
 
-  protected TestRepository<Repo> createProject(String name) throws Exception {
-    gApi.projects().create(name).get();
+  @CanIgnoreReturnValue
+  protected TestRepository<Repository> createAndOpenProject(String name) throws Exception {
+    createProject(name);
     return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
   }
 
-  protected TestRepository<Repo> createProject(String name, String parent) throws Exception {
+  protected TestRepository<Repository> createAndOpenProject(String name, String parent)
+      throws Exception {
+    createProject(name, parent);
+    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
+  }
+
+  protected void createProject(String name) throws Exception {
+    gApi.projects().create(name).get();
+  }
+
+  protected void createProject(String name, String parent) throws Exception {
     ProjectInput input = new ProjectInput();
     input.name = name;
     input.parent = parent;
     gApi.projects().create(input).get();
-    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
   }
 
   protected QueryRequest newQuery(Object query) {
@@ -4365,4 +4481,12 @@
   protected Schema<ChangeData> getSchema() {
     return indexes.getSearchIndex().getSchema();
   }
+
+  protected ChangeUpdate newUpdate(Change c) throws Exception {
+    ChangeUpdate update =
+        TestChanges.newUpdate(injector, c, Optional.empty(), /* shouldExist= */ true);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.setAllowWriteToNewRef(true);
+    return update;
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index fe60119..6b17bb6 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -27,14 +27,13 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
 /**
@@ -58,19 +57,21 @@
   @UseClockStep
   public void stopQueryIfNoMoreResults() throws Exception {
     // create 2 visible changes
-    TestRepository<InMemoryRepositoryManager.Repo> testRepo = createProject("repo");
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
+    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+    }
 
     // create 2 invisible changes
-    TestRepository<Repo> hiddenProject = createProject("hiddenProject");
-    insert(hiddenProject, newChange(hiddenProject));
-    insert(hiddenProject, newChange(hiddenProject));
-    projectOperations
-        .project(Project.nameKey("hiddenProject"))
-        .forUpdate()
-        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
-        .update();
+    try (TestRepository<Repository> hiddenProject = createAndOpenProject("hiddenProject")) {
+      insert("hiddenProject", newChange(hiddenProject));
+      insert("hiddenProject", newChange(hiddenProject));
+      projectOperations
+          .project(Project.nameKey("hiddenProject"))
+          .forUpdate()
+          .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+          .update();
+    }
 
     AbstractFakeIndex<?, ?, ?> idx =
         (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
@@ -83,12 +84,13 @@
   @Test
   @UseClockStep
   public void noLimitQueryPaginates() throws Exception {
-    TestRepository<InMemoryRepositoryManager.Repo> testRepo = createProject("repo");
-    // create 4 changes
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
+    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+      // create 4 changes
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+    }
 
     // Set queryLimit to 2
     projectOperations
@@ -111,11 +113,12 @@
   @UseClockStep
   public void internalQueriesPaginate() throws Exception {
     // create 4 changes
-    TestRepository<InMemoryRepositoryManager.Repo> testRepo = createProject("repo");
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
-    insert(testRepo, newChange(testRepo));
+    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+    }
 
     // Set queryLimit to 2
     projectOperations
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 9717bfb..5cae012 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -24,11 +24,9 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
@@ -45,11 +43,11 @@
 
   @Test
   public void fullTextWithSpecialChars() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("foo_bar_foo").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("one.two.three").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("message:foo_ba");
     assertQuery("message:bar", change1);
@@ -63,8 +61,8 @@
   @Test
   @Override
   public void byOwnerInvalidQuery() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     String nameEmail = user.asIdentifiedUser().getNameEmail();
 
     BadRequestException thrown =
@@ -76,17 +74,17 @@
 
   @Test
   public void openAndClosedChanges() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
     // create 3 closed changes
-    Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change3 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change2 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change3 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
     // create 3 new changes
-    Change change4 = insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
-    Change change5 = insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
-    Change change6 = insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+    Change change4 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+    Change change5 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+    Change change6 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
 
     // Set queryLimit to 1
     projectOperations
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 540416f..12bafd5 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -401,9 +401,8 @@
     String query = "uuid:" + uuid;
     assertQuery(query, group);
 
-    for (GroupIndex index : groupIndexes.getWriteIndexes()) {
-      index.delete(uuid);
-    }
+    deleteGroup(uuid);
+
     assertQuery(query);
   }
 
@@ -441,6 +440,10 @@
     return createGroupWithDescription(name, null, members);
   }
 
+  protected GroupInfo createGroup(GroupInput in) throws Exception {
+    return gApi.groups().create(in).get();
+  }
+
   protected GroupInfo createGroupWithDescription(
       String name, String description, AccountInfo... members) throws Exception {
     GroupInput in = new GroupInput();
@@ -448,21 +451,27 @@
     in.description = description;
     in.members =
         Arrays.asList(members).stream().map(a -> String.valueOf(a._accountId)).collect(toList());
-    return gApi.groups().create(in).get();
+    return createGroup(in);
   }
 
   protected GroupInfo createGroupWithOwner(String name, GroupInfo ownerGroup) throws Exception {
     GroupInput in = new GroupInput();
     in.name = name;
     in.ownerId = ownerGroup.id;
-    return gApi.groups().create(in).get();
+    return createGroup(in);
   }
 
   protected GroupInfo createGroupThatIsVisibleToAll(String name) throws Exception {
     GroupInput in = new GroupInput();
     in.name = name;
     in.visibleToAll = true;
-    return gApi.groups().create(in).get();
+    return createGroup(in);
+  }
+
+  protected void deleteGroup(AccountGroup.UUID uuid) throws Exception {
+    for (GroupIndex index : groupIndexes.getWriteIndexes()) {
+      index.delete(uuid);
+    }
   }
 
   protected GroupInfo getGroup(AccountGroup.UUID uuid) throws Exception {
diff --git a/modules/jgit b/modules/jgit
index e74f385..a190130 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit e74f3855ad9d54c986d60b0b2ea4c223d52b2cd1
+Subproject commit a1901305b26ed5e0116f138bc02837713d2cf5c3
diff --git a/plugins/BUILD b/plugins/BUILD
index 32efa3e..39560c5 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -6,7 +6,7 @@
     "CORE_PLUGINS",
     "CUSTOM_PLUGINS",
 )
-load("@npm//@bazel/typescript:index.bzl", "ts_config")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -169,3 +169,32 @@
     pkgs = ["com.google.gerrit"],
     title = "Gerrit Review Plugin API Documentation",
 )
+
+# This is a generic test target for TypeScript plugins.
+#
+# `nodejs_test` needs to run in the directory where the `package.json` and
+# `node_modules` are, so unfortunately we cannot move this target into the
+# BUILD files of individual plugins. On the other hand one common target
+# for all plugins also has the advantage of being re-usable.
+#
+# For making this work for a specific plugin you have make the source files
+# of the plugin available as a `filegroup` and add it to the `data` attribute.
+# And you have to specify the `PLUGIN_DIR` in the `env` attribute.
+nodejs_test(
+    name = "web-test-runner",
+    size = "large",
+    chdir = package_name(),
+    data = [
+        ":package.json",
+        ":web-test-runner.config.mjs",
+        # This is an example of how you could reference your plugin sources:
+        # "//plugins/codemirror-editor/web:codemirror-test-sources",
+        "@plugins_npm//:node_modules",
+    ],
+    entry_point = "@plugins_npm//:node_modules/@web/test-runner/dist/bin.js",
+    env = {"PLUGIN_DIR": "codemirror-editor"},
+    tags = [
+        "local",
+        "manual",
+    ],
+)
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 3af12c5..c964b31 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 3af12c5a5e65861830b42bd07933e275c33b9159
+Subproject commit c964b31675de90cfafe43fff9ec357f4b97d1e08
diff --git a/plugins/package.json b/plugins/package.json
index 79bb7665..331a417 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -7,9 +7,14 @@
     "@polymer/decorators": "^3.0.0",
     "@polymer/polymer": "^3.4.1",
     "@open-wc/testing": "^3.1.6",
+    "@types/codemirror": "^5.60.5",
+    "@web/dev-server-esbuild": "^0.3.2",
+    "@web/test-runner": "^0.14.0",
+    "codemirror": "^5.65.6",
     "lit": "^2.2.3",
-    "rxjs": "^6.6.7"
+    "rxjs": "^6.6.7",
+    "sinon": "^13.0.0"
   },
   "license": "Apache-2.0",
   "private": true
-}
+}
\ No newline at end of file
diff --git a/plugins/replication b/plugins/replication
index 47ee3da..8fd3c27 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 47ee3dab0dd96900e85662adf0d5f48a33d17733
+Subproject commit 8fd3c271ce0a21480e3d04da5ad2112efea3bedf
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index ec05cad..368b3e0 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -23,6 +23,11 @@
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
+"@esbuild/linux-loong64@0.14.54":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
+  integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
+
 "@esm-bundle/chai@^4.3.4-fix.0":
   version "4.3.4-fix.0"
   resolved "https://registry.yarnpkg.com/@esm-bundle/chai/-/chai-4.3.4-fix.0.tgz#3084cff7eb46d741749f47f3a48dbbdcbaf30a92"
@@ -40,6 +45,11 @@
   resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.4.1.tgz#3f587eec5708692135bc9e94cf396130604979f3"
   integrity sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw==
 
+"@mdn/browser-compat-data@^4.0.0":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz#1fead437f3957ceebe2e8c3f46beccdb9bc575b8"
+  integrity sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==
+
 "@nodelib/fs.scandir@2.1.5":
   version "2.1.5"
   resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
@@ -132,6 +142,69 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
+"@rollup/plugin-node-resolve@^13.0.4":
+  version "13.3.0"
+  resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.3.0.tgz#da1c5c5ce8316cef96a2f823d111c1e4e498801c"
+  integrity sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw==
+  dependencies:
+    "@rollup/pluginutils" "^3.1.0"
+    "@types/resolve" "1.17.1"
+    deepmerge "^4.2.2"
+    is-builtin-module "^3.1.0"
+    is-module "^1.0.0"
+    resolve "^1.19.0"
+
+"@rollup/pluginutils@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
+  integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
+  dependencies:
+    "@types/estree" "0.0.39"
+    estree-walker "^1.0.1"
+    picomatch "^2.2.2"
+
+"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3":
+  version "1.8.6"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.6.tgz#80c516a4dc264c2a69115e7578d62581ff455ed9"
+  integrity sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==
+  dependencies:
+    type-detect "4.0.8"
+
+"@sinonjs/commons@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3"
+  integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==
+  dependencies:
+    type-detect "4.0.8"
+
+"@sinonjs/fake-timers@^10.0.2":
+  version "10.0.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.0.2.tgz#d10549ed1f423d80639c528b6c7f5a1017747d0c"
+  integrity sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==
+  dependencies:
+    "@sinonjs/commons" "^2.0.0"
+
+"@sinonjs/fake-timers@^9.1.2":
+  version "9.1.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c"
+  integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==
+  dependencies:
+    "@sinonjs/commons" "^1.7.0"
+
+"@sinonjs/samsam@^6.1.1":
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.3.tgz#4e30bcd4700336363302a7d72cbec9b9ab87b104"
+  integrity sha512-nhOb2dWPeb1sd3IQXL/dVPnKHDOAFfvichtBf4xV00/rU1QbPCQqKMbvIheIjqwVjh7qIgf2AHTHi391yMOMpQ==
+  dependencies:
+    "@sinonjs/commons" "^1.6.0"
+    lodash.get "^4.4.2"
+    type-detect "^4.0.8"
+
+"@sinonjs/text-encoding@^0.7.1":
+  version "0.7.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918"
+  integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==
+
 "@types/accepts@*":
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
@@ -172,6 +245,18 @@
     "@types/node" "*"
     "@types/qs" "*"
 
+"@types/codemirror@^5.60.5":
+  version "5.60.5"
+  resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-5.60.5.tgz#5b989a3b4bbe657458cf372c92b6bfda6061a2b7"
+  integrity sha512-TiECZmm8St5YxjFUp64LK0c8WU5bxMDt9YaAek1UqUb9swrSCoJhh92fWu1p3mTEqlHjhB5sY7OFBhWroJXZVg==
+  dependencies:
+    "@types/tern" "*"
+
+"@types/command-line-args@^5.0.0":
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.0.tgz#adbb77980a1cc376bb208e3f4142e907410430f6"
+  integrity sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==
+
 "@types/connect@*":
   version "3.4.35"
   resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
@@ -204,6 +289,16 @@
   resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.1.tgz#79b65710bc8b6d44094d286aecf38e44f9627852"
   integrity sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==
 
+"@types/estree@*":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2"
+  integrity sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==
+
+"@types/estree@0.0.39":
+  version "0.0.39"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
+  integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
+
 "@types/express-serve-static-core@^4.17.18":
   version "4.17.31"
   resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz#a1139efeab4e7323834bb0226e62ac019f474b2f"
@@ -233,7 +328,7 @@
   resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.2.tgz#7315b4c4c54f82d13fa61c228ec5c2ea5cc9e0e1"
   integrity sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==
 
-"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.3":
+"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.3":
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
   integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==
@@ -283,6 +378,11 @@
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
   integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
 
+"@types/mocha@^8.2.0":
+  version "8.2.3"
+  resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323"
+  integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==
+
 "@types/node@*":
   version "18.8.3"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.3.tgz#ce750ab4017effa51aed6a7230651778d54e327c"
@@ -303,6 +403,13 @@
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
   integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
 
+"@types/resolve@1.17.1":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
+  integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==
+  dependencies:
+    "@types/node" "*"
+
 "@types/serve-static@*":
   version "1.15.0"
   resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.0.tgz#c7930ff61afb334e121a9da780aac0d9b8f34155"
@@ -331,6 +438,13 @@
   resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e"
   integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==
 
+"@types/tern@*":
+  version "0.23.4"
+  resolved "https://registry.yarnpkg.com/@types/tern/-/tern-0.23.4.tgz#03926eb13dbeaf3ae0d390caf706b2643a0127fb"
+  integrity sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==
+  dependencies:
+    "@types/estree" "*"
+
 "@types/trusted-types@^2.0.2":
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
@@ -343,14 +457,28 @@
   dependencies:
     "@types/node" "*"
 
-"@web/browser-logs@^0.2.1":
+"@types/yauzl@^2.9.1":
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599"
+  integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==
+  dependencies:
+    "@types/node" "*"
+
+"@web/browser-logs@^0.2.1", "@web/browser-logs@^0.2.2":
   version "0.2.5"
   resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.2.5.tgz#0895efb641eacb0fbc1138c6092bd18c01df2734"
   integrity sha512-Qxo1wY/L7yILQqg0jjAaueh+tzdORXnZtxQgWH23SsTCunz9iq9FvsZa8Q5XlpjnZ3vLIsFEuEsCMqFeohJnEg==
   dependencies:
     errorstacks "^2.2.0"
 
-"@web/dev-server-core@^0.3.18":
+"@web/config-loader@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@web/config-loader/-/config-loader-0.1.3.tgz#8325ea54f75ef2ee7166783e64e66936db25bff7"
+  integrity sha512-XVKH79pk4d3EHRhofete8eAnqto1e8mCRAqPV00KLNFzCWSe8sWmLnqKCqkPNARC6nksMaGrATnA5sPDRllMpQ==
+  dependencies:
+    semver "^7.3.4"
+
+"@web/dev-server-core@^0.3.18", "@web/dev-server-core@^0.3.19":
   version "0.3.19"
   resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.3.19.tgz#b61f9a0b92351371347a758b30ba19e683c72e94"
   integrity sha512-Q/Xt4RMVebLWvALofz1C0KvP8qHbzU1EmdIA2Y1WMPJwiFJFhPxdr75p9YxK32P2t0hGs6aqqS5zE0HW9wYzYA==
@@ -374,6 +502,49 @@
     picomatch "^2.2.2"
     ws "^7.4.2"
 
+"@web/dev-server-esbuild@^0.3.2":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-esbuild/-/dev-server-esbuild-0.3.3.tgz#e82af2e5acec0e645b920400be9601601b3921c5"
+  integrity sha512-hB9C8X9NsFWUG2XKT3W+Xcw3IZ/VObf4LNbK14BTjApnNyZfV6hVhSlJfvhgOoJ4DxsImfhIB5+gMRKOG9NmBw==
+  dependencies:
+    "@mdn/browser-compat-data" "^4.0.0"
+    "@web/dev-server-core" "^0.3.19"
+    esbuild "^0.12 || ^0.13 || ^0.14"
+    parse5 "^6.0.1"
+    ua-parser-js "^1.0.2"
+
+"@web/dev-server-rollup@^0.3.19":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-rollup/-/dev-server-rollup-0.3.19.tgz#188f3a37bcc38f4dc1b208663b14ab2d17321a57"
+  integrity sha512-IwiwI+fyX0YuvAOldStlYJ+Zm/JfSCk9OSGIs7+fWbOYysEHwkEVvBwoPowaclSZA44Tobvqt+6ej9udbbZ/WQ==
+  dependencies:
+    "@rollup/plugin-node-resolve" "^13.0.4"
+    "@web/dev-server-core" "^0.3.19"
+    nanocolors "^0.2.1"
+    parse5 "^6.0.1"
+    rollup "^2.67.0"
+    whatwg-url "^11.0.0"
+
+"@web/dev-server@^0.1.35":
+  version "0.1.35"
+  resolved "https://registry.yarnpkg.com/@web/dev-server/-/dev-server-0.1.35.tgz#d845822d7c3c7749adf03f7abac4a69e2a4490cc"
+  integrity sha512-E7TSTSFdGPzhkiE3kIVt8i49gsiAYpJIZHzs1vJmVfdt8U4rsmhE+5roezxZo0hkEw4mNsqj9zCc4Dzqy/IFHg==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/command-line-args" "^5.0.0"
+    "@web/config-loader" "^0.1.3"
+    "@web/dev-server-core" "^0.3.19"
+    "@web/dev-server-rollup" "^0.3.19"
+    camelcase "^6.2.0"
+    command-line-args "^5.1.1"
+    command-line-usage "^6.1.1"
+    debounce "^1.2.0"
+    deepmerge "^4.2.2"
+    ip "^1.1.5"
+    nanocolors "^0.2.1"
+    open "^8.0.2"
+    portfinder "^1.0.32"
+
 "@web/parse5-utils@^1.2.0":
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-1.3.0.tgz#e2e9e98b31a4ca948309f74891bda8d77399f6bd"
@@ -382,7 +553,17 @@
     "@types/parse5" "^6.0.1"
     parse5 "^6.0.1"
 
-"@web/test-runner-commands@^0.6.1":
+"@web/test-runner-chrome@^0.10.7":
+  version "0.10.7"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-chrome/-/test-runner-chrome-0.10.7.tgz#2dc35da47aa8b98c59f9e229a70ea3f443303e0c"
+  integrity sha512-DKJVHhHh3e/b6/erfKOy0a4kGfZ47qMoQRgROxi9T4F9lavEY3E5/MQ7hapHFM2lBF4vDrm+EWjtBdOL8o42tw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    "@web/test-runner-coverage-v8" "^0.4.8"
+    chrome-launcher "^0.15.0"
+    puppeteer-core "^13.1.3"
+
+"@web/test-runner-commands@^0.6.1", "@web/test-runner-commands@^0.6.3":
   version "0.6.5"
   resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.6.5.tgz#69a2a06b52fd9d329f9cf1e172cd8fb1d5ffc521"
   integrity sha512-W+wLg10jEAJY9N6tNWqG1daKmAzxGmTbO/H9fFfcgOgdxdn+hHiR4r2/x1iylKbFLujHUQlnjNQeu2d6eDPFqg==
@@ -390,7 +571,7 @@
     "@web/test-runner-core" "^0.10.27"
     mkdirp "^1.0.4"
 
-"@web/test-runner-core@^0.10.27":
+"@web/test-runner-core@^0.10.20", "@web/test-runner-core@^0.10.27":
   version "0.10.27"
   resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.27.tgz#8d1430f2364fb36b3ac15b9b43034fae9d94e177"
   integrity sha512-ClV/hSxs4wDm/ANFfQOdRRFb/c0sYywC1QfUXG/nS4vTp3nnt7x7mjydtMGGLmvK9f6Zkubkc1aa+7ryfmVwNA==
@@ -422,6 +603,46 @@
     picomatch "^2.2.2"
     source-map "^0.7.3"
 
+"@web/test-runner-coverage-v8@^0.4.8":
+  version "0.4.9"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.4.9.tgz#334d80cd19fc68c08ec3339b1b1d2725078b51a2"
+  integrity sha512-y9LWL4uY25+fKQTljwr0XTYjeWIwU4h8eYidVuLoW3n1CdFkaddv+smrGzzF5j8XY+Mp6TmV9NdxjvMWqVkDdw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    istanbul-lib-coverage "^3.0.0"
+    picomatch "^2.2.2"
+    v8-to-istanbul "^8.0.0"
+
+"@web/test-runner-mocha@^0.7.5":
+  version "0.7.5"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-mocha/-/test-runner-mocha-0.7.5.tgz#696f8cb7f5118a72bd7ac5778367ae3bd3fb92cd"
+  integrity sha512-12/OBq6efPCAvJpcz3XJs2OO5nHe7GtBibZ8Il1a0QtsGpRmuJ4/m1EF0Fj9f6KHg7JdpGo18A37oE+5hXjHwg==
+  dependencies:
+    "@types/mocha" "^8.2.0"
+    "@web/test-runner-core" "^0.10.20"
+
+"@web/test-runner@^0.14.0":
+  version "0.14.1"
+  resolved "https://registry.yarnpkg.com/@web/test-runner/-/test-runner-0.14.1.tgz#a637e45c9b6ce7860ab780b5ac82dbfa1ed824f9"
+  integrity sha512-S2/Xp/bZBJdbWeTQxhs45cO9Khwqx99X+rrx8l0uDR0Ju/+kX+yC3RpjnOY1ooKD3rjkoEAE82soZTZNz+aKIg==
+  dependencies:
+    "@web/browser-logs" "^0.2.2"
+    "@web/config-loader" "^0.1.3"
+    "@web/dev-server" "^0.1.35"
+    "@web/test-runner-chrome" "^0.10.7"
+    "@web/test-runner-commands" "^0.6.3"
+    "@web/test-runner-core" "^0.10.27"
+    "@web/test-runner-mocha" "^0.7.5"
+    camelcase "^6.2.0"
+    command-line-args "^5.1.1"
+    command-line-usage "^6.1.1"
+    convert-source-map "^1.7.0"
+    diff "^5.0.0"
+    globby "^11.0.1"
+    nanocolors "^0.2.1"
+    portfinder "^1.0.32"
+    source-map "^0.7.3"
+
 "@webcomponents/shadycss@^1.9.1":
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
@@ -435,6 +656,13 @@
     mime-types "~2.1.34"
     negotiator "0.6.3"
 
+agent-base@6:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
+  integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
+  dependencies:
+    debug "4"
+
 ansi-escapes@^4.3.0:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
@@ -469,6 +697,16 @@
     normalize-path "^3.0.0"
     picomatch "^2.0.4"
 
+array-back@^3.0.1, array-back@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
+  integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
+
+array-back@^4.0.1, array-back@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e"
+  integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==
+
 array-union@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
@@ -479,16 +717,50 @@
   resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
   integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
 
+async@^2.6.4:
+  version "2.6.4"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
+  integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
+  dependencies:
+    lodash "^4.17.14"
+
 axe-core@^4.3.3:
   version "4.4.3"
   resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.3.tgz#11c74d23d5013c0fa5d183796729bc3482bd2f6f"
   integrity sha512-32+ub6kkdhhWick/UjvEwRchgoetXqTK14INLqbGm5U2TzBkBNF3nQtLYm8ovxSkQWArjEQvftCKryjZaATu3w==
 
+balanced-match@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+
+base64-js@^1.3.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
+  integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
+
 binary-extensions@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
   integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
 
+bl@^4.0.3:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
+  integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
+  dependencies:
+    buffer "^5.5.0"
+    inherits "^2.0.4"
+    readable-stream "^3.4.0"
+
+brace-expansion@^1.1.7:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+  dependencies:
+    balanced-match "^1.0.0"
+    concat-map "0.0.1"
+
 braces@^3.0.2, braces@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
@@ -496,6 +768,24 @@
   dependencies:
     fill-range "^7.0.1"
 
+buffer-crc32@~0.2.3:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+  integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
+
+buffer@^5.2.1, buffer@^5.5.0:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
+  integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
+  dependencies:
+    base64-js "^1.3.1"
+    ieee754 "^1.1.13"
+
+builtin-modules@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
+  integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
+
 bytes@3.1.2:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
@@ -517,6 +807,11 @@
     function-bind "^1.1.1"
     get-intrinsic "^1.0.2"
 
+camelcase@^6.2.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
+  integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
+
 chai-a11y-axe@^1.3.2:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.4.0.tgz#e584af967727a8656e27c32e845f5db21f2bf2e0"
@@ -524,7 +819,7 @@
   dependencies:
     axe-core "^4.3.3"
 
-chalk@^2.0.0:
+chalk@^2.0.0, chalk@^2.4.2:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
@@ -548,6 +843,21 @@
   optionalDependencies:
     fsevents "~2.3.2"
 
+chownr@^1.1.1:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
+  integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
+
+chrome-launcher@^0.15.0:
+  version "0.15.1"
+  resolved "https://registry.yarnpkg.com/chrome-launcher/-/chrome-launcher-0.15.1.tgz#0a0208037063641e2b3613b7e42b0fcb3fa2d399"
+  integrity sha512-UugC8u59/w2AyX5sHLZUHoxBAiSiunUhZa3zZwMH6zPVis0C3dDKiRWyUGIo14tTbZHGVviWxv3PQWZ7taZ4fg==
+  dependencies:
+    "@types/node" "*"
+    escape-string-regexp "^4.0.0"
+    is-wsl "^2.2.0"
+    lighthouse-logger "^1.0.0"
+
 cli-cursor@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
@@ -575,6 +885,11 @@
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
   integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==
 
+codemirror@^5.65.6:
+  version "5.65.10"
+  resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.10.tgz#4276a93b8534ce91f14b733ba9a1ac949666eac9"
+  integrity sha512-IXAG5wlhbgcTJ6rZZcmi4+sjWIbJqIGfeg3tNa3yX84Jb3T4huS5qzQAo/cUisc1l3bI47WZodpyf7cYcocDKg==
+
 color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@@ -599,6 +914,31 @@
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
+command-line-args@^5.1.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
+  integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
+  dependencies:
+    array-back "^3.1.0"
+    find-replace "^3.0.0"
+    lodash.camelcase "^4.3.0"
+    typical "^4.0.0"
+
+command-line-usage@^6.1.1:
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.3.tgz#428fa5acde6a838779dfa30e44686f4b6761d957"
+  integrity sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==
+  dependencies:
+    array-back "^4.0.2"
+    chalk "^2.4.2"
+    table-layout "^1.0.2"
+    typical "^5.2.0"
+
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
+
 content-disposition@~0.5.2:
   version "0.5.4"
   resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
@@ -611,6 +951,11 @@
   resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
   integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
 
+convert-source-map@^1.6.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
+  integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
+
 convert-source-map@^1.7.0:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
@@ -626,30 +971,54 @@
     depd "~2.0.0"
     keygrip "~1.1.0"
 
+cross-fetch@3.1.5:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"
+  integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==
+  dependencies:
+    node-fetch "2.6.7"
+
 debounce@^1.2.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
   integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
 
-debug@^3.1.0:
-  version "3.2.7"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
-  integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
-  dependencies:
-    ms "^2.1.1"
-
-debug@^4.1.1, debug@^4.3.2:
+debug@4, debug@4.3.4, debug@^4.1.1, debug@^4.3.2:
   version "4.3.4"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
   integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
   dependencies:
     ms "2.1.2"
 
+debug@^2.6.9:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+  integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
+  dependencies:
+    ms "2.0.0"
+
+debug@^3.1.0, debug@^3.2.7:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
+  integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
+  dependencies:
+    ms "^2.1.1"
+
 deep-equal@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
   integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==
 
+deep-extend@~0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
+
+deepmerge@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
+  integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
+
 define-lazy-prop@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
@@ -680,6 +1049,16 @@
   resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
   integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
 
+devtools-protocol@0.0.981744:
+  version "0.0.981744"
+  resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf"
+  integrity sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg==
+
+diff@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
+  integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
+
 dir-glob@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@@ -702,6 +1081,13 @@
   resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
   integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
 
+end-of-stream@^1.1.0, end-of-stream@^1.4.1:
+  version "1.4.4"
+  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
+  integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
+  dependencies:
+    once "^1.4.0"
+
 errorstacks@^2.2.0:
   version "2.4.0"
   resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.4.0.tgz#2155674dd9e741aacda3f3b8b967d9c40a4a0baf"
@@ -712,6 +1098,133 @@
   resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.0.3.tgz#f0d8d35b36d13024110000d5e6fadc8eeaeb66b8"
   integrity sha512-iC67eXHToclrlVhQfpRawDiF8D8sQxNxmbqw5oebegOaJkyx/w9C/k57/5e6yJR2zIByRt9OXdqX50DV2t6ZKw==
 
+esbuild-android-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
+  integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
+
+esbuild-android-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
+  integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
+
+esbuild-darwin-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
+  integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
+
+esbuild-darwin-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
+  integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
+
+esbuild-freebsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
+  integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
+
+esbuild-freebsd-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
+  integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
+
+esbuild-linux-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
+  integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
+
+esbuild-linux-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652"
+  integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
+
+esbuild-linux-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
+  integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
+
+esbuild-linux-arm@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
+  integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
+
+esbuild-linux-mips64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
+  integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
+
+esbuild-linux-ppc64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
+  integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
+
+esbuild-linux-riscv64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
+  integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
+
+esbuild-linux-s390x@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
+  integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
+
+esbuild-netbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
+  integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
+
+esbuild-openbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
+  integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
+
+esbuild-sunos-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
+  integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
+
+esbuild-windows-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
+  integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
+
+esbuild-windows-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
+  integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
+
+esbuild-windows-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
+  integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
+
+"esbuild@^0.12 || ^0.13 || ^0.14":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2"
+  integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==
+  optionalDependencies:
+    "@esbuild/linux-loong64" "0.14.54"
+    esbuild-android-64 "0.14.54"
+    esbuild-android-arm64 "0.14.54"
+    esbuild-darwin-64 "0.14.54"
+    esbuild-darwin-arm64 "0.14.54"
+    esbuild-freebsd-64 "0.14.54"
+    esbuild-freebsd-arm64 "0.14.54"
+    esbuild-linux-32 "0.14.54"
+    esbuild-linux-64 "0.14.54"
+    esbuild-linux-arm "0.14.54"
+    esbuild-linux-arm64 "0.14.54"
+    esbuild-linux-mips64le "0.14.54"
+    esbuild-linux-ppc64le "0.14.54"
+    esbuild-linux-riscv64 "0.14.54"
+    esbuild-linux-s390x "0.14.54"
+    esbuild-netbsd-64 "0.14.54"
+    esbuild-openbsd-64 "0.14.54"
+    esbuild-sunos-64 "0.14.54"
+    esbuild-windows-32 "0.14.54"
+    esbuild-windows-64 "0.14.54"
+    esbuild-windows-arm64 "0.14.54"
+
 escape-html@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
@@ -722,11 +1235,32 @@
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
   integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
 
+escape-string-regexp@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
+  integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
+
+estree-walker@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
+  integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==
+
 etag@^1.8.1:
   version "1.8.1"
   resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
   integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
 
+extract-zip@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
+  integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==
+  dependencies:
+    debug "^4.1.1"
+    get-stream "^5.1.0"
+    yauzl "^2.10.0"
+  optionalDependencies:
+    "@types/yauzl" "^2.9.1"
+
 fast-glob@^3.2.9:
   version "3.2.12"
   resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
@@ -745,6 +1279,13 @@
   dependencies:
     reusify "^1.0.4"
 
+fd-slicer@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
+  integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==
+  dependencies:
+    pend "~1.2.0"
+
 fill-range@^7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
@@ -752,11 +1293,36 @@
   dependencies:
     to-regex-range "^5.0.1"
 
+find-replace@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
+  integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==
+  dependencies:
+    array-back "^3.0.1"
+
+find-up@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+  dependencies:
+    locate-path "^5.0.0"
+    path-exists "^4.0.0"
+
 fresh@~0.5.2:
   version "0.5.2"
   resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
   integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
 
+fs-constants@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
+  integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+
+fs.realpath@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
+
 fsevents@~2.3.2:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
@@ -776,6 +1342,13 @@
     has "^1.0.3"
     has-symbols "^1.0.3"
 
+get-stream@^5.1.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
+  integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==
+  dependencies:
+    pump "^3.0.0"
+
 get-stream@^6.0.0:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
@@ -788,6 +1361,18 @@
   dependencies:
     is-glob "^4.0.1"
 
+glob@^7.1.3:
+  version "7.2.3"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
+  integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.1.1"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
 globby@^11.0.1:
   version "11.1.0"
   resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
@@ -874,6 +1459,14 @@
     setprototypeof "1.1.0"
     statuses ">= 1.4.0 < 2"
 
+https-proxy-agent@5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
+  integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
+  dependencies:
+    agent-base "6"
+    debug "4"
+
 iconv-lite@0.4.24:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -881,6 +1474,11 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
+ieee754@^1.1.13:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
+  integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
+
 ignore@^5.2.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
@@ -891,16 +1489,24 @@
   resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f"
   integrity sha512-m3xv4hJYR2oXw4o4Y5l6P5P16WYmazYof+el6Al3f+YlggGj6qT9kImBAnzDelRALnP5d3h4jGBPKzYCizjZZw==
 
+inflight@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
+  dependencies:
+    once "^1.3.0"
+    wrappy "1"
+
+inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
 inherits@2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
   integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==
 
-inherits@2.0.4:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
-  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
-
 ip@^1.1.5:
   version "1.1.8"
   resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
@@ -913,6 +1519,20 @@
   dependencies:
     binary-extensions "^2.0.0"
 
+is-builtin-module@^3.1.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.0.tgz#bb0310dfe881f144ca83f30100ceb10cf58835e0"
+  integrity sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw==
+  dependencies:
+    builtin-modules "^3.3.0"
+
+is-core-module@^2.9.0:
+  version "2.11.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144"
+  integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==
+  dependencies:
+    has "^1.0.3"
+
 is-docker@^2.0.0, is-docker@^2.1.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
@@ -942,6 +1562,11 @@
   dependencies:
     is-extglob "^2.1.1"
 
+is-module@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
+  integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
+
 is-number@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
@@ -959,6 +1584,11 @@
   dependencies:
     is-docker "^2.0.0"
 
+isarray@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
+  integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==
+
 isbinaryfile@^4.0.6:
   version "4.0.10"
   resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3"
@@ -991,6 +1621,11 @@
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
   integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
 
+just-extend@^4.0.2:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744"
+  integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==
+
 keygrip@~1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
@@ -1064,6 +1699,14 @@
     type-is "^1.6.16"
     vary "^1.1.2"
 
+lighthouse-logger@^1.0.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.3.0.tgz#ba6303e739307c4eee18f08249524e7dafd510db"
+  integrity sha512-BbqAKApLb9ywUli+0a+PcV04SyJ/N1q/8qgCNe6U97KbPCS1BTksEuHFLYdvc8DltuhfxIUBqDZsC0bBGtl3lA==
+  dependencies:
+    debug "^2.6.9"
+    marky "^1.2.2"
+
 lit-element@^3.2.0:
   version "3.2.2"
   resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.2.tgz#d148ab6bf4c53a33f707a5168e087725499e5f2b"
@@ -1088,6 +1731,28 @@
     lit-element "^3.2.0"
     lit-html "^2.4.0"
 
+locate-path@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
+  integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
+  dependencies:
+    p-locate "^4.1.0"
+
+lodash.camelcase@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
+  integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
+
+lodash.get@^4.4.2:
+  version "4.4.2"
+  resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
+  integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==
+
+lodash@^4.17.14:
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+  integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
+
 log-update@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1"
@@ -1112,6 +1777,11 @@
   dependencies:
     semver "^6.0.0"
 
+marky@^1.2.2:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0"
+  integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==
+
 media-typer@0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -1147,11 +1817,40 @@
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
   integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
 
+minimatch@^3.1.1:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+  integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+minimist@^1.2.6:
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
+  integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
+
+mkdirp-classic@^0.5.2:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
+  integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
+
+mkdirp@^0.5.6:
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+  integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
+  dependencies:
+    minimist "^1.2.6"
+
 mkdirp@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
   integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
 
+ms@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+  integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
+
 ms@2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
@@ -1177,6 +1876,24 @@
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
   integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
 
+nise@^5.1.1:
+  version "5.1.4"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.4.tgz#491ce7e7307d4ec546f5a659b2efe94a18b4bbc0"
+  integrity sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==
+  dependencies:
+    "@sinonjs/commons" "^2.0.0"
+    "@sinonjs/fake-timers" "^10.0.2"
+    "@sinonjs/text-encoding" "^0.7.1"
+    just-extend "^4.0.2"
+    path-to-regexp "^1.7.0"
+
+node-fetch@2.6.7:
+  version "2.6.7"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
+  integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
+  dependencies:
+    whatwg-url "^5.0.0"
+
 normalize-path@^3.0.0, normalize-path@~3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
@@ -1194,6 +1911,13 @@
   dependencies:
     ee-first "1.1.1"
 
+once@^1.3.0, once@^1.3.1, once@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+  integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
+  dependencies:
+    wrappy "1"
+
 onetime@^5.1.0:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
@@ -1215,6 +1939,25 @@
     is-docker "^2.1.1"
     is-wsl "^2.2.0"
 
+p-limit@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
+  integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
+  dependencies:
+    p-try "^2.0.0"
+
+p-locate@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
+  integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
+  dependencies:
+    p-limit "^2.2.0"
+
+p-try@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
+  integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
+
 parse5@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
@@ -1225,21 +1968,100 @@
   resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
   integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
 
-path-is-absolute@1.0.1:
+path-exists@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+  integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
+
+path-is-absolute@1.0.1, path-is-absolute@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
 
+path-parse@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
+  integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
+
+path-to-regexp@^1.7.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
+  integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
+  dependencies:
+    isarray "0.0.1"
+
 path-type@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
   integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
 
+pend@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+  integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
+
 picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1:
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
   integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
 
+pkg-dir@4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
+  integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==
+  dependencies:
+    find-up "^4.0.0"
+
+portfinder@^1.0.32:
+  version "1.0.32"
+  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.32.tgz#2fe1b9e58389712429dc2bea5beb2146146c7f81"
+  integrity sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==
+  dependencies:
+    async "^2.6.4"
+    debug "^3.2.7"
+    mkdirp "^0.5.6"
+
+progress@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
+  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+
+proxy-from-env@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
+  integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
+
+pump@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
+  integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
+punycode@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
+  integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
+
+puppeteer-core@^13.1.3:
+  version "13.7.0"
+  resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-13.7.0.tgz#3344bee3994163f49120a55ddcd144a40575ba5b"
+  integrity sha512-rXja4vcnAzFAP1OVLq/5dWNfwBGuzcOARJ6qGV7oAZhnLmVRU8G5MsdeQEAOy332ZhkIOnn9jp15R89LKHyp2Q==
+  dependencies:
+    cross-fetch "3.1.5"
+    debug "4.3.4"
+    devtools-protocol "0.0.981744"
+    extract-zip "2.0.1"
+    https-proxy-agent "5.0.1"
+    pkg-dir "4.2.0"
+    progress "2.0.3"
+    proxy-from-env "1.1.0"
+    rimraf "3.0.2"
+    tar-fs "2.1.1"
+    unbzip2-stream "1.4.3"
+    ws "8.5.0"
+
 qs@^6.5.2:
   version "6.11.0"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
@@ -1262,6 +2084,15 @@
     iconv-lite "0.4.24"
     unpipe "1.0.0"
 
+readable-stream@^3.1.1, readable-stream@^3.4.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
+  integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
+  dependencies:
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
+
 readdirp@~3.6.0:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
@@ -1269,6 +2100,11 @@
   dependencies:
     picomatch "^2.2.1"
 
+reduce-flatten@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
+  integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==
+
 resolve-path@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7"
@@ -1277,6 +2113,15 @@
     http-errors "~1.6.2"
     path-is-absolute "1.0.1"
 
+resolve@^1.19.0:
+  version "1.22.1"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
+  integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
+  dependencies:
+    is-core-module "^2.9.0"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
 restore-cursor@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
@@ -1290,6 +2135,20 @@
   resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
   integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
 
+rimraf@3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
+  dependencies:
+    glob "^7.1.3"
+
+rollup@^2.67.0:
+  version "2.79.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
+  integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==
+  optionalDependencies:
+    fsevents "~2.3.2"
+
 run-parallel@^1.1.9:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
@@ -1304,7 +2163,7 @@
   dependencies:
     tslib "^1.9.0"
 
-safe-buffer@5.2.1:
+safe-buffer@5.2.1, safe-buffer@~5.2.0:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -1324,6 +2183,13 @@
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
+semver@^7.3.4:
+  version "7.3.8"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
+  integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
+  dependencies:
+    lru-cache "^6.0.0"
+
 setprototypeof@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
@@ -1348,6 +2214,18 @@
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
   integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
 
+sinon@^13.0.0:
+  version "13.0.2"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-13.0.2.tgz#c6a8ddd655dc1415bbdc5ebf0e5b287806850c3a"
+  integrity sha512-KvOrztAVqzSJWMDoxM4vM+GPys1df2VBoXm+YciyB/OLMamfS3VXh3oGh5WtrAGSzrgczNWFFY22oKb7Fi5eeA==
+  dependencies:
+    "@sinonjs/commons" "^1.8.3"
+    "@sinonjs/fake-timers" "^9.1.2"
+    "@sinonjs/samsam" "^6.1.1"
+    diff "^5.0.0"
+    nise "^5.1.1"
+    supports-color "^7.2.0"
+
 slash@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
@@ -1386,6 +2264,13 @@
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.1"
 
+string_decoder@^1.1.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
+  integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+  dependencies:
+    safe-buffer "~5.2.0"
+
 strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
@@ -1400,13 +2285,54 @@
   dependencies:
     has-flag "^3.0.0"
 
-supports-color@^7.1.0:
+supports-color@^7.1.0, supports-color@^7.2.0:
   version "7.2.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
   integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
   dependencies:
     has-flag "^4.0.0"
 
+supports-preserve-symlinks-flag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+  integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
+table-layout@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.2.tgz#c4038a1853b0136d63365a734b6931cf4fad4a04"
+  integrity sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==
+  dependencies:
+    array-back "^4.0.1"
+    deep-extend "~0.6.0"
+    typical "^5.2.0"
+    wordwrapjs "^4.0.0"
+
+tar-fs@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784"
+  integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==
+  dependencies:
+    chownr "^1.1.1"
+    mkdirp-classic "^0.5.2"
+    pump "^3.0.0"
+    tar-stream "^2.1.4"
+
+tar-stream@^2.1.4:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
+  integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
+  dependencies:
+    bl "^4.0.3"
+    end-of-stream "^1.4.1"
+    fs-constants "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^3.1.1"
+
+through@^2.3.8:
+  version "2.3.8"
+  resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+  integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
+
 to-regex-range@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
@@ -1419,6 +2345,18 @@
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
   integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
 
+tr46@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9"
+  integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==
+  dependencies:
+    punycode "^2.1.1"
+
+tr46@~0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
+  integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
+
 tslib@^1.9.0:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
@@ -1429,6 +2367,11 @@
   resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
   integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
 
+type-detect@4.0.8, type-detect@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
+  integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
+
 type-fest@^0.21.3:
   version "0.21.3"
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
@@ -1442,16 +2385,87 @@
     media-typer "0.3.0"
     mime-types "~2.1.24"
 
+typical@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
+  integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
+
+typical@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066"
+  integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
+
+ua-parser-js@^1.0.2:
+  version "1.0.32"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.32.tgz#786bf17df97de159d5b1c9d5e8e9e89806f8a030"
+  integrity sha512-dXVsz3M4j+5tTiovFVyVqssXBu5HM47//YSOeZ9fQkdDKkfzv2v3PP1jmH6FUyPW+yCSn7aBVK1fGGKNhowdDA==
+
+unbzip2-stream@1.4.3:
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7"
+  integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==
+  dependencies:
+    buffer "^5.2.1"
+    through "^2.3.8"
+
 unpipe@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
   integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
 
+util-deprecate@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+
+v8-to-istanbul@^8.0.0:
+  version "8.1.1"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz#77b752fd3975e31bbcef938f85e9bd1c7a8d60ed"
+  integrity sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==
+  dependencies:
+    "@types/istanbul-lib-coverage" "^2.0.1"
+    convert-source-map "^1.6.0"
+    source-map "^0.7.3"
+
 vary@^1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
   integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
 
+webidl-conversions@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
+  integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
+
+webidl-conversions@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
+  integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
+
+whatwg-url@^11.0.0:
+  version "11.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018"
+  integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==
+  dependencies:
+    tr46 "^3.0.0"
+    webidl-conversions "^7.0.0"
+
+whatwg-url@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
+  integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
+  dependencies:
+    tr46 "~0.0.3"
+    webidl-conversions "^3.0.0"
+
+wordwrapjs@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.1.tgz#d9790bccfb110a0fc7836b5ebce0937b37a8b98f"
+  integrity sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==
+  dependencies:
+    reduce-flatten "^2.0.0"
+    typical "^5.2.0"
+
 wrap-ansi@^6.2.0:
   version "6.2.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
@@ -1461,6 +2475,16 @@
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
+wrappy@1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+  integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
+
+ws@8.5.0:
+  version "8.5.0"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f"
+  integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==
+
 ws@^7.4.2:
   version "7.5.9"
   resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
@@ -1471,6 +2495,14 @@
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
   integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
 
+yauzl@^2.10.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
+  integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==
+  dependencies:
+    buffer-crc32 "~0.2.3"
+    fd-slicer "~1.1.0"
+
 ylru@^1.2.0:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785"
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index ce9ce2d..e3b3ad4 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -201,7 +201,7 @@
   syntax_highlighting?: boolean;
   tab_size: number;
   font_size: number;
-  // TODO: Missing documentation
+  // Hides the FILE and LOST diff rows. Default is TRUE.
   show_file_comment_button?: boolean;
   line_wrapping?: boolean;
 }
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 3ed48e6..d3d012d 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -78,8 +78,6 @@
     moduleName?: string,
     options?: RegisterOptions
   ): HookApi<T>;
-  // DEPRECATED: Use styleApi() instead.
-  registerStyleModule(endpoint: string, moduleName: string): void;
   reporting(): ReportingPluginApi;
   restApi(): RestPluginApi;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 89c7622..f915432 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -319,6 +319,4 @@
 
 export const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
 
-export const SHOWN_ITEMS_COUNT = 25;
-
 export const WAITING = 'Waiting';
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 c472d4d..893a997 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
@@ -5,19 +5,21 @@
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-group-dialog/gr-create-group-dialog';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
 import {fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, query, property, state} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
-import {AdminViewState} from '../../../models/views/admin';
+import {
+  AdminChildView,
+  AdminViewState,
+  createAdminUrl,
+} from '../../../models/views/admin';
 import {createGroupUrl} from '../../../models/views/group';
 import {whenVisible} from '../../../utils/dom-util';
 import {modalStyles} from '../../../styles/gr-modal-styles';
@@ -30,8 +32,6 @@
 
 @customElement('gr-admin-group-list')
 export class GrAdminGroupList extends LitElement {
-  readonly path = '/admin/groups';
-
   @query('#createModal') private createModal?: HTMLDialogElement;
 
   @query('#createNewModal') private createNewModal?: GrCreateGroupDialog;
@@ -42,21 +42,19 @@
   /**
    * Offset of currently visible query results.
    */
-  @state() private offset = 0;
+  @state() offset = 0;
 
-  @state() private hasNewGroupName = false;
+  @state() hasNewGroupName = false;
 
-  @state() private createNewCapability = false;
+  @state() createNewCapability = false;
 
-  // private but used in test
   @state() groups: GroupInfo[] = [];
 
-  @state() private groupsPerPage = 25;
+  @state() groupsPerPage = 25;
 
-  // private but used in test
   @state() loading = true;
 
-  @state() private filter = '';
+  @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -88,7 +86,7 @@
         .itemsPerPage=${this.groupsPerPage}
         .loading=${this.loading}
         .offset=${this.offset}
-        .path=${this.path}
+        .path=${createAdminUrl({adminView: AdminChildView.GROUPS})}
         @create-clicked=${() => this.handleCreateClicked()}
       >
         <table id="list" class="genericList">
@@ -107,7 +105,7 @@
           </tbody>
           <tbody class=${this.loading ? 'loading' : ''}>
             ${this.groups
-              .slice(0, SHOWN_ITEMS_COUNT)
+              .slice(0, this.groupsPerPage)
               .map(group => this.renderGroupList(group))}
           </tbody>
         </table>
@@ -168,18 +166,14 @@
    *
    * private but used in test
    */
-  maybeOpenCreateModal(params?: AdminViewState) {
+  async maybeOpenCreateModal(params?: AdminViewState) {
     if (params?.openCreateModal) {
-      assertIsDefined(this.createModal, 'createModal');
-      this.createModal.showModal();
+      await this.updateComplete;
+      if (!this.createModal?.open) this.createModal?.showModal();
     }
   }
 
-  /**
-   * Generates groups link (/admin/groups/<uuid>)
-   *
-   * private but used in test
-   */
+  // private but used in test
   computeGroupUrl(encodedId: string) {
     const groupId = decodeURIComponent(encodedId) as GroupId;
     return createGroupUrl({groupId});
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
index 1b128aa..fe5aa22 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
@@ -15,7 +15,6 @@
 import {GerritView} from '../../../services/router/router-model';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
 import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
@@ -117,20 +116,22 @@
     });
 
     test('groups', () => {
-      assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.groupsPerPage);
     });
 
-    test('maybeOpenCreateModal', () => {
+    test('maybeOpenCreateModal', async () => {
       const modalOpen = sinon.stub(
         queryAndAssert<HTMLDialogElement>(element, '#createModal'),
         'showModal'
       );
-      element.maybeOpenCreateModal();
+      await element.maybeOpenCreateModal();
       assert.isFalse(modalOpen.called);
-      element.maybeOpenCreateModal(undefined);
+      await element.maybeOpenCreateModal(undefined);
       assert.isFalse(modalOpen.called);
       value.openCreateModal = true;
-      element.maybeOpenCreateModal(value);
+      await element.maybeOpenCreateModal(value);
       assert.isTrue(modalOpen.called);
     });
   });
@@ -145,7 +146,9 @@
     });
 
     test('groups', () => {
-      assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.groupsPerPage);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts
index c3f79c7..0cfbaa4 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts
@@ -14,7 +14,7 @@
 import {LitElement, html, nothing} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {resolve} from '../../../models/dependency';
-import {createEditUrl} from '../../../models/views/edit';
+import {createEditUrl} from '../../../models/views/change';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {assertIsDefined} from '../../../utils/common-util';
 import {when} from 'lit/directives/when.js';
@@ -162,8 +162,8 @@
     const url = createEditUrl({
       changeNum: change._number,
       repo: change.project,
-      path: this.path,
       patchNum: 1 as PatchSetNumber,
+      editView: {path: this.path},
     });
     this.getNavigation().setUrl(url);
   }
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 4808d00..8d5689c 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
@@ -6,9 +6,8 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
-import {GroupName} from '../../../types/common';
+import {GroupId, GroupName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -16,6 +15,7 @@
 import {customElement, query, property} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {fireEvent} from '../../../utils/event-util';
+import {createGroupUrl} from '../../../models/views/group';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -75,10 +75,6 @@
     fireEvent(this, 'has-new-group-name');
   }
 
-  private computeGroupUrl(groupId: string) {
-    return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
-  }
-
   override focus() {
     this.input.focus();
   }
@@ -89,7 +85,9 @@
       if (groupRegistered.status !== 201) return;
       return this.restApiService.getGroupConfig(name).then(group => {
         if (!group) return;
-        page.show(this.computeGroupUrl(String(group.group_id!)));
+        const groupId = String(group.group_id!) as GroupId;
+        // TODO: Use navigation service instead of `page.show()` directly.
+        page.show(createGroupUrl({groupId}));
       });
     });
   }
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 a90abbd..bb59ccc 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
@@ -7,7 +7,6 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 import {
   BranchName,
@@ -24,6 +23,7 @@
 import {customElement, query, property, state} from 'lit/decorators.js';
 import {fireEvent} from '../../../utils/event-util';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {createRepoUrl} from '../../../models/views/repo';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -183,10 +183,6 @@
     `;
   }
 
-  _computeRepoUrl(repoName: string) {
-    return getBaseUrl() + '/admin/repos/' + encodeURL(repoName, true);
-  }
-
   override focus() {
     this.input?.focus();
   }
@@ -199,7 +195,8 @@
     );
     if (repoRegistered.status === 201) {
       this.repoCreated = true;
-      page.show(this._computeRepoUrl(this.repoConfig.name));
+      // TODO: Use navigation service instead of `page.show()` directly.
+      page.show(createRepoUrl({repo: this.repoConfig.name}));
     }
     return repoRegistered;
   }
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 383b4a7..944cd7a 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
@@ -9,7 +9,6 @@
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
@@ -34,17 +33,15 @@
   /**
    * Offset of currently visible query results.
    */
-  @state() private offset = 0;
+  @state() offset = 0;
 
-  // private but used in test
   @state() plugins?: PluginInfoWithName[];
 
-  @state() private pluginsPerPage = 25;
+  @state() pluginsPerPage = 25;
 
-  // private but used in test
   @state() loading = true;
 
-  @state() private filter = '';
+  @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -107,7 +104,7 @@
     return html`
       <tbody>
         ${this.plugins
-          ?.slice(0, SHOWN_ITEMS_COUNT)
+          ?.slice(0, this.pluginsPerPage)
           .map(plugin => this.renderPluginList(plugin))}
       </tbody>
     `;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
index 4057e52..34c88be 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
@@ -17,7 +17,6 @@
 import {PluginInfo} from '../../../types/common';
 import {GerritView} from '../../../services/router/router-model';
 import {PageErrorEvent} from '../../../types/events';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
 import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
@@ -334,7 +333,9 @@
     });
 
     test('plugins', () => {
-      assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.pluginsPerPage);
     });
   });
 
@@ -348,7 +349,9 @@
     });
 
     test('plugins', () => {
-      assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.pluginsPerPage);
     });
   });
 
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 389e7c4..52e0b3f 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
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-access-section/gr-access-section';
-import {encodeURL, getBaseUrl, singleDecodeURL} from '../../../utils/url-util';
+import {singleDecodeURL} from '../../../utils/url-util';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {toSortedPermissionsArray} from '../../../utils/access-util';
 import {
@@ -44,6 +44,7 @@
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {createRepoUrl, RepoDetailView} from '../../../models/views/repo';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
@@ -726,10 +727,10 @@
 
   computeParentHref() {
     if (!this.inheritsFrom?.name) return '';
-    return `${getBaseUrl()}/admin/repos/${encodeURL(
-      this.inheritsFrom.name,
-      true
-    )},access`;
+    return createRepoUrl({
+      repo: this.inheritsFrom.name,
+      detail: RepoDetailView.ACCESS,
+    });
   }
 
   private handleEditInheritFromTextChanged(e: ValueChangedEvent) {
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 e13609e..11cfaab 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
@@ -7,7 +7,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-change-dialog/gr-create-change-dialog';
 import '../gr-create-change-dialog/gr-create-file-edit-dialog';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
@@ -32,7 +31,7 @@
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, query, property, state} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
-import {createEditUrl} from '../../../models/views/edit';
+import {createEditUrl} from '../../../models/views/change';
 import {resolve} from '../../../models/dependency';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {GrCreateFileEditDialog} from '../gr-create-change-dialog/gr-create-file-edit-dialog';
@@ -321,8 +320,8 @@
           createEditUrl({
             changeNum: change._number,
             repo: change.project,
-            path: CONFIG_PATH,
             patchNum: INITIAL_PATCHSET,
+            editView: {path: CONFIG_PATH},
           })
         );
       })
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 3b193a6..981cbe4 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,7 +25,6 @@
 import {firePageError} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -51,36 +50,30 @@
   @property({type: Object})
   params?: RepoViewState;
 
-  // private but used in test
   @state() detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
 
-  // private but used in test
   @state() isOwner = false;
 
-  @state() private loggedIn = false;
+  @state() loggedIn = false;
 
-  @state() private offset = 0;
+  @state() offset = 0;
 
-  // private but used in test
   @state() repo?: RepoName;
 
-  // private but used in test
   @state() items?: BranchInfo[] | TagInfo[];
 
-  @state() private readonly itemsPerPage = 25;
+  @state() readonly itemsPerPage = 25;
 
-  @state() private loading = true;
+  @state() loading = true;
 
-  @state() private filter?: string;
+  @state() filter?: string;
 
-  @state() private refName?: GitRef;
+  @state() refName?: GitRef;
 
-  @state() private newItemName = false;
+  @state() newItemName = false;
 
-  // private but used in test
   @state() isEditing = false;
 
-  // private but used in test
   @state() revisedRef?: GitRef;
 
   private readonly restApiService = getAppContext().restApiService;
@@ -185,7 +178,7 @@
           </tbody>
           <tbody class=${this.loading ? 'loading' : ''}>
             ${this.items
-              ?.slice(0, SHOWN_ITEMS_COUNT)
+              ?.slice(0, this.itemsPerPage)
               .map((item, index) => this.renderItemList(item, index))}
           </tbody>
         </table>
@@ -442,6 +435,9 @@
   }
 
   private getPath(repo?: RepoName, detailType?: RepoDetailView) {
+    // TODO: Replace with `createRepoUrl()`, but be aware that `encodeURL()`
+    // gets `false` as a second parameter here. The router pattern in gr-router
+    // does not handle the filter URLs, if the repo is not encoded!
     return `/admin/repos/${encodeURL(repo ?? '', false)},${detailType}`;
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
index 28fc751..9b9cf29 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
@@ -32,7 +32,6 @@
 import {PageErrorEvent} from '../../../types/events';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
 import {RepoDetailView} from '../../../models/views/repo';
 
@@ -2391,7 +2390,9 @@
       });
 
       test('items', () => {
-        assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+        const table = queryAndAssert(element, 'table');
+        const rows = table.querySelectorAll('tr.table');
+        assert.equal(rows.length, element.itemsPerPage);
       });
     });
 
@@ -2411,7 +2412,9 @@
       });
 
       test('items', () => {
-        assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+        const table = queryAndAssert(element, 'table');
+        const rows = table.querySelectorAll('tr.table');
+        assert.equal(rows.length, element.itemsPerPage);
       });
     });
 
@@ -2434,6 +2437,18 @@
     });
 
     suite('create new', () => {
+      setup(async () => {
+        stubRestApi('getRepoBranches').resolves(createBranchesList(3));
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.BRANCHES,
+        };
+        await element.paramsChanged();
+        await element.updateComplete;
+      });
+
       test('handleCreateClicked called when create-click fired', () => {
         const handleCreateClickedStub = sinon.stub(
           element,
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 c6c6efd..055cb30 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
@@ -6,23 +6,23 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {
-  RepoName,
-  ProjectInfoWithName,
-  WebLinkInfo,
-} from '../../../types/common';
+import {ProjectInfoWithName, WebLinkInfo} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {RepoState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {RepoState} from '../../../constants/constants';
 import {fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {AdminViewState} from '../../../models/views/admin';
+import {
+  AdminChildView,
+  AdminViewState,
+  createAdminUrl,
+} from '../../../models/views/admin';
 import {createSearchUrl} from '../../../models/views/search';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {createRepoUrl} from '../../../models/views/repo';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -32,8 +32,6 @@
 
 @customElement('gr-repo-list')
 export class GrRepoList extends LitElement {
-  readonly path = '/admin/repos';
-
   @query('#createModal') private createModal?: HTMLDialogElement;
 
   @query('#createNewModal') private createNewModal?: GrCreateRepoDialog;
@@ -41,23 +39,18 @@
   @property({type: Object})
   params?: AdminViewState;
 
-  // private but used in test
   @state() offset = 0;
 
-  @state() private newRepoName = false;
+  @state() newRepoName = false;
 
-  @state() private createNewCapability = false;
+  @state() createNewCapability = false;
 
-  // private but used in test
   @state() repos: ProjectInfoWithName[] = [];
 
-  // private but used in test
   @state() reposPerPage = 25;
 
-  // private but used in test
   @state() loading = true;
 
-  // private but used in test
   @state() filter = '';
 
   private readonly restApiService = getAppContext().restApiService;
@@ -103,7 +96,7 @@
         .items=${this.repos}
         .loading=${this.loading}
         .offset=${this.offset}
-        .path=${this.path}
+        .path=${createAdminUrl({adminView: AdminChildView.REPOS})}
         @create-clicked=${() => this.handleCreateClicked()}
       >
         <table id="list" class="genericList">
@@ -149,7 +142,7 @@
   }
 
   private renderRepoList() {
-    const shownRepos = this.repos.slice(0, SHOWN_ITEMS_COUNT);
+    const shownRepos = this.repos.slice(0, this.reposPerPage);
     return shownRepos.map(item => this.renderRepo(item));
   }
 
@@ -157,11 +150,11 @@
     return html`
       <tr class="table">
         <td class="name">
-          <a href=${this.computeRepoUrl(item.name)}>${item.name}</a>
+          <a href=${createRepoUrl({repo: item.name})}>${item.name}</a>
         </td>
         <td class="repositoryBrowser">${this.renderWebLinks(item)}</td>
         <td class="changesLink">
-          <a href=${this.computeChangesLink(item.name)}>view all</a>
+          <a href=${createSearchUrl({repo: item.name})}>view all</a>
         </td>
         <td class="readOnly">
           ${item.state === RepoState.READ_ONLY ? 'Y' : ''}
@@ -210,14 +203,6 @@
     }
   }
 
-  private computeRepoUrl(name: string) {
-    return `${getBaseUrl()}${this.path}/${encodeURL(name, true)}`;
-  }
-
-  private computeChangesLink(name: string) {
-    return createSearchUrl({repo: name as RepoName});
-  }
-
   private async getCreateRepoCapability() {
     const account = await this.restApiService.getAccount();
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
index 5b65942..65f5a8c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
@@ -17,7 +17,7 @@
   ProjectInfoWithName,
   RepoName,
 } from '../../../types/common';
-import {RepoState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {RepoState} from '../../../api/rest-api';
 import {GerritView} from '../../../services/router/router-model';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
@@ -614,7 +614,9 @@
     });
 
     test('shownRepos', () => {
-      assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.reposPerPage);
     });
 
     test('maybeOpenCreateModal', () => {
@@ -645,7 +647,9 @@
     });
 
     test('shownRepos', () => {
-      assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+      const table = queryAndAssert(element, 'table');
+      const rows = table.querySelectorAll('tr.table');
+      assert.equal(rows.length, element.reposPerPage);
     });
   });
 
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 8d6d89a..3599224 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -25,7 +25,7 @@
   RepoState,
   SubmitType,
 } from '../../../constants/constants';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {WebLinkInfo} from '../../../types/diff';
@@ -36,8 +36,9 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {BindValueChangeEvent} from '../../../types/events';
 import {deepClone} from '../../../utils/deep-util';
-import {LitElement, PropertyValues, css, html} from 'lit';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {createSearchUrl} from '../../../models/views/search';
 import {userModelToken} from '../../../models/user/user-model';
@@ -150,16 +151,6 @@
           color: var(--deemphasized-text-color);
           content: ' *';
         }
-        .loading,
-        .hide {
-          display: none;
-        }
-        #loading.loading {
-          display: block;
-        }
-        #loading:not(.loading) {
-          display: none;
-        }
         #options .repositorySettings {
           display: none;
         }
@@ -187,49 +178,48 @@
             >
           </div>
         </div>
-        <div id="loading" class=${this.loading ? 'loading' : ''}>
-          Loading...
-        </div>
-        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
-          ${this.renderDownloadCommands()}
-          <h2
-            id="configurations"
-            class="heading-2 ${configChanged ? 'edited' : ''}"
-          >
-            Configurations
-          </h2>
-          <div id="form">
-            <fieldset>
-              ${this.renderDescription()} ${this.renderRepoOptions()}
-              ${this.renderPluginConfig()}
-              <gr-button
-                ?disabled=${this.readOnly || !configChanged}
-                @click=${this.handleSaveRepoConfig}
-                >Save changes</gr-button
-              >
-            </fieldset>
-            <gr-endpoint-decorator name="repo-config">
-              <gr-endpoint-param
-                name="repoName"
-                .value=${this.repo}
-              ></gr-endpoint-param>
-              <gr-endpoint-param
-                name="readOnly"
-                .value=${this.readOnly}
-              ></gr-endpoint-param>
-            </gr-endpoint-decorator>
-          </div>
-        </div>
+        ${when(
+          this.loading || !this.repoConfig,
+          () => html`<div id="loading">Loading...</div>`,
+          () => html`<div id="loadedContent">
+            ${this.renderDownloadCommands()}
+            <h2
+              id="configurations"
+              class="heading-2 ${configChanged ? 'edited' : ''}"
+            >
+              Configurations
+            </h2>
+            <div id="form">
+              <fieldset>
+                ${this.renderDescription()} ${this.renderRepoOptions()}
+                ${this.renderPluginConfig()}
+                <gr-button
+                  ?disabled=${this.readOnly || !configChanged}
+                  @click=${this.handleSaveRepoConfig}
+                  >Save changes</gr-button
+                >
+              </fieldset>
+              <gr-endpoint-decorator name="repo-config">
+                <gr-endpoint-param
+                  name="repoName"
+                  .value=${this.repo}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="readOnly"
+                  .value=${this.readOnly}
+                ></gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+          </div>`
+        )}
       </div>
     `;
   }
 
   private renderDownloadCommands() {
+    if (!this.schemes.length) return nothing;
     return html`
-      <div
-        id="downloadContent"
-        class=${!this.schemes || !this.schemes.length ? 'hide' : ''}
-      >
+      <div id="downloadContent">
         <h2 id="download" class="heading-2">Download</h2>
         <fieldset>
           <gr-download-commands
@@ -252,6 +242,7 @@
   }
 
   private renderDescription() {
+    assertIsDefined(this.repoConfig, 'repoConfig');
     return html`
       <h3 id="Description" class="heading-3">Description</h3>
       <fieldset>
@@ -263,7 +254,7 @@
           rows="4"
           monospace
           ?disabled=${this.readOnly}
-          .text=${this.repoConfig?.description ?? ''}
+          .text=${this.repoConfig.description ?? ''}
           @text-changed=${this.handleDescriptionTextChanged}
         ></gr-textarea>
       </fieldset>
@@ -725,8 +716,9 @@
 
   private renderPluginConfig() {
     const pluginData = this.computePluginData();
+    if (!pluginData.length) return nothing;
     return html` <div
-      class="pluginConfig ${!pluginData || !pluginData.length ? 'hide' : ''}"
+      class="pluginConfig"
       @plugin-config-changed=${this.handlePluginConfigChanged}
     >
       <h3 class="heading-3">Plugins</h3>
@@ -762,6 +754,12 @@
   // private but used in test
   async loadRepo() {
     if (!this.repo) return Promise.resolve();
+    this.repoConfig = undefined;
+    this.originalConfig = undefined;
+    this.loading = true;
+    this.weblinks = [];
+    this.schemesObj = undefined;
+    this.readOnly = true;
 
     const promises = [];
 
@@ -1121,6 +1119,7 @@
 
   private handleDescriptionTextChanged(e: BindValueChangeEvent) {
     if (!this.repoConfig || this.loading) return;
+    if (this.repoConfig.description === e.detail.value) return;
     this.repoConfig = {
       ...this.repoConfig,
       description: e.detail.value,
@@ -1130,6 +1129,7 @@
 
   private handleStateSelectBindValueChanged(e: BindValueChangeEvent) {
     if (!this.repoConfig || this.loading) return;
+    if (this.repoConfig.state === e.detail.value) return;
     this.repoConfig = {
       ...this.repoConfig,
       state: e.detail.value as RepoState,
@@ -1139,6 +1139,7 @@
 
   private handleSubmitTypeSelectBindValueChanged(e: BindValueChangeEvent) {
     if (!this.repoConfig || this.loading) return;
+    if (this.repoConfig.submit_type === e.detail.value) return;
     this.repoConfig = {
       ...this.repoConfig,
       submit_type: e.detail.value as SubmitType,
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index c013c9e..4deb99a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -157,14 +157,17 @@
     element = await fixture(html`<gr-repo></gr-repo>`);
   });
 
-  test('render', () => {
+  test('render', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
+    await element.updateComplete;
     // prettier and shadowDom assert do not agree about span.title wrapping
     assert.shadowDom.equal(
       element,
       /* prettier-ignore */ /* HTML */ `
       <div class="gr-form-styles main read-only">
         <div class="info">
-          <h1 class="heading-1" id="Title"></h1>
+          <h1 class="heading-1" id="Title">test-repo</h1>
           <hr />
           <div>
             <a href="">
@@ -178,7 +181,7 @@
                 Browse
               </gr-button>
             </a>
-            <a href="">
+            <a href="/q/project:test-repo">
               <gr-button
                 aria-disabled="false"
                 link=""
@@ -190,15 +193,7 @@
             </a>
           </div>
         </div>
-        <div class="loading" id="loading">Loading...</div>
-        <div class="loading" id="loadedContent">
-          <div class="hide" id="downloadContent">
-            <h2 class="heading-2" id="download">Download</h2>
-            <fieldset>
-              <gr-download-commands id="downloadCommands">
-              </gr-download-commands>
-            </fieldset>
-          </div>
+        <div id="loadedContent">
           <h2 class="heading-2" id="configurations">Configurations</h2>
           <div id="form">
             <fieldset>
@@ -266,7 +261,7 @@
                   </span>
                 </section>
                 <section
-                  class="repositorySettings"
+                  class="repositorySettings showConfig"
                   id="enableSignedPushSettings"
                 >
                   <span class="title"> Enable signed push </span>
@@ -277,7 +272,7 @@
                   </span>
                 </section>
                 <section
-                  class="repositorySettings"
+                  class="repositorySettings showConfig"
                   id="requireSignedPushSettings"
                 >
                   <span class="title"> Require signed push </span>
@@ -379,9 +374,6 @@
                   </span>
                 </section>
               </fieldset>
-              <div class="hide pluginConfig">
-                <h3 class="heading-3">Plugins</h3>
-              </div>
               <gr-button
                 aria-disabled="true"
                 disabled=""
@@ -398,7 +390,51 @@
           </div>
         </div>
       </div>
-    `
+    `,
+      {ignoreTags: ['option']}
+    );
+  });
+
+  test('render loading', async () => {
+    element.repo = REPO as RepoName;
+    element.loading = true;
+    await element.updateComplete;
+    // prettier and shadowDom assert do not agree about span.title wrapping
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <div class="gr-form-styles main read-only">
+        <div class="info">
+          <h1 class="heading-1" id="Title">test-repo</h1>
+          <hr />
+          <div>
+            <a href="">
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                link=""
+                role="button"
+                tabindex="-1"
+              >
+                Browse
+              </gr-button>
+            </a>
+            <a href="/q/project:test-repo">
+              <gr-button
+                aria-disabled="false"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                View Changes
+              </gr-button>
+            </a>
+          </div>
+        </div>
+        <div id="loading">Loading...</div>
+      </div>
+    `,
+      {ignoreTags: ['option']}
     );
   });
 
@@ -451,55 +487,22 @@
     assert.isTrue(requestUpdateStub.called);
   });
 
-  test('loading displays before repo config is loaded', () => {
-    assert.isTrue(
-      queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
-        'loading'
-      )
-    );
-    assert.isFalse(
-      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '#loading'))
-        .display === 'none'
-    );
-    assert.isTrue(
-      queryAndAssert<HTMLDivElement>(
-        element,
-        '#loadedContent'
-      ).classList.contains('loading')
-    );
-    assert.isTrue(
-      getComputedStyle(
-        queryAndAssert<HTMLDivElement>(element, '#loadedContent')
-      ).display === 'none'
-    );
-  });
-
-  test('download commands visibility', async () => {
-    element.loading = false;
-    await element.updateComplete;
-    assert.isTrue(
-      queryAndAssert<HTMLDivElement>(
-        element,
-        '#downloadContent'
-      ).classList.contains('hide')
-    );
-    assert.isTrue(
-      getComputedStyle(
-        queryAndAssert<HTMLDivElement>(element, '#downloadContent')
-      ).display === 'none'
-    );
+  test('render download commands', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
     element.schemesObj = SCHEMES;
     await element.updateComplete;
-    assert.isFalse(
-      queryAndAssert<HTMLDivElement>(
-        element,
-        '#downloadContent'
-      ).classList.contains('hide')
-    );
-    assert.isFalse(
-      getComputedStyle(
-        queryAndAssert<HTMLDivElement>(element, '#downloadContent')
-      ).display === 'none'
+    const content = queryAndAssert<HTMLDivElement>(element, '#downloadContent');
+    assert.dom.equal(
+      content,
+      /* HTML */ `
+        <div id="downloadContent">
+          <h2 class="heading-2" id="download">Download</h2>
+          <fieldset>
+            <gr-download-commands id="downloadCommands"></gr-download-commands>
+          </fieldset>
+        </div>
+      `
     );
   });
 
@@ -715,9 +718,9 @@
         Promise.resolve(new Response())
       );
 
-      const button = queryAll<GrButton>(element, 'gr-button')[2];
-
       await element.loadRepo();
+
+      const button = queryAll<GrButton>(element, 'gr-button')[2];
       assert.isTrue(button.hasAttribute('disabled'));
       assert.isFalse(
         queryAndAssert<HTMLHeadingElement>(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
index 71977d1..654ed91 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
@@ -286,7 +286,7 @@
             </div>
           </div>
         </gr-dialog>
-      </gr-overlay> `
+      </dialog> `
     );
   });
 
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 f206a9a..342b876 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,6 +26,7 @@
   ServerInfo,
   AccountInfo,
   Timestamp,
+  NumericChangeId,
 } from '../../../types/common';
 import {hasOwnProperty, assertIsDefined} from '../../../utils/common-util';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
@@ -130,8 +131,7 @@
       this,
       () => this.getBulkActionsModel().selectedChangeNums$,
       selectedChangeNums => {
-        if (!this.change) return;
-        this.checked = selectedChangeNums.includes(this.change._number);
+        this.updateCheckedState(selectedChangeNums);
       }
     );
   }
@@ -159,6 +159,20 @@
     if (this.selected && changedProperties.has('selected')) {
       this.focus();
     }
+
+    if (changedProperties.has('change')) {
+      this.updateCheckedState(
+        this.getBulkActionsModel().getState().selectedChangeNums
+      );
+    }
+  }
+
+  private updateCheckedState(selectedChangeNums: NumericChangeId[]) {
+    if (!this.change) {
+      this.checked = false;
+      return;
+    }
+    this.checked = selectedChangeNums.includes(this.change._number);
   }
 
   static override get styles() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index e8f7fc3..c7cb5b8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -153,10 +153,6 @@
       element.change = {...createChange(), _number: 1 as NumericChangeId};
       bulkActionsModel.sync([element.change]);
       bulkActionsModel.addSelectedChangeNum(element.change._number);
-      await waitUntilObserved(
-        bulkActionsModel.selectedChangeNums$,
-        s => s.length === 1
-      );
       await element.updateComplete;
 
       const checkbox = queryAndAssert<HTMLInputElement>(
@@ -166,10 +162,31 @@
       assert.isTrue(checkbox.checked);
 
       bulkActionsModel.removeSelectedChangeNum(element.change._number);
-      await waitUntilObserved(
-        bulkActionsModel.selectedChangeNums$,
-        s => s.length === 0
+      await element.updateComplete;
+
+      assert.isFalse(checkbox.checked);
+    });
+
+    test('checkbox state updates with change id update', async () => {
+      element.requestUpdate();
+      await element.updateComplete;
+
+      const changes = [
+        {...createChange(), _number: 1 as NumericChangeId},
+        {...createChange(), _number: 2 as NumericChangeId},
+      ];
+      element.change = changes[0];
+      bulkActionsModel.sync(changes);
+      bulkActionsModel.addSelectedChangeNum(element.change._number);
+      await element.updateComplete;
+
+      const checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '.selection > .selectionLabel > input'
       );
+      assert.isTrue(checkbox.checked);
+
+      element.change = changes[1];
       await element.updateComplete;
 
       assert.isFalse(checkbox.checked);
@@ -341,12 +358,14 @@
   });
 
   test('renders', async () => {
+    const change = createChange();
+    bulkActionsModel.sync([change]);
+    bulkActionsModel.addSelectedChangeNum(change._number);
     element.showStar = true;
     element.showNumber = true;
     element.account = createAccountWithId(1);
     element.config = createServerInfo();
-    element.change = createChange();
-    element.checked = true;
+    element.change = change;
     await element.updateComplete;
     assert.isTrue(element.hasAttribute('checked'));
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index 1789fcb..ab116f1 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -268,7 +268,7 @@
       <dialog
         tabindex="-1"
         id=${id}
-        @iron-overlay-canceled=${() => this.cancelPendingGroup(reviewerState)}
+        @close=${() => this.cancelPendingGroup(reviewerState)}
       >
         <div class="confirmation-text">
           Group
@@ -466,11 +466,11 @@
   }
 
   private cancelPendingGroup(reviewerState: ReviewerState) {
-    const overlay =
+    const modal =
       reviewerState === ReviewerState.CC
         ? this.ccConfirmModal
         : this.reviewerConfirmModal;
-    overlay?.close();
+    modal?.close();
     this.groupPendingConfirmationByReviewerState.set(reviewerState, null);
     this.requestUpdate();
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 7abfce9..faaee0b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -282,12 +282,14 @@
   // private but used in test
   handleNextPage() {
     if (!this.nextArrow || !this.changesPerPage) return;
+    // TODO: Use navigation service instead of `page.show()` directly.
     page.show(this.computeNavLink(1));
   }
 
   // private but used in test
   handlePreviousPage() {
     if (!this.prevArrow || !this.changesPerPage) return;
+    // TODO: Use navigation service instead of `page.show()` directly.
     page.show(this.computeNavLink(-1));
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 6e0e3a9..d013654 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -6,7 +6,6 @@
 import '../gr-change-list/gr-change-list';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-commands-dialog/gr-create-commands-dialog';
 import '../gr-create-change-help/gr-create-change-help';
 import '../gr-create-destination-dialog/gr-create-destination-dialog';
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index a78ba1c..e47b450 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -8,7 +8,6 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-icon/gr-icon';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
 import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
 import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
@@ -38,6 +37,7 @@
   ActionInfo,
   ActionNameToActionInfoMap,
   BranchName,
+  ChangeActionDialog,
   ChangeInfo,
   ChangeViewChangeInfo,
   CherryPickInput,
@@ -317,11 +317,6 @@
   priority: ActionPriority;
 }
 
-interface ChangeActionDialog extends HTMLElement {
-  resetFocus?(): void;
-  init?(): void;
-}
-
 @customElement('gr-change-actions')
 export class GrChangeActions
   extends LitElement
@@ -450,6 +445,8 @@
   // private but used in test
   @state() actionLoadingMessage = '';
 
+  @state() private inProgressActionKeys = new Set<string>();
+
   // _computeAllActions always returns an array
   // private but used in test
   @state() allActionValues: UIActionInfo[] = [];
@@ -676,6 +673,9 @@
           .changeNumber=${this.change?._number}
           @confirm=${this.handleRebaseConfirm}
           @cancel=${this.handleConfirmDialogCancel}
+          .disableActions=${this.inProgressActionKeys.has(
+            RevisionActions.REBASE
+          )}
           .branch=${this.change?.branch}
           .hasParent=${this.hasParent}
           .rebaseOnCurrent=${this.revisionRebaseAction
@@ -1577,10 +1577,11 @@
       base: e.detail.base,
       allow_conflicts: e.detail.allowConflicts,
     };
+    const rebaseChain = !!e.detail.rebaseChain;
     this.fireAction(
-      '/rebase',
+      rebaseChain ? '/rebase:chain' : '/rebase',
       assertUIActionInfo(this.revisionActions.rebase),
-      true,
+      rebaseChain ? false : true,
       payload,
       {allow_conflicts: payload.allow_conflicts}
     );
@@ -1746,7 +1747,9 @@
   }
 
   // private but used in test
-  setLoadingOnButtonWithKey(type: string, key: string) {
+  setLoadingOnButtonWithKey(action: UIActionInfo) {
+    const key = action.__key;
+    this.inProgressActionKeys.add(key);
     this.actionLoadingMessage = this.computeLoadingLabel(key);
     let buttonKey = key;
     // TODO(dhruvsri): clean this up later
@@ -1757,12 +1760,14 @@
     }
 
     // If the action appears in the overflow menu.
-    if (this.getActionOverflowIndex(type, buttonKey) !== -1) {
+    if (this.getActionOverflowIndex(action.__type, buttonKey) !== -1) {
       this.disabledMenuActions.push(buttonKey === '/' ? 'delete' : buttonKey);
       this.requestUpdate('disabledMenuActions');
       return () => {
+        this.inProgressActionKeys.delete(key);
         this.actionLoadingMessage = '';
         this.disabledMenuActions = [];
+        this.requestUpdate();
       };
     }
 
@@ -1776,9 +1781,11 @@
     buttonEl.setAttribute('loading', 'true');
     buttonEl.disabled = true;
     return () => {
+      this.inProgressActionKeys.delete(action.__key);
       this.actionLoadingMessage = '';
       buttonEl.removeAttribute('loading');
       buttonEl.disabled = false;
+      this.requestUpdate();
     };
   }
 
@@ -1790,10 +1797,7 @@
     payload?: RequestPayload,
     toReport?: Object
   ) {
-    const cleanupFn = this.setLoadingOnButtonWithKey(
-      action.__type,
-      action.__key
-    );
+    const cleanupFn = this.setLoadingOnButtonWithKey(action);
     this.reporting.reportInteraction(Interaction.CHANGE_ACTION_FIRED, {
       endpoint,
       toReport,
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index bdcdc31..4602eac 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -625,7 +625,9 @@
       };
       assert.isTrue(fetchChangesStub.called);
       element.handleRebaseConfirm(
-        new CustomEvent('', {detail: {base: '1234', allowConflicts: false}})
+        new CustomEvent('', {
+          detail: {base: '1234', allowConflicts: false, rebaseChain: false},
+        })
       );
       assert.deepEqual(fireActionStub.lastCall.args, [
         '/rebase',
@@ -1269,18 +1271,28 @@
       await keyTapped;
     });
 
-    test('setLoadingOnButtonWithKey top-level', () => {
+    test('setLoadingOnButtonWithKey top-level', async () => {
       const key = 'rebase';
-      const type = 'revision';
-      const cleanup = element.setLoadingOnButtonWithKey(type, key);
+      const type = ActionType.REVISION;
+      const cleanup = element.setLoadingOnButtonWithKey({
+        __type: type,
+        __key: key,
+        label: 'label',
+      });
       assert.equal(element.actionLoadingMessage, 'Rebasing...');
 
       const button = queryAndAssert<GrButton>(
         element,
         '[data-action-key="' + key + '"]'
       );
+      const dialog = queryAndAssert<GrConfirmRebaseDialog>(
+        element,
+        'gr-confirm-rebase-dialog'
+      );
       assert.isTrue(button.hasAttribute('loading'));
       assert.isTrue(button.disabled);
+      await dialog.updateComplete;
+      assert.isTrue(dialog.disableActions);
 
       assert.isOk(cleanup);
       assert.isFunction(cleanup);
@@ -1289,12 +1301,18 @@
       assert.isFalse(button.hasAttribute('loading'));
       assert.isFalse(button.disabled);
       assert.isNotOk(element.actionLoadingMessage);
+      await dialog.updateComplete;
+      assert.isFalse(dialog.disableActions);
     });
 
     test('setLoadingOnButtonWithKey overflow menu', () => {
       const key = 'cherrypick';
-      const type = 'revision';
-      const cleanup = element.setLoadingOnButtonWithKey(type, key);
+      const type = ActionType.REVISION;
+      const cleanup = element.setLoadingOnButtonWithKey({
+        __type: type,
+        __key: key,
+        label: 'label',
+      });
       assert.equal(element.actionLoadingMessage, 'Cherry-picking...');
       assert.include(element.disabledMenuActions, 'cherrypick');
       assert.isFunction(cleanup);
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index a06f8c9..b9a04bd 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -9,7 +9,6 @@
 import '../../../styles/gr-change-view-integration-shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import '../../plugins/gr-external-style/gr-external-style';
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-editable-label/gr-editable-label';
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 84bdffb..b726292 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -55,7 +55,6 @@
 
 import {SummaryChipStyles} from './gr-summary-chip';
 import {when} from 'lit/directives/when.js';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {combineLatest} from 'rxjs';
 import {userModelToken} from '../../../models/user/user-model';
 
@@ -120,8 +119,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   constructor() {
     super();
     subscribe(
@@ -174,24 +171,22 @@
       () => this.getUserModel().account$,
       x => (this.selfAccount = x)
     );
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      subscribe(
-        this,
-        () =>
-          combineLatest([
-            this.getUserModel().account$,
-            this.getCommentsModel().threads$,
-          ]),
-        ([selfAccount, threads]) => {
-          if (!selfAccount || !selfAccount.email) return;
-          const unresolvedThreadsMentioningSelf = getMentionedThreads(
-            threads,
-            selfAccount
-          ).filter(isUnresolved);
-          this.mentionCount = unresolvedThreadsMentioningSelf.length;
-        }
-      );
-    }
+    subscribe(
+      this,
+      () =>
+        combineLatest([
+          this.getUserModel().account$,
+          this.getCommentsModel().threads$,
+        ]),
+      ([selfAccount, threads]) => {
+        if (!selfAccount || !selfAccount.email) return;
+        const unresolvedThreadsMentioningSelf = getMentionedThreads(
+          threads,
+          selfAccount
+        ).filter(isUnresolved);
+        this.mentionCount = unresolvedThreadsMentioningSelf.length;
+      }
+    );
   }
 
   static override get styles() {
@@ -575,8 +570,6 @@
   }
 
   private renderMentionChip() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
-      return nothing;
     if (!this.mentionCount) return nothing;
     return html` <gr-summary-chip
       class="mentionSummary"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 91b82dc..d4627e2 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -16,7 +16,6 @@
 import '../../shared/gr-change-status/gr-change-status';
 import '../../shared/gr-editable-content/gr-editable-content';
 import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-change-actions/gr-change-actions';
 import '../gr-change-summary/gr-change-summary';
@@ -35,11 +34,7 @@
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
-import {
-  querySelectorAll,
-  whenVisible,
-  windowLocationReload,
-} from '../../../utils/dom-util';
+import {whenVisible, windowLocationReload} from '../../../utils/dom-util';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {
@@ -70,7 +65,6 @@
 import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
 import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
 import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
@@ -141,16 +135,12 @@
   fireTitleChange,
 } from '../../../utils/event-util';
 import {
-  GerritView,
-  routerModelToken,
-} from '../../../services/router/router-model';
-import {
   debounce,
   DelayedTask,
   throttleWrap,
   until,
 } from '../../../utils/async-util';
-import {Interaction, Timing, Execution} from '../../../constants/reporting';
+import {Interaction, Timing} from '../../../constants/reporting';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {
@@ -182,12 +172,13 @@
 import {getBaseUrl, prependOrigin} from '../../../utils/url-util';
 import {CopyLink, GrCopyLinks} from '../gr-copy-links/gr-copy-links';
 import {
+  ChangeChildView,
   changeViewModelToken,
   ChangeViewState,
   createChangeUrl,
+  createEditUrl,
 } from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
-import {createEditUrl} from '../../../models/views/edit';
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {modalStyles} from '../../../styles/gr-modal-styles';
@@ -545,8 +536,6 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly getRouterModel = resolve(this, routerModelToken);
-
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly getConfigModel = resolve(this, configModelToken);
@@ -579,7 +568,7 @@
 
   /** Simply reflects the router-model value. */
   // visible for testing
-  routerPatchNum?: PatchSetNum;
+  viewModelPatchNum?: PatchSetNum;
 
   private readonly shortcutsController = new ShortcutController(this);
 
@@ -706,16 +695,16 @@
     );
     subscribe(
       this,
-      () => this.getRouterModel().routerView$,
-      view => {
-        this.isViewCurrent = view === GerritView.CHANGE;
+      () => this.getViewModel().childView$,
+      childView => {
+        this.isViewCurrent = childView === ChangeChildView.OVERVIEW;
       }
     );
     subscribe(
       this,
-      () => this.getRouterModel().routerPatchNum$,
+      () => this.getViewModel().patchNum$,
       patchNum => {
-        this.routerPatchNum = patchNum;
+        this.viewModelPatchNum = patchNum;
       }
     );
     subscribe(
@@ -889,7 +878,6 @@
           border-bottom: 1px solid var(--border-color);
           display: flex;
           padding: var(--spacing-s) var(--spacing-l);
-          z-index: 99; /* Less than gr-overlay's backdrop */
         }
         .header.editMode {
           background-color: var(--edit-mode-background-color);
@@ -1268,7 +1256,14 @@
         flatten
         down-arrow
         class="showCopyLinkDialogButton"
-        @click=${() => this.copyLinksDropdown?.toggleDropdown()}
+        @click=${(e: MouseEvent) => {
+          // We don't want to handle clicks on the star or the <a> link.
+          // Calling `stopPropagation()` from the click handler of <a> is not an
+          // option, because then the click does not reach the top-level page.js
+          // click handler and would result is a full page reload.
+          if ((e.target as HTMLElement)?.nodeName !== 'GR-BUTTON') return;
+          this.copyLinksDropdown?.toggleDropdown();
+        }}
         ><gr-change-star
           id="changeStar"
           .change=${this.change}
@@ -1280,7 +1275,6 @@
           class="changeNumber"
           aria-label=${`Change ${this.change?._number}`}
           href=${ifDefined(this.computeChangeUrl(true))}
-          @click=${(e: MouseEvent) => e.stopPropagation()}
           >${this.change?._number}</a
         >
       </gr-button>
@@ -1536,7 +1530,6 @@
           .allPatchSets=${this.allPatchSets}
           .change=${this.change}
           .changeNum=${this.changeNum}
-          .revisionInfo=${this.getRevisionInfo()}
           .commitInfo=${this.commitInfo}
           .changeUrl=${this.computeChangeUrl()}
           .editMode=${this.getEditMode()}
@@ -2086,13 +2079,7 @@
 
   // Private but used in tests.
   viewStateChanged() {
-    if (this.viewState === undefined) {
-      this.initialLoadComplete = false;
-      querySelectorAll(this, 'gr-overlay').forEach(overlay =>
-        (overlay as GrOverlay).close()
-      );
-      return;
-    }
+    if (!this.viewState) return;
 
     if (this.isChangeObsolete()) {
       // Tell the app element that we are not going to handle the new change
@@ -2101,13 +2088,6 @@
       return;
     }
 
-    if (this.viewState.changeNum && this.viewState.repo) {
-      this.restApiService.setInProjectLookup(
-        this.viewState.changeNum,
-        this.viewState.repo
-      );
-    }
-
     if (this.viewState.basePatchNum === undefined)
       this.viewState.basePatchNum = PARENT;
 
@@ -2293,7 +2273,7 @@
 
   private updateTitle(change?: ChangeInfo | ParsedChangeInfo) {
     if (!change) return;
-    const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+    const title = `${change.subject} (${change._number})`;
     fireTitleChange(this, title);
   }
 
@@ -2352,13 +2332,10 @@
       this.prefs &&
       this.prefs.default_base_for_merges === DefaultBase.FIRST_PARENT;
 
-    // TODO: I think checking `!patchRange.patchNum` here is a bug and means
-    // that the feature is actually broken at the moment. Looking at the
-    // `changeChanged` method, `patchRange.patchNum` is set before
-    // `getBasePatchNum` is called, so it is unlikely that this method will
-    // ever return -1.
+    // Verified via reportExecution that -1 is returned(1-5 times per day)
+    // changeChanged does set this.patchRange?.patchNum so it's still unclear
+    // how it is undefined.
     if (isMerge && preferFirst && !this.patchRange?.patchNum) {
-      this.reporting.reportExecution(Execution.PREFER_MERGE_FIRST_PARENT);
       return -1 as BasePatchSetNum;
     }
     return PARENT;
@@ -2460,6 +2437,7 @@
 
   // Private but used in tests.
   handleDiffBaseAgainstLeft() {
+    if (this.viewState?.childView !== ChangeChildView.OVERVIEW) return;
     assertIsDefined(this.change, 'change');
     assertIsDefined(this.patchRange, 'patchRange');
 
@@ -2662,7 +2640,7 @@
     // is under change-model control. `patchRange.patchNum` should eventually
     // also be model managed, so we can reconcile these two code snippets into
     // one location.
-    if (!this.routerPatchNum && latestPsNum === editParentRev._number) {
+    if (!this.viewModelPatchNum && latestPsNum === editParentRev._number) {
       this.patchRange = {...this.patchRange, patchNum: EDIT};
       // The file list is not reactive (yet) with regards to patch range
       // changes, so we have to actively trigger it.
@@ -3155,8 +3133,8 @@
           createEditUrl({
             changeNum: this.change._number,
             repo: this.change.project,
-            path,
             patchNum: this.patchRange.patchNum,
+            editView: {path},
           })
         );
         break;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 9d89192..9b78e64 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -99,7 +99,7 @@
 import {Modifier} from '../../../utils/dom-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrCopyLinks} from '../gr-copy-links/gr-copy-links';
-import {ChangeViewState} from '../../../models/views/change';
+import {ChangeChildView, ChangeViewState} from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
@@ -369,6 +369,7 @@
     );
     element.viewState = {
       view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
       changeNum: TEST_NUMERIC_CHANGE_ID,
       repo: 'gerrit' as RepoName,
     };
@@ -1986,7 +1987,7 @@
 
     // When edit is set, but patchNum as well, then keep patchNum.
     element.patchRange.patchNum = 5 as RevisionPatchSetNum;
-    element.routerPatchNum = 5 as RevisionPatchSetNum;
+    element.viewModelPatchNum = 5 as RevisionPatchSetNum;
     element.processEdit(change);
     assert.equal(element.patchRange.patchNum, 5 as RevisionPatchSetNum);
   });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
index ab15ae6..85746df 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -13,6 +13,7 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
+import {ChangeActionDialog} from '../../../types/common';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -21,7 +22,10 @@
 }
 
 @customElement('gr-confirm-abandon-dialog')
-export class GrConfirmAbandonDialog extends LitElement {
+export class GrConfirmAbandonDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
index 15f3e21..02156df 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
@@ -6,10 +6,14 @@
 import {css, html, LitElement} from 'lit';
 import {customElement} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {ChangeActionDialog} from '../../../types/common';
 import '../../shared/gr-dialog/gr-dialog';
 
 @customElement('gr-confirm-cherrypick-conflict-dialog')
-export class GrConfirmCherrypickConflictDialog extends LitElement {
+export class GrConfirmCherrypickConflictDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 5d7e55c..5f3b824 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -17,6 +17,7 @@
   CommitId,
   ChangeInfoId,
   TopicName,
+  ChangeActionDialog,
 } from '../../../types/common';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {
@@ -60,7 +61,10 @@
 }
 
 @customElement('gr-confirm-cherrypick-dialog')
-export class GrConfirmCherrypickDialog extends LitElement {
+export class GrConfirmCherrypickDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index 8f5c8dc..3f84189 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -6,7 +6,7 @@
 import {css, html, LitElement} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {BranchName, RepoName} from '../../../types/common';
+import {BranchName, ChangeActionDialog, RepoName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
@@ -19,7 +19,10 @@
 const SUGGESTIONS_LIMIT = 15;
 
 @customElement('gr-confirm-move-dialog')
-export class GrConfirmMoveDialog extends LitElement {
+export class GrConfirmMoveDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 072ec73d..b0dbda5 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -5,7 +5,12 @@
  */
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {NumericChangeId, BranchName} from '../../../types/common';
+import {when} from 'lit/directives/when.js';
+import {
+  NumericChangeId,
+  BranchName,
+  ChangeActionDialog,
+} from '../../../types/common';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import {
@@ -17,6 +22,7 @@
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ValueChangedEvent} from '../../../types/events';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 export interface RebaseChange {
   name: string;
@@ -26,10 +32,14 @@
 export interface ConfirmRebaseEventDetail {
   base: string | null;
   allowConflicts: boolean;
+  rebaseChain: boolean;
 }
 
 @customElement('gr-confirm-rebase-dialog')
-export class GrConfirmRebaseDialog extends LitElement {
+export class GrConfirmRebaseDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
@@ -54,6 +64,9 @@
   @property({type: Boolean})
   rebaseOnCurrent?: boolean;
 
+  @property({type: Boolean})
+  disableActions = false;
+
   @state()
   text = '';
 
@@ -75,11 +88,16 @@
   @query('#rebaseAllowConflicts')
   private rebaseAllowConflicts!: HTMLInputElement;
 
+  @query('#rebaseChain')
+  private rebaseChain?: HTMLInputElement;
+
   @query('#parentInput')
   parentInput!: GrAutocomplete;
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly flagsService = getAppContext().flagsService;
+
   constructor() {
     super();
     this.query = input => this.getChangeSuggestions(input);
@@ -130,6 +148,7 @@
       <gr-dialog
         id="confirmDialog"
         confirm-label="Rebase"
+        .disabled=${this.disableActions}
         @confirm=${this.handleConfirmTap}
         @cancel=${this.handleCancelTap}
       >
@@ -210,6 +229,14 @@
               >Allow rebase with conflicts</label
             >
           </div>
+          ${when(
+            this.flagsService.isEnabled(KnownExperimentId.REBASE_CHAIN),
+            () =>
+              html`<div>
+                <input id="rebaseChain" type="checkbox" />
+                <label for="rebaseChain">Rebase all ancestors</label>
+              </div>`
+          )}
         </div>
       </gr-dialog>
     `;
@@ -315,6 +342,7 @@
     const detail: ConfirmRebaseEventDetail = {
       base: this.getSelectedBase(),
       allowConflicts: this.rebaseAllowConflicts.checked,
+      rebaseChain: !!this.rebaseChain?.checked,
     };
     this.dispatchEvent(new CustomEvent('confirm', {detail}));
     this.text = '';
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
index eba4bfe..776e923 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
@@ -16,6 +16,7 @@
 import {createChangeViewChange} from '../../../test/test-data-generators';
 import {fixture, html, assert} from '@open-wc/testing';
 import {Key} from '../../../utils/dom-util';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 
 suite('gr-confirm-rebase-dialog tests', () => {
   let element: GrConfirmRebaseDialog;
@@ -89,6 +90,19 @@
     );
   });
 
+  test('disableActions property disables dialog confirm', async () => {
+    element.disableActions = false;
+    await element.updateComplete;
+
+    const dialog = queryAndAssert<GrDialog>(element, 'gr-dialog');
+    assert.isFalse(dialog.disabled);
+
+    element.disableActions = true;
+    await element.updateComplete;
+
+    assert.isTrue(dialog.disabled);
+  });
+
   test('controls with parent and rebase on current available', async () => {
     element.rebaseOnCurrent = true;
     element.hasParent = true;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index 6b37284..dd9a5ee 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -8,7 +8,7 @@
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {LitElement, html, css, nothing} from 'lit';
 import {customElement, state} from 'lit/decorators.js';
-import {ChangeInfo, CommitId} from '../../../types/common';
+import {ChangeActionDialog, ChangeInfo, CommitId} from '../../../types/common';
 import {fire, fireAlert} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {BindValueChangeEvent} from '../../../types/events';
@@ -46,7 +46,10 @@
 }
 
 @customElement('gr-confirm-revert-dialog')
-export class GrConfirmRevertDialog extends LitElement {
+export class GrConfirmRevertDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /* The revert message updated by the user
       The default value is set by the dialog */
   @state()
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index 1c48a42..1b5f171 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -8,7 +8,7 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../gr-thread-list/gr-thread-list';
-import {ActionInfo, EDIT} from '../../../types/common';
+import {ActionInfo, ChangeActionDialog, EDIT} from '../../../types/common';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {pluralize} from '../../../utils/string-util';
 import {CommentThread, isUnresolved} from '../../../utils/comment-util';
@@ -23,7 +23,10 @@
 import {resolve} from '../../../models/dependency';
 
 @customElement('gr-confirm-submit-dialog')
-export class GrConfirmSubmitDialog extends LitElement {
+export class GrConfirmSubmitDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   @query('#dialog')
   dialog?: GrDialog;
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 89a1ff5..c1e866c 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -20,8 +20,6 @@
   PatchSetNum,
   CommitInfo,
   ServerInfo,
-  RevisionInfo,
-  NumericChangeId,
   BasePatchSetNum,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
@@ -71,9 +69,6 @@
   change: ChangeInfo | undefined;
 
   @property({type: String})
-  changeNum?: NumericChangeId;
-
-  @property({type: String})
   changeUrl?: string;
 
   @property({type: Object})
@@ -97,9 +92,6 @@
   @property({type: String})
   filesExpanded?: FilesExpandedState;
 
-  @property({type: Object})
-  revisionInfo?: RevisionInfo;
-
   @state()
   diffPrefs?: DiffPreferencesInfo;
 
@@ -274,12 +266,6 @@
           <div class="patchInfoContent">
             <gr-patch-range-select
               id="rangeSelect"
-              .changeNum=${this.changeNum}
-              .patchNum=${this.patchNum}
-              .basePatchNum=${this.basePatchNum}
-              .availablePatches=${this.allPatchSets}
-              .revisions=${this.change.revisions}
-              .revisionInfo=${this.revisionInfo}
               @patch-range-change=${this.handlePatchChange}
             >
             </gr-patch-range-select>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
index 23534a0..6c2282b 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
@@ -18,7 +18,6 @@
 import {
   BasePatchSetNum,
   ChangeId,
-  NumericChangeId,
   PARENT,
   PatchSetNum,
   PatchSetNumber,
@@ -174,7 +173,6 @@
   });
 
   test('show/hide diffs disabled for large amounts of files', async () => {
-    element.changeNum = 42 as NumericChangeId;
     element.basePatchNum = PARENT;
     element.patchNum = '2' as PatchSetNum;
     element.shownFileCount = 1;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 071c489..d4defcb 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -78,9 +78,11 @@
 import {incrementalRepeat} from '../../lit/incremental-repeat';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {HtmlPatched} from '../../../utils/lit-util';
-import {createDiffUrl} from '../../../models/views/diff';
-import {createEditUrl} from '../../../models/views/edit';
-import {createChangeUrl} from '../../../models/views/change';
+import {
+  createDiffUrl,
+  createEditUrl,
+  createChangeUrl,
+} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {FileMode, fileModeToString} from '../../../utils/file-util';
@@ -758,7 +760,7 @@
     );
     subscribe(
       this,
-      () => this.getFilesModel().filesWithUnmodified$,
+      () => this.getFilesModel().filesIncludingUnmodified$,
       files => {
         this.files = [...files];
       }
@@ -2121,9 +2123,9 @@
     this.getNavigation().setUrl(
       createDiffUrl({
         change: this.change,
-        path: diff.path,
         patchNum: this.patchRange.patchNum,
         basePatchNum: this.patchRange.basePatchNum,
+        diffView: {path: diff.path},
       })
     );
   }
@@ -2142,9 +2144,9 @@
     this.getNavigation().setUrl(
       createDiffUrl({
         change: this.change,
-        path: this.files[this.fileCursor.index].__path,
         patchNum: this.patchRange.patchNum,
         basePatchNum: this.patchRange.basePatchNum,
+        diffView: {path: this.files[this.fileCursor.index].__path},
       })
     );
   }
@@ -2176,16 +2178,16 @@
       return createEditUrl({
         changeNum: this.change._number,
         repo: this.change.project,
-        path,
         patchNum: this.patchRange.patchNum,
+        editView: {path},
       });
     }
     return createDiffUrl({
       changeNum: this.change._number,
       repo: this.change.project,
-      path,
       patchNum: this.patchRange.patchNum,
       basePatchNum: this.patchRange.basePatchNum,
+      diffView: {path},
     });
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 767c4cf..218594b 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -121,7 +121,6 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
 import {hasHumanReviewer, isOwner} from '../../../utils/change-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {
   CommentEditingChangedDetail,
@@ -384,8 +383,6 @@
   private readonly restApiService: RestApiService =
     getAppContext().restApiService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
   private readonly getConfigModel = resolve(this, configModelToken);
@@ -671,9 +668,6 @@
       this,
       () => this.getCommentsModel().mentionedUsersInUnresolvedDrafts$,
       x => {
-        if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-          return;
-        }
         this.mentionedUsersInUnresolvedDrafts = x.filter(
           v => !this.isAlreadyReviewerOrCC(v)
         );
@@ -1441,18 +1435,13 @@
     ).filter(isDefined);
 
     for (const user of newAttentionSetUsers) {
-      let reason;
-      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-        reason =
-          getMentionedReason(
-            this.draftCommentThreads,
-            this.account,
-            user,
-            this.serverConfig
-          ) ?? '';
-      } else {
-        reason = getReplyByReason(this.account, this.serverConfig);
-      }
+      const reason =
+        getMentionedReason(
+          this.draftCommentThreads,
+          this.account,
+          user,
+          this.serverConfig
+        ) ?? '';
       reviewInput.add_to_attention_set.push({user: getUserId(user), reason});
     }
     reviewInput.remove_from_attention_set = [];
@@ -1943,6 +1932,7 @@
   }
 
   confirmPendingReviewer() {
+    this.reviewerConfirmationModal?.close();
     if (this.ccPendingConfirmation) {
       this.ccsList?.confirmGroup(this.ccPendingConfirmation.group);
       this.focusOn(FocusTarget.CCS);
@@ -1960,6 +1950,7 @@
   }
 
   cancelPendingReviewer() {
+    this.reviewerConfirmationModal?.close();
     this.ccPendingConfirmation = null;
     this.reviewerPendingConfirmation = null;
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index acb7c83..f7b3aec 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -13,7 +13,6 @@
   query,
   queryAll,
   queryAndAssert,
-  stubFlags,
   stubRestApi,
   waitUntilVisible,
 } from '../../../test/test-utils';
@@ -55,12 +54,10 @@
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
-import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {fixture, html, waitUntil, assert} from '@open-wc/testing';
 import {accountKey} from '../../../utils/account-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {Key, Modifier} from '../../../utils/dom-util';
 import {GrComment} from '../../shared/gr-comment/gr-comment';
 import {testResolver} from '../../../test/common-test-setup';
@@ -1284,40 +1281,6 @@
     });
   });
 
-  function getActiveElement() {
-    return document.activeElement;
-  }
-
-  function overlayObserver(mode: string) {
-    return new Promise(resolve => {
-      function listener() {
-        element.removeEventListener('iron-overlay-' + mode, listener);
-        resolve(mode);
-      }
-      element.addEventListener('iron-overlay-' + mode, listener);
-    });
-  }
-
-  function isFocusInsideElement(element: Element) {
-    // In Polymer 2 focused element either <paper-input> or nested
-    // native input <input> element depending on the current focus
-    // in browser window.
-    // For example, the focus is changed if the developer console
-    // get a focus.
-    let activeElement = getActiveElement();
-    while (activeElement) {
-      if (activeElement === element) {
-        return true;
-      }
-      if (activeElement.parentElement) {
-        activeElement = activeElement.parentElement;
-      } else {
-        activeElement = (activeElement.getRootNode() as ShadowRoot).host;
-      }
-    }
-    return false;
-  }
-
   async function testConfirmationDialog(cc?: boolean) {
     const yesButton = queryAndAssert<GrButton>(
       element,
@@ -1332,11 +1295,9 @@
     element.reviewerPendingConfirmation = null;
     await element.updateComplete;
     assert.isFalse(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationModal'))
+      isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
 
-    // Cause the confirmation dialog to display.
-    let observer = overlayObserver('opened');
     const group = {
       id: 'id' as GroupId,
       name: 'name' as GroupName,
@@ -1345,13 +1306,13 @@
       element.ccPendingConfirmation = {
         group,
         confirm: false,
-        count: 1,
+        count: 10,
       };
     } else {
       element.reviewerPendingConfirmation = {
         group,
         confirm: false,
-        count: 1,
+        count: 10,
       };
     }
     await element.updateComplete;
@@ -1368,40 +1329,35 @@
       );
     }
 
-    await observer;
     assert.isTrue(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationModal'))
+      isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
-    observer = overlayObserver('closed');
     const expected = 'Group name has 10 members';
     assert.notEqual(
       queryAndAssert<HTMLElement>(
         element,
-        'reviewerConfirmationModal'
+        '#reviewerConfirmationModal'
       ).innerText.indexOf(expected),
       -1
     );
-    noButton.click(); // close the overlay
-
-    await observer;
-    assert.isFalse(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationModal'))
+    noButton.click(); // close the dialog
+    await waitUntil(
+      () => !isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
 
+    // TODO(dhruvsri): figure out why focus is not on the input element
     // We should be focused on account entry input.
-    const reviewersEntry = queryAndAssert<GrAccountList>(element, '#reviewers');
-    assert.isTrue(
-      isFocusInsideElement(
-        queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
-      )
-    );
+    // const reviewersEntry = queryAndAssert<GrAccountList>(element, '#reviewers');
+    // assert.isTrue(
+    //   isFocusInsideElement(
+    //     queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
+    //   )
+    // );
 
     // No reviewer/CC should have been added.
     assert.equal(element.ccsList?.additions().length, 0);
     assert.equal(element.reviewersList?.additions().length, 0);
 
-    // Reopen confirmation dialog.
-    observer = overlayObserver('opened');
     if (cc) {
       element.ccPendingConfirmation = {
         group,
@@ -1415,48 +1371,57 @@
         count: 1,
       };
     }
+    await element.updateComplete;
 
-    await observer;
     assert.isTrue(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationModal'))
+      isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
-    observer = overlayObserver('closed');
-    yesButton.click(); // Confirm the group.
 
-    await observer;
-    assert.isFalse(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationModal'))
+    yesButton.click(); // Confirm the group.
+    await waitUntil(
+      () => !isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
     const additions = cc
       ? element.ccsList?.additions()
       : element.reviewersList?.additions();
     assert.deepEqual(additions, [
       {
+        confirmed: true,
+        id: 'id' as GroupId,
         name: 'name' as GroupName,
       },
     ]);
 
     // We should be focused on account entry input.
-    if (cc) {
-      const ccsEntry = queryAndAssert<GrAccountList>(element, '#ccs');
-      assert.isTrue(
-        isFocusInsideElement(
-          queryAndAssert<GrAutocomplete>(ccsEntry.entry, '#input').input!
-        )
-      );
-    } else {
-      const reviewersEntry = queryAndAssert<GrAccountList>(
-        element,
-        '#reviewers'
-      );
-      assert.isTrue(
-        isFocusInsideElement(
-          queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
-        )
-      );
-    }
+    // TODO(dhruvsri): figure out why focus is not on the input element
+    // if (cc) {
+    //   const ccsEntry = queryAndAssert<GrAccountList>(element, '#ccs');
+    //   assert.isTrue(
+    //     isFocusInsideElement(
+    //       queryAndAssert<GrAutocomplete>(ccsEntry.entry, '#input').input!
+    //     )
+    //   );
+    // } else {
+    //   const reviewersEntry = queryAndAssert<GrAccountList>(
+    //     element,
+    //     '#reviewers'
+    //   );
+    //   assert.isTrue(
+    //     isFocusInsideElement(
+    //       queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
+    //     )
+    //   );
+    // }
   }
 
+  test('cc confirmation', async () => {
+    testConfirmationDialog(true);
+  });
+
+  test('reviewer confirmation', async () => {
+    testConfirmationDialog(false);
+  });
+
   suite('reviewer toast for WIP changes', () => {
     let fireStub: sinon.SinonStub;
     setup(() => {
@@ -1521,14 +1486,6 @@
     });
   });
 
-  test('cc confirmation', async () => {
-    testConfirmationDialog(true);
-  });
-
-  test('reviewer confirmation', async () => {
-    testConfirmationDialog(false);
-  });
-
   test('reviewersMutated when account-text-change is fired from ccs', () => {
     assert.isFalse(element.reviewersMutated);
     assert.isTrue(queryAndAssert<GrAccountList>(element, '#ccs').allowAnyInput);
@@ -2568,9 +2525,6 @@
 
   suite('mention users', () => {
     setup(async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(true);
       element.account = createAccountWithId(1);
       element.requestUpdate();
       await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 80a1a9a..c09d2a2 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -42,7 +42,6 @@
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
 import {Interaction} from '../../../constants/reporting';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {HtmlPatched} from '../../../utils/lit-util';
 import {userModelToken} from '../../../models/user/user-model';
 import {specialFilePathCompare} from '../../../utils/path-list-util';
@@ -205,8 +204,6 @@
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly patched = new HtmlPatched(key => {
@@ -495,14 +492,10 @@
       value: CommentTabState.UNRESOLVED,
     });
     if (this.account) {
-      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-        items.push({
-          text: `Mentions (${
-            getMentionedThreads(threads, this.account).length
-          })`,
-          value: CommentTabState.MENTIONS,
-        });
-      }
+      items.push({
+        text: `Mentions (${getMentionedThreads(threads, this.account).length})`,
+        value: CommentTabState.MENTIONS,
+      });
       items.push({
         text: `Drafts (${threads.filter(isDraftThread).length})`,
         value: CommentTabState.DRAFTS,
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 3fdb1c0..5792230 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -75,8 +75,7 @@
 import {HtmlPatched} from '../../utils/lit-util';
 import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
 import './gr-checks-attempt';
-import {createDiffUrl} from '../../models/views/diff';
-import {changeViewModelToken} from '../../models/views/change';
+import {createDiffUrl, changeViewModelToken} from '../../models/views/change';
 
 /**
  * Firing this event sets the regular expression of the results filter.
@@ -715,9 +714,8 @@
         url: createDiffUrl({
           changeNum: change._number,
           repo: change.project,
-          path,
           patchNum: patchset,
-          lineNum: line,
+          diffView: {path, lineNum: line},
         }),
         primary: true,
       };
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index 0a21da4..efc6efe 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -8,11 +8,21 @@
 import {LitElement, css, html, PropertyValues, nothing} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {RunResult} from '../../models/checks/checks-model';
-import {createFixAction, iconFor} from '../../models/checks/checks-util';
+import {
+  createFixAction,
+  createPleaseFixComment,
+  iconFor,
+} from '../../models/checks/checks-util';
 import {modifierPressed} from '../../utils/dom-util';
 import './gr-checks-results';
 import './gr-hovercard-run';
 import {fontStyles} from '../../styles/gr-font-styles';
+import {Action} from '../../api/checks';
+import {assertIsDefined} from '../../utils/common-util';
+import {resolve} from '../../models/dependency';
+import {commentsModelToken} from '../../models/comments/comments-model';
+import {subscribe} from '../lit/subscription-controller';
+import {changeModelToken} from '../../models/change/change-model';
 
 @customElement('gr-diff-check-result')
 export class GrDiffCheckResult extends LitElement {
@@ -32,6 +42,13 @@
   @state()
   isExpandable = false;
 
+  @state()
+  isOwner = false;
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
   static override get styles() {
     return [
       fontStyles,
@@ -114,6 +131,15 @@
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().isOwner$,
+      x => (this.isOwner = x)
+    );
+  }
+
   override render() {
     if (!this.result) return;
     const cat = this.result.category.toLowerCase();
@@ -182,14 +208,39 @@
 
   private renderActions() {
     if (!this.isExpanded) return nothing;
-    return html`<div class="actions">${this.renderFixButton()}</div>`;
+    return html`<div class="actions">
+      ${this.renderPleaseFixButton()}${this.renderShowFixButton()}
+    </div>`;
   }
 
-  private renderFixButton() {
+  private renderPleaseFixButton() {
+    if (this.isOwner) return nothing;
+    const action: Action = {
+      name: 'Please Fix',
+      callback: () => {
+        assertIsDefined(this.result, 'result');
+        this.getCommentsModel().saveDraft(createPleaseFixComment(this.result));
+        return undefined;
+      },
+    };
+    return html`
+      <gr-checks-action
+        id="please-fix"
+        context="diff-fix"
+        .action=${action}
+      ></gr-checks-action>
+    `;
+  }
+
+  private renderShowFixButton() {
     const action = createFixAction(this, this.result);
     if (!action) return nothing;
     return html`
-      <gr-checks-action context="diff-fix" .action=${action}></gr-checks-action>
+      <gr-checks-action
+        id="show-fix"
+        context="diff-fix"
+        .action=${action}
+      ></gr-checks-action>
     `;
   }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index 3892c9a..0377e0e 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -7,6 +7,7 @@
 import {fakeRun1} from '../../models/checks/checks-fakes';
 import {RunResult} from '../../models/checks/checks-model';
 import '../../test/common-test-setup';
+import {queryAndAssert} from '../../utils/common-util';
 import './gr-diff-check-result';
 import {GrDiffCheckResult} from './gr-diff-check-result';
 
@@ -50,4 +51,30 @@
     `
     );
   });
+
+  test('renders expanded', async () => {
+    element.result = {...fakeRun1, ...fakeRun1.results?.[2]} as RunResult;
+    element.isExpanded = true;
+    await element.updateComplete;
+
+    const details = queryAndAssert(element, 'div.details');
+    assert.dom.equal(
+      details,
+      /* HTML */ `
+        <div class="details">
+          <gr-result-expanded hidecodepointers=""></gr-result-expanded>
+          <div class="actions">
+            <gr-checks-action
+              id="please-fix"
+              context="diff-fix"
+            ></gr-checks-action>
+            <gr-checks-action
+              id="show-fix"
+              context="diff-fix"
+            ></gr-checks-action>
+          </div>
+        </div>
+      `
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 2b7d1ff..1176ce3 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -5,7 +5,6 @@
  */
 import '../gr-error-dialog/gr-error-dialog';
 import '../../shared/gr-alert/gr-alert';
-import '../../shared/gr-overlay/gr-overlay';
 import {getBaseUrl} from '../../../utils/url-util';
 import {getAppContext} from '../../../services/app-context';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index 1c4e237..833a91a 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -204,18 +204,22 @@
           text-decoration: underline;
         }
         .titleText::before {
+          --icon-width: var(--header-icon-width, var(--header-icon-size, 0));
+          --icon-height: var(--header-icon-height, var(--header-icon-size, 0));
           background-image: var(--header-icon);
-          background-size: var(--header-icon-size) var(--header-icon-size);
+          background-size: var(--icon-width) var(--icon-height);
           background-repeat: no-repeat;
           content: '';
           display: inline-block;
-          height: var(--header-icon-size);
-          margin-right: calc(var(--header-icon-size) / 4);
+          height: var(--icon-height);
+          /* If size or height are set, then use 'spacing-m', 0px otherwise. */
+          margin-right: clamp(0px, var(--icon-height), var(--spacing-m));
           vertical-align: text-bottom;
-          width: var(--header-icon-size);
+          width: var(--icon-width);
         }
         .titleText::after {
           content: var(--header-title-content);
+          white-space: nowrap;
         }
         ul {
           list-style: none;
@@ -424,17 +428,19 @@
   private renderAccount() {
     return html`
       <div class="accountContainer" id="accountContainer">
-        <gr-icon
-          id="mobileSearch"
-          icon="search"
-          @click=${(e: Event) => {
-            this.onMobileSearchTap(e);
-          }}
-          role="button"
-          aria-label=${this.mobileSearchHidden
-            ? 'Show Searchbar'
-            : 'Hide Searchbar'}
-        ></gr-icon>
+        <div>
+          <gr-icon
+            id="mobileSearch"
+            icon="search"
+            @click=${(e: Event) => {
+              this.onMobileSearchTap(e);
+            }}
+            role="button"
+            aria-label=${this.mobileSearchHidden
+              ? 'Show Searchbar'
+              : 'Hide Searchbar'}
+          ></gr-icon>
+        </div>
         ${this.renderRegister()}
         <a class="loginButton" href=${this.loginUrl}>Sign in</a>
         <a
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index bca2634..7eb19f0 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -75,13 +75,15 @@
             </gr-endpoint-decorator>
           </div>
           <div class="accountContainer" id="accountContainer">
-            <gr-icon
-              aria-label="Hide Searchbar"
-              icon="search"
-              id="mobileSearch"
-              role="button"
-            >
-            </gr-icon>
+            <div>
+              <gr-icon
+                aria-label="Hide Searchbar"
+                icon="search"
+                id="mobileSearch"
+                role="button"
+              >
+              </gr-icon>
+            </div>
             <a class="loginButton" href="/login"> Sign in </a>
             <a
               aria-label="Settings"
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 6caa000..bcf6937 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -10,7 +10,11 @@
 } from '../../../utils/page-wrapper-utils';
 import {NavigationService} from '../gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
-import {convertToPatchSetNum} from '../../../utils/patch-set-util';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  convertToPatchSetNum,
+} from '../../../utils/patch-set-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {
   BasePatchSetNum,
@@ -27,7 +31,7 @@
 import {AppElement, AppElementParams} from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
 import {GerritView, RouterModel} from '../../../services/router/router-model';
-import {firePageError} from '../../../utils/event-util';
+import {fireAlert, firePageError} from '../../../utils/event-util';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {
   getBaseUrl,
@@ -56,17 +60,18 @@
   RepoViewState,
 } from '../../../models/views/repo';
 import {
+  createGroupUrl,
   GroupDetailView,
   GroupViewModel,
   GroupViewState,
 } from '../../../models/views/group';
-import {DiffViewModel, DiffViewState} from '../../../models/views/diff';
 import {
+  ChangeChildView,
   ChangeViewModel,
   ChangeViewState,
-  createChangeUrl,
+  createChangeViewUrl,
+  createDiffUrl,
 } from '../../../models/views/change';
-import {EditViewModel, EditViewState} from '../../../models/views/edit';
 import {
   DashboardViewModel,
   DashboardViewState,
@@ -87,6 +92,13 @@
 import {SearchViewModel, SearchViewState} from '../../../models/views/search';
 import {DashboardSection} from '../../../utils/dashboard-util';
 import {Subscription} from 'rxjs';
+import {
+  addPath,
+  findComment,
+  getPatchRangeForCommentUrl,
+  isInBaseOfPatchRange,
+} from '../../../utils/comment-util';
+import {isFileUnchanged} from '../../../embed/diff/gr-diff/gr-diff-utils';
 
 const RoutePattern = {
   ROOT: '/',
@@ -302,9 +314,7 @@
     private readonly agreementViewModel: AgreementViewModel,
     private readonly changeViewModel: ChangeViewModel,
     private readonly dashboardViewModel: DashboardViewModel,
-    private readonly diffViewModel: DiffViewModel,
     private readonly documentationViewModel: DocumentationViewModel,
-    private readonly editViewModel: EditViewModel,
     private readonly groupViewModel: GroupViewModel,
     private readonly pluginViewModel: PluginViewModel,
     private readonly repoViewModel: RepoViewModel,
@@ -321,7 +331,7 @@
         // So this check is slightly fragile, but should work.
         if (this.view !== GerritView.CHANGE) return;
         const browserUrl = new URL(window.location.toString());
-        const stateUrl = new URL(createChangeUrl(state), browserUrl);
+        const stateUrl = new URL(createChangeViewUrl(state), browserUrl);
 
         // Keeping the hash and certain parameters are stop-gap solution. We
         // should find better ways of maintaining an overall consistent URL
@@ -362,13 +372,14 @@
     if ('repo' in state && state.repo !== undefined && 'changeNum' in state)
       this.restApiService.setInProjectLookup(state.changeNum, state.repo);
 
-    this.routerModel.setState({
-      view: state.view,
-      changeNum: 'changeNum' in state ? state.changeNum : undefined,
-      patchNum: 'patchNum' in state ? state.patchNum ?? undefined : undefined,
-      basePatchNum:
-        'basePatchNum' in state ? state.basePatchNum ?? undefined : undefined,
-    });
+    this.routerModel.setState({view: state.view});
+    // We are trying to reset the change (view) model when navigating to other
+    // views, because we don't trust our reset logic at the moment. The models
+    // singletons and might unintentionally keep state from one change to
+    // another. TODO: Let's find some way to avoid that.
+    if (state.view !== GerritView.CHANGE) {
+      this.changeViewModel.setState(undefined);
+    }
     this.appElement().params = state;
   }
 
@@ -1067,7 +1078,8 @@
   }
 
   handleGroupInfoRoute(ctx: PageContext) {
-    this.redirect('/admin/groups/' + encodeURIComponent(ctx.params[0]));
+    const groupId = ctx.params[0] as GroupId;
+    this.redirect(createGroupUrl({groupId}));
   }
 
   handleGroupSelfRedirectRoute(_: PageContext) {
@@ -1151,6 +1163,8 @@
       }
     }
 
+    // TODO: Change the route pattern to match `repo` and `detailView`
+    // separately, and then use `createRepoUrl()` here.
     this.redirect(`/admin/repos/${params}`);
   }
 
@@ -1437,6 +1451,7 @@
       basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
       patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
       view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
     };
 
     const queryMap = new URLSearchParams(ctx.querystring);
@@ -1467,21 +1482,57 @@
     this.changeViewModel.setState(state);
   }
 
-  handleCommentRoute(ctx: PageContext) {
+  async handleCommentRoute(ctx: PageContext) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const state: DiffViewState = {
-      repo: ctx.params[0] as RepoName,
+    const repo = ctx.params[0] as RepoName;
+    const commentId = ctx.params[2] as UrlEncodedCommentId;
+
+    const comments = await this.restApiService.getDiffComments(changeNum);
+    const change = await this.restApiService.getChangeDetail(changeNum);
+
+    const comment = findComment(addPath(comments), commentId);
+    const path = comment?.path;
+    const patchsets = computeAllPatchSets(change);
+    const latestPatchNum = computeLatestPatchNum(patchsets);
+    if (!comment || !path || !latestPatchNum) {
+      this.show404();
+      return;
+    }
+    let {basePatchNum, patchNum} = getPatchRangeForCommentUrl(
+      comment,
+      latestPatchNum
+    );
+
+    if (basePatchNum !== PARENT) {
+      const diff = await this.restApiService.getDiff(
+        changeNum,
+        basePatchNum,
+        patchNum,
+        path
+      );
+      if (diff && isFileUnchanged(diff)) {
+        fireAlert(
+          document,
+          `File is unchanged between Patchset ${basePatchNum} and ${patchNum}.
+           Showing diff of Base vs ${basePatchNum}.`
+        );
+        patchNum = basePatchNum as RevisionPatchSetNum;
+        basePatchNum = PARENT;
+      }
+    }
+
+    const diffUrl = createDiffUrl({
       changeNum,
-      commentId: ctx.params[2] as UrlEncodedCommentId,
-      view: GerritView.DIFF,
-      commentLink: true,
-    };
-    this.reporting.setRepoName(state.repo ?? '');
-    this.reporting.setChangeId(changeNum);
-    this.normalizePatchRangeParams(state);
-    // Note that router model view must be updated before view models.
-    this.setState(state);
-    this.diffViewModel.setState(state);
+      repo,
+      patchNum,
+      basePatchNum,
+      diffView: {
+        path,
+        lineNum: comment.line,
+        leftSide: isInBaseOfPatchRange(comment, {basePatchNum, patchNum}),
+      },
+    });
+    this.redirect(diffUrl);
   }
 
   handleCommentsRoute(ctx: PageContext) {
@@ -1491,6 +1542,7 @@
       changeNum,
       commentId: ctx.params[2] as UrlEncodedCommentId,
       view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
     };
     assertIsDefined(state.repo);
     this.reporting.setRepoName(state.repo);
@@ -1504,25 +1556,26 @@
   handleDiffRoute(ctx: PageContext) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
     // Parameter order is based on the regex group number matched.
-    const state: DiffViewState = {
+    const state: ChangeViewState = {
       repo: ctx.params[0] as RepoName,
       changeNum,
       basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
       patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
-      path: ctx.params[8],
-      view: GerritView.DIFF,
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.DIFF,
+      diffView: {path: ctx.params[8]},
     };
     const address = this.parseLineAddress(ctx.hash);
     if (address) {
-      state.leftSide = address.leftSide;
-      state.lineNum = address.lineNum;
+      state.diffView!.leftSide = address.leftSide;
+      state.diffView!.lineNum = address.lineNum;
     }
     this.reporting.setRepoName(state.repo ?? '');
     this.reporting.setChangeId(changeNum);
     this.normalizePatchRangeParams(state);
     // Note that router model view must be updated before view models.
     this.setState(state);
-    this.diffViewModel.setState(state);
+    this.changeViewModel.setState(state);
   }
 
   handleChangeLegacyRoute(ctx: PageContext) {
@@ -1550,19 +1603,19 @@
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const state: EditViewState = {
+    const state: ChangeViewState = {
       repo: project,
       changeNum,
       // for edit view params, patchNum cannot be undefined
       patchNum: convertToPatchSetNum(ctx.params[2]) as RevisionPatchSetNum,
-      path: ctx.params[3],
-      lineNum: Number(ctx.hash),
-      view: GerritView.EDIT,
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.EDIT,
+      editView: {path: ctx.params[3], lineNum: Number(ctx.hash)},
     };
     this.normalizePatchRangeParams(state);
     // Note that router model view must be updated before view models.
     this.setState(state);
-    this.editViewModel.setState(state);
+    this.changeViewModel.setState(state);
     this.reporting.setRepoName(project);
     this.reporting.setChangeId(changeNum);
   }
@@ -1577,6 +1630,7 @@
       changeNum,
       patchNum: convertToPatchSetNum(ctx.params[3]) as RevisionPatchSetNum,
       view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
       edit: true,
     };
     const tab = queryMap.get('tab');
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index b8f68e6..d8761bf 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -28,10 +28,16 @@
 import {AdminChildView} from '../../../models/views/admin';
 import {RepoDetailView} from '../../../models/views/repo';
 import {GroupDetailView} from '../../../models/views/group';
-import {EditViewState} from '../../../models/views/edit';
-import {ChangeViewState} from '../../../models/views/change';
+import {ChangeChildView, ChangeViewState} from '../../../models/views/change';
 import {PatchRangeParams} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
+import {
+  createComment,
+  createDiff,
+  createParsedChange,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {ParsedChangeInfo} from '../../../types/types';
 
 suite('gr-router tests', () => {
   let router: GrRouter;
@@ -1134,6 +1140,7 @@
           const ctx = makeParams('', '');
           assertctxToParams(ctx, 'handleChangeRoute', {
             view: GerritView.CHANGE,
+            childView: ChangeChildView.OVERVIEW,
             repo: 'foo/bar' as RepoName,
             changeNum: 1234 as NumericChangeId,
             basePatchNum: 4 as BasePatchSetNum,
@@ -1154,6 +1161,7 @@
           ctx.querystring = queryMap.toString();
           assertctxToParams(ctx, 'handleChangeRoute', {
             view: GerritView.CHANGE,
+            childView: ChangeChildView.OVERVIEW,
             repo: 'foo/bar' as RepoName,
             changeNum: 1234 as NumericChangeId,
             basePatchNum: 4 as BasePatchSetNum,
@@ -1193,36 +1201,89 @@
         test('diff view', () => {
           const ctx = makeParams('foo/bar/baz', 'b44');
           assertctxToParams(ctx, 'handleDiffRoute', {
-            view: GerritView.DIFF,
+            view: GerritView.CHANGE,
+            childView: ChangeChildView.DIFF,
             repo: 'foo/bar' as RepoName,
             changeNum: 1234 as NumericChangeId,
             basePatchNum: 4 as BasePatchSetNum,
             patchNum: 7 as RevisionPatchSetNum,
-            path: 'foo/bar/baz',
-            leftSide: true,
-            lineNum: 44,
+            diffView: {
+              path: 'foo/bar/baz',
+              lineNum: 44,
+              leftSide: true,
+            },
           });
           assert.isFalse(redirectStub.called);
         });
 
-        test('comment route', () => {
-          const url = '/c/gerrit/+/264833/comment/00049681_f34fd6a9/';
+        test('comment route base..1', async () => {
+          const change: ParsedChangeInfo = createParsedChange();
+          const repo = change.project;
+          const changeNum = change._number;
+          const ps = 1 as RevisionPatchSetNum;
+          const line = 23;
+          const id = '00049681_f34fd6a9' as UrlEncodedCommentId;
+          stubRestApi('getChangeDetail').resolves(change);
+          stubRestApi('getDiffComments').resolves({
+            filepath: [{...createComment(), id, patch_set: ps, line}],
+          });
+
+          const url = `/c/${repo}/+/${changeNum}/comment/${id}/`;
           const groups = url.match(_testOnly_RoutePattern.COMMENT);
-          assert.deepEqual(groups!.slice(1), [
-            'gerrit', // project
-            '264833', // changeNum
-            '00049681_f34fd6a9', // commentId
-          ]);
-          assertctxToParams(
-            {params: groups!.slice(1)} as any,
-            'handleCommentRoute',
-            {
-              repo: 'gerrit' as RepoName,
-              changeNum: 264833 as NumericChangeId,
-              commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
-              commentLink: true,
-              view: GerritView.DIFF,
-            }
+          assert.deepEqual(groups!.slice(1), [repo, `${changeNum}`, id]);
+
+          await router.handleCommentRoute({params: groups!.slice(1)} as any);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.equal(
+            redirectStub.lastCall.args[0],
+            `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
+          );
+        });
+
+        test('comment route 1..2', async () => {
+          const change: ParsedChangeInfo = {
+            ...createParsedChange(),
+            revisions: {
+              abc: createRevision(1),
+              def: createRevision(2),
+            },
+          };
+          const repo = change.project;
+          const changeNum = change._number;
+          const ps = 1 as RevisionPatchSetNum;
+          const line = 23;
+          const id = '00049681_f34fd6a9' as UrlEncodedCommentId;
+
+          stubRestApi('getChangeDetail').resolves(change);
+          stubRestApi('getDiffComments').resolves({
+            filepath: [{...createComment(), id, patch_set: ps, line}],
+          });
+          const diffStub = stubRestApi('getDiff');
+
+          const url = `/c/${repo}/+/${changeNum}/comment/${id}/`;
+          const groups = url.match(_testOnly_RoutePattern.COMMENT);
+
+          // If getDiff() returns a diff with changes, then we will compare
+          // the patchset of the comment (1) against latest (2).
+          diffStub.onFirstCall().resolves(createDiff());
+          await router.handleCommentRoute({params: groups!.slice(1)} as any);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.equal(
+            redirectStub.lastCall.args[0],
+            `/c/${repo}/+/${changeNum}/${ps}..2/filepath#b${line}`
+          );
+
+          // If getDiff() returns an unchanged diff, then we will compare
+          // the patchset of the comment (1) against base.
+          diffStub.onSecondCall().resolves({
+            ...createDiff(),
+            content: [],
+          });
+          await router.handleCommentRoute({params: groups!.slice(1)} as any);
+          assert.isTrue(redirectStub.calledTwice);
+          assert.equal(
+            redirectStub.lastCall.args[0],
+            `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
           );
         });
 
@@ -1242,6 +1303,7 @@
               changeNum: 264833 as NumericChangeId,
               commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
               view: GerritView.CHANGE,
+              childView: ChangeChildView.OVERVIEW,
             }
           );
         });
@@ -1259,13 +1321,13 @@
             3: 'foo/bar/baz', // 3 File path
           },
         };
-        const appParams: EditViewState = {
+        const appParams: ChangeViewState = {
           repo: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
-          view: GerritView.EDIT,
-          path: 'foo/bar/baz',
+          view: GerritView.CHANGE,
+          childView: ChangeChildView.EDIT,
           patchNum: 3 as RevisionPatchSetNum,
-          lineNum: 0,
+          editView: {path: 'foo/bar/baz', lineNum: 0},
         };
 
         router.handleDiffEditRoute(ctx);
@@ -1285,13 +1347,13 @@
             3: 'foo/bar/baz', // 3 File path
           },
         };
-        const appParams: EditViewState = {
+        const appParams: ChangeViewState = {
           repo: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
-          view: GerritView.EDIT,
-          path: 'foo/bar/baz',
+          view: GerritView.CHANGE,
+          childView: ChangeChildView.EDIT,
           patchNum: 3 as RevisionPatchSetNum,
-          lineNum: 4,
+          editView: {path: 'foo/bar/baz', lineNum: 4},
         };
 
         router.handleDiffEditRoute(ctx);
@@ -1314,6 +1376,7 @@
           repo: 'foo/bar' as RepoName,
           changeNum: 1234 as NumericChangeId,
           view: GerritView.CHANGE,
+          childView: ChangeChildView.OVERVIEW,
           patchNum: 3 as RevisionPatchSetNum,
           edit: true,
         };
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index bbca52d..b146e93 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -6,7 +6,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-icon/gr-icon';
-import '../../shared/gr-overlay/gr-overlay';
 import '../../../embed/diff/gr-diff/gr-diff';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 26043b32..6eb5243 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -7,7 +7,6 @@
   PatchRange,
   PatchSetNum,
   RobotCommentInfo,
-  UrlEncodedCommentId,
   PathToCommentsInfoMap,
   FileInfo,
   PARENT,
@@ -64,26 +63,6 @@
     return this._drafts;
   }
 
-  findCommentById(
-    commentId?: UrlEncodedCommentId
-  ): CommentInfo | DraftInfo | undefined {
-    if (!commentId) return undefined;
-    const findComment = (comments: {
-      [path: string]: (CommentInfo | DraftInfo)[];
-    }) => {
-      let comment;
-      for (const path of Object.keys(comments)) {
-        comment = comment || comments[path].find(c => c.id === commentId);
-      }
-      return comment;
-    };
-    return (
-      findComment(this._comments) ||
-      findComment(this._robotComments) ||
-      findComment(this._drafts)
-    );
-  }
-
   /**
    * Get an object mapping file paths to a boolean representing whether that
    * path contains diff comments in the given patch set (including drafts and
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index eb2b494..23dce97 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -26,12 +26,7 @@
   isInBaseOfPatchRange,
   isInRevisionOfPatchRange,
 } from '../../../utils/comment-util';
-import {
-  CommitRange,
-  CoverageRange,
-  DiffLayer,
-  PatchSetFile,
-} from '../../../types/types';
+import {CoverageRange, DiffLayer, PatchSetFile} from '../../../types/types';
 import {
   Base64ImageFile,
   BlameInfo,
@@ -192,9 +187,6 @@
     fire(this, 'is-image-diff-changed', {value: isImageDiff});
   }
 
-  @property({type: Object})
-  commitRange?: CommitRange;
-
   @state()
   private _editWeblinks?: GeneratedWebLink[];
 
@@ -355,9 +347,7 @@
     );
     this.renderPrefs = {
       ...this.renderPrefs,
-      use_lit_components: this.flags.isEnabled(
-        KnownExperimentId.DIFF_RENDERING_LIT
-      ),
+      use_lit_components: true,
     };
     this.addEventListener(
       // These are named inconsistently for a reason:
@@ -607,7 +597,11 @@
     this.hasReloadBeenCalledOnce = true;
     this.reporting.time(Timing.DIFF_TOTAL);
     this.reporting.time(Timing.DIFF_LOAD);
+    // TODO: Find better names for these 3 clear/cancel methods. Ideally the
+    // <gr-diff-host> should not re-used at all for another diff rendering pass.
     this.clear();
+    this.cancel();
+    this.clearDiffContent();
     assertIsDefined(this.path, 'path');
     assertIsDefined(this.changeNum, 'changeNum');
     this.diff = undefined;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index b68adf1..c74993f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -20,22 +20,12 @@
 import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../gr-patch-range-select/gr-patch-range-select';
 import '../../change/gr-download-dialog/gr-download-dialog';
-import '../../shared/gr-overlay/gr-overlay';
-import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getAppContext} from '../../../services/app-context';
+import {isMergeParent, getParentIndex} from '../../../utils/patch-set-util';
 import {
-  computeAllPatchSets,
-  computeLatestPatchNum,
-  PatchSet,
-  isMergeParent,
-  getParentIndex,
-} from '../../../utils/patch-set-util';
-import {
-  addUnmodifiedFiles,
   computeDisplayPath,
   computeTruncatedPath,
   isMagicPath,
-  specialFilePathCompare,
 } from '../../../utils/path-list-util';
 import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
@@ -43,56 +33,36 @@
   DropdownItem,
   GrDropdownList,
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
   BasePatchSetNum,
-  ChangeInfo,
-  CommitId,
   EDIT,
-  FileInfo,
   NumericChangeId,
   PARENT,
   PatchRange,
-  PatchSetNum,
   PatchSetNumber,
   PreferencesInfo,
   RepoName,
-  RevisionInfo,
   RevisionPatchSetNum,
   ServerInfo,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {
-  CommitRange,
-  EditRevisionInfo,
-  FileRange,
-  ParsedChangeInfo,
-} from '../../../types/types';
+import {FileRange, ParsedChangeInfo} from '../../../types/types';
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
 import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
 import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
-import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
-import {
-  CommentMap,
-  getPatchRangeForCommentUrl,
-  isInBaseOfPatchRange,
-} from '../../../utils/comment-util';
+import {CommentMap} from '../../../utils/comment-util';
 import {
   EventType,
   OpenFixPreviewEvent,
   ValueChangedEvent,
 } from '../../../types/events';
 import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
-import {
-  GerritView,
-  routerModelToken,
-} from '../../../services/router/router-model';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 import {Key, toggleClass, whenVisible} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
-import {isFalse, throttleWrap, until} from '../../../utils/async-util';
+import {throttleWrap} from '../../../utils/async-util';
 import {filter, take, switchMap} from 'rxjs/operators';
 import {combineLatest} from 'rxjs';
 import {
@@ -100,14 +70,12 @@
   ShortcutSection,
   shortcutsServiceToken,
 } from '../../../services/shortcuts/shortcuts-service';
-import {LoadingStatus} from '../../../models/change/change-model';
 import {DisplayLine} from '../../../api/diff';
 import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {resolve} from '../../../models/dependency';
-import {BehaviorSubject} from 'rxjs';
-import {css, html, LitElement, PropertyValues} from 'lit';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {subscribe} from '../../lit/subscription-controller';
 import {customElement, property, query, state} from 'lit/decorators.js';
@@ -118,15 +86,18 @@
 import {when} from 'lit/directives/when.js';
 import {
   createDiffUrl,
-  diffViewModelToken,
-  DiffViewState,
-} from '../../../models/views/diff';
-import {createChangeUrl} from '../../../models/views/change';
-import {createEditUrl} from '../../../models/views/edit';
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
 import {GeneratedWebLink} from '../../../utils/weblink-util';
 import {userModelToken} from '../../../models/user/user-model';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {GrDiffPreferencesDialog} from '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import {
+  FileNameToNormalizedFileInfoMap,
+  filesModelToken,
+} from '../../../models/change/files-model';
 
 const LOADING_BLAME = 'Loading blame...';
 const LOADED_BLAME = 'Blame loaded';
@@ -136,14 +107,11 @@
 
 // visible for testing
 export interface Files {
-  sortedFileList: string[];
-  changeFilesByPath: {[path: string]: FileInfo};
+  /** All file paths sorted by `specialFilePathCompare`. */
+  sortedPaths: string[];
+  changeFilesByPath: FileNameToNormalizedFileInfoMap;
 }
 
-interface CommentSkips {
-  previous: string | null;
-  next: string | null;
-}
 @customElement('gr-diff-view')
 export class GrDiffView extends LitElement {
   /**
@@ -160,8 +128,8 @@
   @query('#diffHost')
   diffHost?: GrDiffHost;
 
-  @query('#reviewed')
-  reviewed?: HTMLInputElement;
+  @state()
+  reviewed = false;
 
   @query('#downloadModal')
   downloadModal?: HTMLDialogElement;
@@ -176,35 +144,33 @@
   applyFixDialog?: GrApplyFixDialog;
 
   @query('#diffPreferencesDialog')
-  diffPreferencesDialog?: GrOverlay;
+  diffPreferencesDialog?: GrDiffPreferencesDialog;
 
-  private _viewState: DiffViewState | undefined;
-
+  // Private but used in tests.
   @state()
-  get viewState(): DiffViewState | undefined {
-    return this._viewState;
-  }
-
-  set viewState(viewState: DiffViewState | undefined) {
-    if (this._viewState === viewState) return;
-    const oldViewState = this._viewState;
-    this._viewState = viewState;
-    this.viewStateChanged();
-    this.requestUpdate('viewState', oldViewState);
+  get patchRange(): PatchRange | undefined {
+    if (!this.patchNum) return undefined;
+    return {
+      patchNum: this.patchNum,
+      basePatchNum: this.basePatchNum,
+    };
   }
 
   // Private but used in tests.
   @state()
-  patchRange?: PatchRange;
+  patchNum?: RevisionPatchSetNum;
 
   // Private but used in tests.
   @state()
-  commitRange?: CommitRange;
+  basePatchNum: BasePatchSetNum = PARENT;
 
   // Private but used in tests.
   @state()
   change?: ParsedChangeInfo;
 
+  @state()
+  latestPatchNum?: PatchSetNumber;
+
   // Private but used in tests.
   @state()
   changeComments?: ChangeComments;
@@ -217,10 +183,9 @@
   @state()
   diff?: DiffInfo;
 
-  // TODO: Move to using files-model.
   // Private but used in tests.
   @state()
-  files: Files = {sortedFileList: [], changeFilesByPath: {}};
+  files: Files = {sortedPaths: [], changeFilesByPath: {}};
 
   // Private but used in tests
   // Use path getter/setter.
@@ -238,13 +203,13 @@
     this.requestUpdate('path', oldPath);
   }
 
+  /** Allows us to react when the user switches to the DIFF view. */
   // Private but used in tests.
-  @state()
-  loggedIn = false;
+  @state() isActiveChildView = false;
 
   // Private but used in tests.
   @state()
-  loading = true;
+  loggedIn = false;
 
   @property({type: Object})
   prefs?: DiffPreferencesInfo;
@@ -267,65 +232,61 @@
 
   // Private but used in tests.
   @state()
-  commentMap?: CommentMap;
-
-  @state()
-  private commentSkips?: CommentSkips;
-
-  // Private but used in tests.
-  @state()
   isBlameLoaded?: boolean;
 
   @state()
   private isBlameLoading = false;
 
-  @state()
-  private allPatchSets?: PatchSet[] = [];
-
+  /** Directly reflects the view model property `diffView.lineNum`. */
   // Private but used in tests.
   @state()
   focusLineNum?: number;
 
+  /** Directly reflects the view model property `diffView.leftSide`. */
+  @state()
+  leftSide = false;
+
   // visible for testing
   reviewedFiles = new Set<string>();
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly restApiService = getAppContext().restApiService;
-
-  private readonly getRouterModel = resolve(this, routerModelToken);
-
   private readonly getUserModel = resolve(this, userModelToken);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
+  private readonly getFilesModel = resolve(this, filesModelToken);
+
   private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
-  private readonly getViewModel = resolve(this, diffViewModelToken);
+  private readonly getViewModel = resolve(this, changeViewModelToken);
 
   private throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
 
   @state()
   cursor?: GrDiffCursor;
 
-  private connected$ = new BehaviorSubject(false);
-
   private readonly shortcutsController = new ShortcutController(this);
 
-  private readonly getNavigation = resolve(this, navigationToken);
-
   constructor() {
     super();
     this.setupKeyboardShortcuts();
     this.setupSubscriptions();
     subscribe(
       this,
-      () => this.getViewModel().state$,
-      x => (this.viewState = x)
+      () => this.getFilesModel().filesIncludingUnmodified$,
+      files => {
+        const filesByPath: FileNameToNormalizedFileInfoMap = {};
+        for (const f of files) filesByPath[f.__path] = f;
+        this.files = {
+          sortedPaths: files.map(f => f.__path),
+          changeFilesByPath: filesByPath,
+        };
+      }
     );
   }
 
@@ -339,10 +300,10 @@
     listen(Shortcut.PREV_LINE, _ => this.handlePrevLine());
     listen(Shortcut.VISIBLE_LINE, _ => this.cursor?.moveToVisibleArea());
     listen(Shortcut.NEXT_FILE_WITH_COMMENTS, _ =>
-      this.moveToNextFileWithComment()
+      this.moveToFileWithComment(1)
     );
     listen(Shortcut.PREV_FILE_WITH_COMMENTS, _ =>
-      this.moveToPreviousFileWithComment()
+      this.moveToFileWithComment(-1)
     );
     listen(Shortcut.NEW_COMMENT, _ => this.handleNewComment());
     listen(Shortcut.SAVE_COMMENT, _ => {});
@@ -355,7 +316,9 @@
     listen(Shortcut.OPEN_REPLY_DIALOG, _ => this.handleOpenReplyDialog());
     listen(Shortcut.TOGGLE_LEFT_PANE, _ => this.handleToggleLeftPane());
     listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ => this.handleOpenDownloadDialog());
-    listen(Shortcut.UP_TO_CHANGE, _ => this.handleUpToChange());
+    listen(Shortcut.UP_TO_CHANGE, _ =>
+      this.getChangeModel().navigateToChange()
+    );
     listen(Shortcut.OPEN_DIFF_PREFS, _ => this.handleCommaKey());
     listen(Shortcut.TOGGLE_DIFF_MODE, _ => this.handleToggleDiffMode());
     listen(Shortcut.TOGGLE_FILE_REVIEWED, e => {
@@ -438,6 +401,11 @@
     );
     subscribe(
       this,
+      () => this.getChangeModel().latestPatchNum$,
+      latestPatchNum => (this.latestPatchNum = latestPatchNum)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().reviewedFiles$,
       reviewedFiles => {
         this.reviewedFiles = new Set(reviewedFiles) ?? new Set();
@@ -445,45 +413,82 @@
     );
     subscribe(
       this,
-      () => this.getChangeModel().diffPath$,
+      () => this.getViewModel().changeNum$,
+      changeNum => {
+        if (!changeNum || this.changeNum === changeNum) return;
+
+        // We are only setting the changeNum of the diff view once!
+        // Everything in the diff view is tied to the change. It seems better to
+        // force the re-creation of the diff view when the change number changes.
+        if (!this.changeNum) {
+          this.changeNum = changeNum;
+        } else {
+          fireEvent(this, EventType.RECREATE_DIFF_VIEW);
+        }
+      }
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().childView$,
+      childView => (this.isActiveChildView = childView === ChangeChildView.DIFF)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().diffPath$,
       path => (this.path = path)
     );
-
+    subscribe(
+      this,
+      () => this.getViewModel().diffLine$,
+      line => (this.focusLineNum = line)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().diffLeftSide$,
+      leftSide => (this.leftSide = leftSide)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().patchNum$,
+      patchNum => (this.patchNum = patchNum)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().basePatchNum$,
+      basePatchNum => (this.basePatchNum = basePatchNum ?? PARENT)
+    );
     subscribe(
       this,
       () =>
         combineLatest([
-          this.getChangeModel().diffPath$,
+          this.getViewModel().diffPath$,
           this.getChangeModel().reviewedFiles$,
         ]),
       ([path, files]) => {
-        this.updateComplete.then(() => {
-          assertIsDefined(this.reviewed, 'reviewed');
-          this.reviewed.checked = !!path && !!files && files.includes(path);
-        });
+        this.reviewed = !!path && !!files && files.includes(path);
       }
     );
 
-    // When user initially loads the diff view, we want to autmatically mark
+    // When user initially loads the diff view, we want to automatically mark
     // the file as reviewed if they have it enabled. We can't observe these
     // properties since the method will be called anytime a property updates
     // but we only want to call this on the initial load.
     subscribe(
       this,
       () =>
-        this.getChangeModel().diffPath$.pipe(
+        this.getViewModel().diffPath$.pipe(
           filter(diffPath => !!diffPath),
           switchMap(() =>
             combineLatest([
               this.getChangeModel().patchNum$,
-              this.getRouterModel().routerView$,
+              this.getViewModel().childView$,
               this.getUserModel().diffPreferences$,
               this.getChangeModel().reviewedFiles$,
             ]).pipe(
               filter(
-                ([patchNum, routerView, diffPrefs, reviewedFiles]) =>
+                ([patchNum, childView, diffPrefs, reviewedFiles]) =>
                   !!patchNum &&
-                  routerView === GerritView.DIFF &&
+                  childView === ChangeChildView.DIFF &&
                   !!diffPrefs &&
                   !!reviewedFiles
               ),
@@ -492,14 +497,11 @@
           )
         ),
       ([patchNum, _routerView, diffPrefs]) => {
-        this.setReviewedStatus(patchNum!, diffPrefs);
+        // `patchNum` must be defined, because of the `!!patchNum` filter above.
+        assertIsDefined(patchNum, 'patchNum');
+        this.setReviewedStatus(patchNum, diffPrefs);
       }
     );
-    subscribe(
-      this,
-      () => this.getChangeModel().diffPath$,
-      path => (this.path = path)
-    );
   }
 
   static override get styles() {
@@ -692,7 +694,6 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.connected$.next(true);
     this.throttledToggleFileReviewed = throttleWrap(_ =>
       this.handleToggleFileReviewed()
     );
@@ -703,38 +704,11 @@
 
   override disconnectedCallback() {
     this.cursor?.dispose();
-    this.connected$.next(false);
     super.disconnectedCallback();
   }
 
-  protected override willUpdate(changedProperties: PropertyValues) {
-    super.willUpdate(changedProperties);
-    if (changedProperties.has('change')) {
-      this.allPatchSets = computeAllPatchSets(this.change);
-    }
-    if (
-      changedProperties.has('commentMap') ||
-      changedProperties.has('files') ||
-      changedProperties.has('path')
-    ) {
-      this.commentSkips = this.computeCommentSkips(
-        this.commentMap,
-        this.files?.sortedFileList,
-        this.path
-      );
-    }
-
-    if (
-      changedProperties.has('changeNum') ||
-      changedProperties.has('changeComments') ||
-      changedProperties.has('patchRange')
-    ) {
-      this.fetchFiles();
-    }
-  }
-
   private reInitCursor() {
-    assertIsDefined(this.diffHost, 'diffHost');
+    if (!this.diffHost) return;
     this.cursor?.replaceDiffs([this.diffHost]);
     this.cursor?.reInitCursor();
   }
@@ -742,16 +716,35 @@
   protected override updated(changedProperties: PropertyValues): void {
     super.updated(changedProperties);
     if (
+      changedProperties.has('change') ||
+      changedProperties.has('path') ||
+      changedProperties.has('patchNum') ||
+      changedProperties.has('basePatchNum')
+    ) {
+      this.reloadDiff();
+    } else if (
+      changedProperties.has('isActiveChildView') &&
+      this.isActiveChildView
+    ) {
+      this.initializePositions();
+    }
+    if (
+      changedProperties.has('focusLineNum') ||
+      changedProperties.has('leftSide')
+    ) {
+      this.initCursor();
+    }
+    if (
+      changedProperties.has('change') ||
       changedProperties.has('changeComments') ||
       changedProperties.has('path') ||
-      changedProperties.has('patchRange') ||
+      changedProperties.has('patchNum') ||
+      changedProperties.has('basePatchNum') ||
       changedProperties.has('files')
     ) {
-      if (this.changeComments && this.path && this.patchRange) {
+      if (this.change && this.changeComments && this.path && this.patchRange) {
         assertIsDefined(this.diffHost, 'diffHost');
-        const file = this.files?.changeFilesByPath
-          ? this.files.changeFilesByPath[this.path]
-          : undefined;
+        const file = this.files?.changeFilesByPath?.[this.path];
         this.diffHost.updateComplete.then(() => {
           assertIsDefined(this.path);
           assertIsDefined(this.patchRange);
@@ -767,19 +760,21 @@
   }
 
   override render() {
+    if (!this.isActiveChildView) return nothing;
+    if (!this.patchNum || !this.changeNum || !this.change || !this.path) {
+      return html`<div class="loading">Loading...</div>`;
+    }
     const file = this.getFileRange();
     return html`
       ${this.renderStickyHeader()}
-      <div class="loading" ?hidden=${!this.loading}>Loading...</div>
       <h2 class="assistive-tech-only">Diff view</h2>
       <gr-diff-host
         id="diffHost"
-        ?hidden=${this.loading}
         .changeNum=${this.changeNum}
         .change=${this.change}
-        .commitRange=${this.commitRange}
         .patchRange=${this.patchRange}
         .file=${file}
+        .lineOfInterest=${this.getLineOfInterest()}
         .path=${this.path}
         .projectName=${this.change?.project}
         @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
@@ -798,7 +793,7 @@
 
   private renderStickyHeader() {
     return html` <div
-      class="stickyHeader ${this.computeEditMode() ? 'editMode' : ''}"
+      class="stickyHeader ${this.patchNum === EDIT ? 'editMode' : ''}"
     >
       <h1 class="assistive-tech-only">
         Diff of ${this.path ? computeTruncatedPath(this.path) : ''}
@@ -824,7 +819,8 @@
     const fileNum = this.computeFileNum(formattedFiles);
     const fileNumClass = this.computeFileNumClass(fileNum, formattedFiles);
     return html` <div>
-        <a href=${this.getChangePath()}>${this.changeNum}</a
+        <a href=${ifDefined(this.getChangeModel().changeUrl())}
+          >${this.changeNum}</a
         ><span class="changeNumberColon">:</span>
         <span class="headerSubject">${this.change?.subject}</span>
         <input
@@ -834,6 +830,7 @@
           ?hidden=${!this.loggedIn}
           title="Toggle reviewed status of file"
           aria-label="file reviewed"
+          .checked=${this.reviewed}
           @change=${this.handleReviewedChange}
         />
         <div class="jumpToFileContainer">
@@ -867,7 +864,7 @@
             Shortcut.UP_TO_CHANGE,
             ShortcutSection.NAVIGATION
           )}
-          href=${this.getChangePath()}
+          href=${ifDefined(this.getChangeModel().changeUrl())}
           >Up</a
         >
         <span class="separator"></span>
@@ -884,19 +881,10 @@
   }
 
   private renderPatchRangeLeft() {
-    const revisionInfo = this.change
-      ? new RevisionInfoObj(this.change)
-      : undefined;
     return html` <div class="patchRangeLeft">
       <gr-patch-range-select
         id="rangeSelect"
-        .changeNum=${this.changeNum}
-        .patchNum=${this.patchRange?.patchNum}
-        .basePatchNum=${this.patchRange?.basePatchNum}
         .filesWeblinks=${this.filesWeblinks}
-        .availablePatches=${this.allPatchSets}
-        .revisions=${this.change?.revisions}
-        .revisionInfo=${revisionInfo}
         @patch-range-change=${this.handlePatchChange}
       >
       </gr-patch-range-select>
@@ -1023,7 +1011,7 @@
         <gr-download-dialog
           id="downloadDialog"
           .change=${this.change}
-          .patchNum=${this.patchRange?.patchNum}
+          .patchNum=${this.patchNum}
           .config=${this.serverConfig?.download}
           @close=${this.handleDownloadDialogClose}
         ></gr-download-dialog>
@@ -1048,36 +1036,12 @@
     if (!this.files || !this.path) return;
     const fileInfo = this.files.changeFilesByPath[this.path];
     const fileRange: FileRange = {path: this.path};
-    if (fileInfo && fileInfo.old_path) {
+    if (fileInfo?.old_path) {
       fileRange.basePath = fileInfo.old_path;
     }
     return fileRange;
   }
 
-  // Private but used in tests.
-  fetchFiles() {
-    if (!this.changeNum || !this.patchRange || !this.changeComments) {
-      return Promise.resolve();
-    }
-
-    if (!this.patchRange.patchNum) {
-      return Promise.resolve();
-    }
-
-    return this.restApiService
-      .getChangeFiles(this.changeNum, this.patchRange)
-      .then(changeFiles => {
-        if (!changeFiles) return;
-        const commentedPaths = this.changeComments!.getPaths(this.patchRange);
-        const files = {...changeFiles};
-        addUnmodifiedFiles(files, commentedPaths);
-        this.files = {
-          sortedFileList: Object.keys(files).sort(specialFilePathCompare),
-          changeFilesByPath: files,
-        };
-      });
-  }
-
   private handleReviewedChange(e: Event) {
     const input = e.target as HTMLInputElement;
     this.setReviewed(input.checked ?? false);
@@ -1086,12 +1050,14 @@
   // Private but used in tests.
   setReviewed(
     reviewed: boolean,
-    patchNum: RevisionPatchSetNum | undefined = this.patchRange?.patchNum
+    patchNum: RevisionPatchSetNum | undefined = this.patchNum
   ) {
-    if (this.computeEditMode()) return;
+    if (this.patchNum === EDIT) return;
     if (!patchNum || !this.path || !this.changeNum) return;
     // if file is already reviewed then do not make a saveReview request
     if (this.reviewedFiles.has(this.path) && reviewed) return;
+    // optimistic update
+    this.reviewed = reviewed;
     this.getChangeModel().setReviewedFilesStatus(
       this.changeNum,
       patchNum,
@@ -1102,8 +1068,7 @@
 
   // Private but used in tests.
   handleToggleFileReviewed() {
-    assertIsDefined(this.reviewed);
-    this.setReviewed(!this.reviewed.checked);
+    this.setReviewed(!this.reviewed);
   }
 
   private handlePrevLine() {
@@ -1148,48 +1113,13 @@
   }
 
   // Private but used in tests.
-  moveToPreviousFileWithComment() {
-    if (!this.commentSkips) return;
-    if (!this.change) return;
-    if (!this.patchRange?.patchNum) return;
-
-    // If there is no previous diff with comments, then return to the change
-    // view.
-    if (!this.commentSkips.previous) {
-      this.navToChangeView();
-      return;
+  moveToFileWithComment(direction: -1 | 1) {
+    const path = this.findFileWithComment(direction);
+    if (!path) {
+      this.getChangeModel().navigateToChange();
+    } else {
+      this.getChangeModel().navigateToDiff({path});
     }
-
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.commentSkips.previous,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
-      })
-    );
-  }
-
-  // Private but used in tests.
-  moveToNextFileWithComment() {
-    if (!this.commentSkips) return;
-    if (!this.change) return;
-    if (!this.patchRange?.patchNum) return;
-
-    // If there is no next diff with comments, then return to the change view.
-    if (!this.commentSkips.next) {
-      this.navToChangeView();
-      return;
-    }
-
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.commentSkips.next,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
-      })
-    );
   }
 
   private handleNewComment() {
@@ -1199,14 +1129,14 @@
 
   private handlePrevFile() {
     if (!this.path) return;
-    if (!this.files?.sortedFileList) return;
-    this.navToFile(this.files.sortedFileList, -1);
+    if (!this.files?.sortedPaths) return;
+    this.navToFile(this.files.sortedPaths, -1);
   }
 
   private handleNextFile() {
     if (!this.path) return;
-    if (!this.files?.sortedFileList) return;
-    this.navToFile(this.files.sortedFileList, 1);
+    if (!this.files?.sortedPaths) return;
+    this.navToFile(this.files.sortedPaths, 1);
   }
 
   private handleNextChunk() {
@@ -1250,11 +1180,11 @@
 
   private navigateToUnreviewedFile(direction: string) {
     if (!this.path) return;
-    if (!this.files?.sortedFileList) return;
+    if (!this.files?.sortedPaths) return;
     if (!this.reviewedFiles) return;
     // Ensure that the currently viewed file always appears in unreviewedFiles
     // so we resolve the right "next" file.
-    const unreviewedFiles = this.files.sortedFileList.filter(
+    const unreviewedFiles = this.files.sortedPaths.filter(
       file => file === this.path || !this.reviewedFiles.has(file)
     );
 
@@ -1278,7 +1208,7 @@
       fireEvent(this, 'show-auth-required');
       return;
     }
-    this.navToChangeView(true);
+    this.getChangeModel().navigateToChange(true);
   }
 
   private handleToggleLeftPane() {
@@ -1314,10 +1244,6 @@
     this.downloadModal.close();
   }
 
-  private handleUpToChange() {
-    this.navToChangeView();
-  }
-
   private handleCommaKey() {
     if (!this.loggedIn) return;
     assertIsDefined(this.diffPreferencesDialog, 'diffPreferencesDialog');
@@ -1337,19 +1263,6 @@
   }
 
   // Private but used in tests.
-  navToChangeView(openReplyDialog = false) {
-    if (!this.changeNum || !this.patchRange?.patchNum) {
-      return;
-    }
-    this.navigateToChange(
-      this.change,
-      this.patchRange,
-      this.change && this.change.revisions,
-      openReplyDialog
-    );
-  }
-
-  // Private but used in tests.
   navToFile(
     fileList: string[],
     direction: -1 | 1,
@@ -1357,15 +1270,10 @@
   ) {
     const newPath = this.getNavLinkPath(fileList, direction);
     if (!newPath) return;
-    if (!this.change) return;
     if (!this.patchRange) return;
 
     if (newPath.up) {
-      this.navigateToChange(
-        this.change,
-        this.patchRange,
-        this.change && this.change.revisions
-      );
+      this.getChangeModel().navigateToChange();
       return;
     }
 
@@ -1376,15 +1284,7 @@
         newPath.path,
         this.patchRange
       )?.[0].line;
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: newPath.path,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
-        lineNum,
-      })
-    );
+    this.getChangeModel().navigateToDiff({path: newPath.path, lineNum});
   }
 
   /**
@@ -1395,35 +1295,25 @@
   private computeNavLinkURL(direction?: -1 | 1) {
     if (!this.change) return;
     if (!this.path) return;
-    if (!this.files?.sortedFileList) return;
+    if (!this.files?.sortedPaths) return;
     if (!direction) return;
 
-    const newPath = this.getNavLinkPath(this.files.sortedFileList, direction);
-    if (!newPath) {
-      return;
-    }
-
-    if (newPath.up) {
-      return this.getChangePath();
-    }
-    return this.getDiffUrl(this.change, this.patchRange, newPath.path);
+    const newPath = this.getNavLinkPath(this.files.sortedPaths, direction);
+    if (!newPath) return;
+    if (newPath.up) return this.getChangeModel().changeUrl();
+    if (!newPath.path) return;
+    return this.getChangeModel().diffUrl({path: newPath.path});
   }
 
   private goToEditFile() {
-    if (!this.change) return;
-    if (!this.path) return;
-    if (!this.patchRange) return;
+    assertIsDefined(this.path, 'path');
 
     // TODO(taoalpha): add a shortcut for editing
     const cursorAddress = this.cursor?.getAddress();
-    const editUrl = createEditUrl({
-      changeNum: this.change._number,
-      repo: this.change.project,
+    this.getChangeModel().navigateToEdit({
       path: this.path,
-      patchNum: this.patchRange.patchNum,
       lineNum: cursorAddress?.number,
     });
-    this.getNavigation().setUrl(editUrl);
   }
 
   /**
@@ -1445,7 +1335,6 @@
     if (!this.path || !fileList || fileList.length === 0) {
       return null;
     }
-
     let idx = fileList.indexOf(this.path);
     if (idx === -1) {
       const file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
@@ -1462,326 +1351,68 @@
     return {path: fileList[idx]};
   }
 
-  // Private but used in tests.
-  initLineOfInterestAndCursor(leftSide: boolean) {
-    assertIsDefined(this.diffHost, 'diffHost');
-    this.diffHost.lineOfInterest = this.getLineOfInterest(leftSide);
-    this.initCursor(leftSide);
-  }
-
-  // Private but used in tests.
-  displayDiffBaseAgainstLeftToast() {
-    if (!this.patchRange) return;
-    fireAlert(
-      this,
-      `Patchset ${this.patchRange.basePatchNum} vs ` +
-        `${this.patchRange.patchNum} selected. Press v + \u2190 to view ` +
-        `Base vs ${this.patchRange.basePatchNum}`
-    );
-  }
-
-  private displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
-    if (!this.patchRange) return;
-    const leftPatchset =
-      this.patchRange.basePatchNum === PARENT
-        ? 'Base'
-        : `Patchset ${this.patchRange.basePatchNum}`;
-    fireAlert(
-      this,
-      `${leftPatchset} vs
-            ${this.patchRange.patchNum} selected\n. Press v + \u2191 to view
-            ${leftPatchset} vs Patchset ${latestPatchNum}`
-    );
-  }
-
-  private displayToasts() {
-    if (!this.patchRange) return;
-    if (this.patchRange.basePatchNum !== PARENT) {
-      this.displayDiffBaseAgainstLeftToast();
-      return;
-    }
-    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (this.patchRange.patchNum !== latestPatchNum) {
-      this.displayDiffAgainstLatestToast(latestPatchNum);
-      return;
-    }
-  }
-
-  private initCommitRange() {
-    let commit: CommitId | undefined;
-    let baseCommit: CommitId | undefined;
-    if (!this.change) return;
-    if (!this.patchRange || !this.patchRange.patchNum) return;
-    const revisions = this.change.revisions ?? {};
-    for (const [commitSha, revision] of Object.entries(revisions)) {
-      const patchNum = revision._number;
-      if (patchNum === this.patchRange.patchNum) {
-        commit = commitSha as CommitId;
-        const commitObj = revision.commit;
-        const parents = commitObj?.parents || [];
-        if (this.patchRange.basePatchNum === PARENT && parents.length) {
-          baseCommit = parents[parents.length - 1].commit;
-        }
-      } else if (patchNum === this.patchRange.basePatchNum) {
-        baseCommit = commitSha as CommitId;
-      }
-    }
-    this.commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
-  }
-
   private updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
     if (!this.change) return;
-    if (!this.patchRange) return;
+    if (!this.patchNum) return;
     if (!this.changeNum) return;
     if (!this.path) return;
     const url = createDiffUrl({
       changeNum: this.changeNum,
       repo: this.change.project,
-      path: this.path,
-      patchNum: this.patchRange.patchNum,
-      basePatchNum: this.patchRange.basePatchNum,
-      lineNum,
-      leftSide,
+      patchNum: this.patchNum,
+      basePatchNum: this.basePatchNum,
+      diffView: {
+        path: this.path,
+        lineNum,
+        leftSide,
+      },
     });
     history.replaceState(null, '', url);
   }
 
-  // Private but used in tests.
-  initPatchRange() {
-    let leftSide = false;
-    if (!this.change) return;
-    if (this.viewState?.view !== GerritView.DIFF) return;
-    if (this.viewState?.commentId) {
-      const comment = this.changeComments?.findCommentById(
-        this.viewState.commentId
-      );
-      if (!comment) {
-        fireAlert(this, 'comment not found');
-        this.getNavigation().setUrl(createChangeUrl({change: this.change}));
-        return;
-      }
-      this.getChangeModel().updatePath(comment.path);
-
-      const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-      if (!latestPatchNum) throw new Error('Missing allPatchSets');
-      this.patchRange = getPatchRangeForCommentUrl(comment, latestPatchNum);
-      leftSide = isInBaseOfPatchRange(comment, this.patchRange);
-
-      this.focusLineNum = comment.line;
-    } else {
-      if (this.viewState.path) {
-        this.getChangeModel().updatePath(this.viewState.path);
-      }
-      if (this.viewState.patchNum) {
-        this.patchRange = {
-          patchNum: this.viewState.patchNum,
-          basePatchNum: this.viewState.basePatchNum || PARENT,
-        };
-      }
-      if (this.viewState.lineNum) {
-        this.focusLineNum = this.viewState.lineNum;
-        leftSide = !!this.viewState.leftSide;
-      }
-    }
-    assertIsDefined(this.patchRange, 'patchRange');
-    this.initLineOfInterestAndCursor(leftSide);
-
-    if (this.viewState?.commentId) {
-      // url is of type /comment/{commentId} which isn't meaningful
-      this.updateUrlToDiffUrl(this.focusLineNum, leftSide);
-    }
-
-    this.commentMap = this.getPaths();
+  async reloadDiff() {
+    if (!this.diffHost) return;
+    await this.diffHost.reload(true);
+    this.reporting.diffViewDisplayed();
+    if (this.isBlameLoaded) this.loadBlame();
   }
 
-  // Private but used in tests.
-  isFileUnchanged(diff?: DiffInfo) {
-    if (!diff || !diff.content) return false;
-    return !diff.content.some(
-      content =>
-        (content.a && !content.common) || (content.b && !content.common)
-    );
-  }
-
-  private isSameDiffLoaded(value: DiffViewState) {
-    return (
-      this.patchRange?.basePatchNum === value.basePatchNum &&
-      this.patchRange?.patchNum === value.patchNum &&
-      this.path === value.path
-    );
-  }
-
-  private async untilModelLoaded() {
-    // NOTE: Wait until this page is connected before determining whether the
-    // model is loaded.  This can happen when params are changed when setting up
-    // this view. It's unclear whether this issue is related to Polymer
-    // specifically.
-    if (!this.isConnected) {
-      await until(this.connected$, connected => connected);
-    }
-    await until(
-      this.getChangeModel().changeLoadingStatus$,
-      status => status === LoadingStatus.LOADED
-    );
-  }
-
-  // Private but used in tests.
-  viewStateChanged() {
-    if (this.viewState === undefined) return;
-    const viewState = this.viewState;
-
+  /**
+   * (Re-initialize) the diff view without actually reloading the diff. The
+   * typical user journey is that the user comes back from the change page.
+   */
+  initializePositions() {
     // The diff view is kept in the background once created. If the user
     // scrolls in the change page, the scrolling is reflected in the diff view
     // as well, which means the diff is scrolled to a random position based
     // on how much the change view was scrolled.
     // Hence, reset the scroll position here.
     document.documentElement.scrollTop = 0;
-
-    // Everything in the diff view is tied to the change. It seems better to
-    // force the re-creation of the diff view when the change number changes.
-    const changeChanged = this.changeNum !== viewState.changeNum;
-    if (this.changeNum !== undefined && changeChanged) {
-      fireEvent(this, EventType.RECREATE_DIFF_VIEW);
-      return;
-    } else if (
-      this.changeNum !== undefined &&
-      this.isSameDiffLoaded(viewState)
-    ) {
-      // changeNum has not changed, so check if there are changes in patchRange
-      // path. If no changes then we can simply render the view as is.
-      this.reporting.reportInteraction('diff-view-re-rendered');
-      // Make sure to re-initialize the cursor because this is typically
-      // done on the 'render' event which doesn't fire in this path as
-      // rerendering is avoided.
-      this.reInitCursor();
-      this.diffHost?.initLayers();
-      return;
-    }
-
-    this.files = {sortedFileList: [], changeFilesByPath: {}};
-    if (this.isConnected) {
-      this.getChangeModel().updatePath(undefined);
-    }
-    this.patchRange = undefined;
-    this.commitRange = undefined;
-    this.focusLineNum = undefined;
-
-    if (viewState.changeNum && viewState.repo) {
-      this.restApiService.setInProjectLookup(
-        viewState.changeNum,
-        viewState.repo
-      );
-    }
-
-    this.changeNum = viewState.changeNum;
+    this.reInitCursor();
+    this.diffHost?.initLayers();
     this.classList.remove('hideComments');
-
-    // When navigating away from the page, there is a possibility that the
-    // patch number is no longer a part of the URL (say when navigating to
-    // the top-level change info view) and therefore undefined in `params`.
-    // If route is of type /comment/<commentId>/ then no patchNum is present
-    if (!viewState.patchNum && !viewState.commentLink) {
-      this.reporting.error(
-        'GrDiffView',
-        new Error(`Invalid diff view URL, no patchNum found: ${this.viewState}`)
-      );
-      return;
-    }
-
-    const promises: Promise<unknown>[] = [];
-    if (!this.change) {
-      promises.push(this.untilModelLoaded());
-    }
-    promises.push(this.waitUntilCommentsLoaded());
-
-    if (this.diffHost) {
-      this.diffHost.cancel();
-      this.diffHost.clearDiffContent();
-    }
-    this.loading = true;
-    return Promise.all(promises)
-      .then(() => {
-        this.loading = false;
-        this.initPatchRange();
-        this.initCommitRange();
-        return this.updateComplete.then(() => this.diffHost!.reload(true));
-      })
-      .then(() => {
-        this.reporting.diffViewDisplayed();
-      })
-      .then(() => {
-        const fileUnchanged = this.isFileUnchanged(this.diff);
-        if (fileUnchanged && viewState.commentLink) {
-          assertIsDefined(this.change, 'change');
-          assertIsDefined(this.path, 'path');
-          assertIsDefined(this.patchRange, 'patchRange');
-
-          if (this.patchRange.basePatchNum === PARENT) {
-            // file is unchanged between Base vs X
-            // hence should not show diff between Base vs Base
-            return;
-          }
-
-          fireAlert(
-            this,
-            `File is unchanged between Patchset
-                  ${this.patchRange.basePatchNum} and
-                  ${this.patchRange.patchNum}. Showing diff of Base vs
-                  ${this.patchRange.basePatchNum}`
-          );
-          this.getNavigation().setUrl(
-            createDiffUrl({
-              change: this.change,
-              path: this.path,
-              patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
-              basePatchNum: PARENT,
-              lineNum: this.focusLineNum,
-            })
-          );
-          return;
-        }
-        if (viewState.commentLink) {
-          this.displayToasts();
-        }
-        // If the blame was loaded for a previous file and user navigates to
-        // another file, then we load the blame for this file too
-        if (this.isBlameLoaded) this.loadBlame();
-      });
-  }
-
-  private async waitUntilCommentsLoaded() {
-    await until(this.connected$, c => c);
-    await until(this.getCommentsModel().commentsLoading$, isFalse);
   }
 
   /**
    * If the params specify a diff address then configure the diff cursor.
    * Private but used in tests.
    */
-  initCursor(leftSide: boolean) {
-    if (this.focusLineNum === undefined) {
-      return;
-    }
+  initCursor() {
+    if (!this.focusLineNum) return;
     if (!this.cursor) return;
-    if (leftSide) {
-      this.cursor.side = Side.LEFT;
-    } else {
-      this.cursor.side = Side.RIGHT;
-    }
+    this.cursor.side = this.leftSide ? Side.LEFT : Side.RIGHT;
     this.cursor.initialLineNumber = this.focusLineNum;
   }
 
   // Private but used in tests.
-  getLineOfInterest(leftSide: boolean): DisplayLine | undefined {
+  getLineOfInterest(): DisplayLine | undefined {
     // If there is a line number specified, pass it along to the diff so that
     // it will not get collapsed.
-    if (!this.focusLineNum) {
-      return undefined;
-    }
+    if (!this.focusLineNum) return undefined;
 
     return {
       lineNum: this.focusLineNum,
-      side: leftSide ? Side.LEFT : Side.RIGHT,
+      side: this.leftSide ? Side.LEFT : Side.RIGHT,
     };
   }
 
@@ -1791,83 +1422,6 @@
     }
   }
 
-  private getDiffUrl(
-    change?: ChangeInfo | ParsedChangeInfo,
-    patchRange?: PatchRange,
-    path?: string
-  ) {
-    if (!change || !patchRange || !path) return '';
-    return createDiffUrl({
-      changeNum: change._number,
-      repo: change.project,
-      path,
-      patchNum: patchRange.patchNum,
-      basePatchNum: patchRange.basePatchNum,
-    });
-  }
-
-  /**
-   * When the latest patch of the change is selected (and there is no base
-   * patch) then the patch range need not appear in the URL. Return a patch
-   * range object with undefined values when a range is not needed.
-   */
-  private getChangeUrlRange(
-    patchRange?: PatchRange,
-    revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo}
-  ) {
-    let patchNum = undefined;
-    let basePatchNum = undefined;
-    let latestPatchNum = -1;
-    for (const rev of Object.values(revisions || {})) {
-      if (typeof rev._number === 'number') {
-        latestPatchNum = Math.max(latestPatchNum, rev._number);
-      }
-    }
-    if (!patchRange) return {patchNum, basePatchNum};
-    if (
-      patchRange.basePatchNum !== PARENT ||
-      patchRange.patchNum !== latestPatchNum
-    ) {
-      patchNum = patchRange.patchNum;
-      basePatchNum = patchRange.basePatchNum;
-    }
-    return {patchNum, basePatchNum};
-  }
-
-  private getChangePath() {
-    if (!this.change) return '';
-    if (!this.patchRange) return '';
-
-    const range = this.getChangeUrlRange(
-      this.patchRange,
-      this.change.revisions
-    );
-    return createChangeUrl({
-      change: this.change,
-      patchNum: range.patchNum,
-      basePatchNum: range.basePatchNum,
-    });
-  }
-
-  // Private but used in tests.
-  navigateToChange(
-    change?: ChangeInfo | ParsedChangeInfo,
-    patchRange?: PatchRange,
-    revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo},
-    openReplyDialog?: boolean
-  ) {
-    if (!change) return;
-    const range = this.getChangeUrlRange(patchRange, revisions);
-    this.getNavigation().setUrl(
-      createChangeUrl({
-        change,
-        patchNum: range.patchNum,
-        basePatchNum: range.basePatchNum,
-        openReplyDialog: !!openReplyDialog,
-      })
-    );
-  }
-
   // Private but used in tests
   formatFilesForDropdown(): DropdownItem[] {
     if (!this.files) return [];
@@ -1875,7 +1429,8 @@
     if (!this.changeComments) return [];
 
     const dropdownContent: DropdownItem[] = [];
-    for (const path of this.files.sortedFileList) {
+    for (const path of this.files.sortedPaths) {
+      const file = this.files.changeFilesByPath[path];
       dropdownContent.push({
         text: computeDisplayPath(path),
         mobileText: computeTruncatedPath(path),
@@ -1883,56 +1438,35 @@
         bottomText: this.changeComments.computeCommentsString(
           this.patchRange,
           path,
-          this.files.changeFilesByPath[path],
+          file,
           /* includeUnmodified= */ true
         ),
-        file: {...this.files.changeFilesByPath[path], __path: path},
+        file,
       });
     }
     return dropdownContent;
   }
 
   // Private but used in tests.
-  handleFileChange(e: CustomEvent) {
-    if (!this.change) return;
-    if (!this.patchRange) return;
-
-    // This is when it gets set initially.
-    const path = e.detail.value;
-    if (path === this.path) {
-      return;
-    }
-
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
-      })
-    );
+  handleFileChange(e: ValueChangedEvent<string>) {
+    const path: string = e.detail.value;
+    if (path === this.path) return;
+    this.getChangeModel().navigateToDiff({path});
   }
 
   // Private but used in tests.
   handlePatchChange(e: CustomEvent) {
-    if (!this.change) return;
     if (!this.path) return;
-    if (!this.patchRange) return;
+    if (!this.patchNum) return;
 
     const {basePatchNum, patchNum} = e.detail;
-    if (
-      basePatchNum === this.patchRange.basePatchNum &&
-      patchNum === this.patchRange.patchNum
-    ) {
+    if (basePatchNum === this.basePatchNum && patchNum === this.patchNum) {
       return;
     }
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.path,
-        patchNum,
-        basePatchNum,
-      })
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      patchNum,
+      basePatchNum
     );
   }
 
@@ -1957,7 +1491,7 @@
   computeDownloadDropdownLinks() {
     if (!this.change?.project) return [];
     if (!this.changeNum) return [];
-    if (!this.patchRange?.patchNum) return [];
+    if (!this.patchRange) return [];
     if (!this.path) return [];
 
     const links = [
@@ -2005,6 +1539,7 @@
     return links;
   }
 
+  // TODO: Move to view-model or router.
   // Private but used in tests.
   computeDownloadFileLink(
     repo: RepoName,
@@ -2033,6 +1568,7 @@
     return url;
   }
 
+  // TODO: Move to view-model or router.
   // Private but used in tests.
   computeDownloadPatchLink(
     repo: RepoName,
@@ -2046,49 +1582,19 @@
   }
 
   // Private but used in tests.
-  getPaths(): CommentMap {
-    if (!this.changeComments) return {};
-    return this.changeComments.getPaths(this.patchRange);
-  }
+  findFileWithComment(direction: -1 | 1): string | undefined {
+    const fileList = this.files?.sortedPaths;
+    const commentMap: CommentMap =
+      this.changeComments?.getPaths(this.patchRange) ?? {};
+    if (!fileList || fileList.length === 0) return undefined;
+    if (!this.path) return undefined;
 
-  // Private but used in tests.
-  computeCommentSkips(
-    commentMap?: CommentMap,
-    fileList?: string[],
-    path?: string
-  ): CommentSkips | undefined {
-    if (!commentMap) return undefined;
-    if (!fileList) return undefined;
-    if (!path) return undefined;
-
-    const skips: CommentSkips = {previous: null, next: null};
-    if (!fileList.length) {
-      return skips;
+    const pathIndex = fileList.indexOf(this.path);
+    const stopIndex = direction === 1 ? fileList.length : -1;
+    for (let i = pathIndex + direction; i !== stopIndex; i += direction) {
+      if (commentMap[fileList[i]]) return fileList[i];
     }
-    const pathIndex = fileList.indexOf(path);
-
-    // Scan backward for the previous file.
-    for (let i = pathIndex - 1; i >= 0; i--) {
-      if (commentMap[fileList[i]]) {
-        skips.previous = fileList[i];
-        break;
-      }
-    }
-
-    // Scan forward for the next file.
-    for (let i = pathIndex + 1; i < fileList.length; i++) {
-      if (commentMap[fileList[i]]) {
-        skips.next = fileList[i];
-        break;
-      }
-    }
-
-    return skips;
-  }
-
-  // Private but used in tests.
-  computeEditMode() {
-    return this.patchRange?.patchNum === EDIT;
+    return undefined;
   }
 
   // Private but used in tests.
@@ -2131,111 +1637,89 @@
 
   // Private but used in tests.
   handleDiffAgainstBase() {
-    if (!this.change) return;
-    if (!this.path) return;
-    if (!this.patchRange) return;
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    if (this.patchRange.basePatchNum === PARENT) {
+    if (this.basePatchNum === PARENT) {
       fireAlert(this, 'Base is already selected.');
       return;
     }
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.path,
-        patchNum: this.patchRange.patchNum,
-      })
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.patchNum,
+      PARENT
     );
   }
 
   // Private but used in tests.
   handleDiffBaseAgainstLeft() {
-    if (!this.change) return;
-    if (!this.path) return;
-    if (!this.patchRange) return;
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    if (this.patchRange.basePatchNum === PARENT) {
+    if (this.basePatchNum === PARENT) {
       fireAlert(this, 'Left is already base.');
       return;
     }
-    const lineNum =
-      this.viewState?.view === GerritView.DIFF && this.viewState?.commentLink
-        ? this.focusLineNum
-        : undefined;
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.path,
-        patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
-        basePatchNum: PARENT,
-        lineNum,
-      })
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.basePatchNum as RevisionPatchSetNum,
+      PARENT
     );
   }
 
   // Private but used in tests.
   handleDiffAgainstLatest() {
-    if (!this.change) return;
-    if (!this.path) return;
-    if (!this.patchRange) return;
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (this.patchRange.patchNum === latestPatchNum) {
+    if (this.patchNum === this.latestPatchNum) {
       fireAlert(this, 'Latest is already selected.');
       return;
     }
 
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.path,
-        patchNum: latestPatchNum,
-        basePatchNum: this.patchRange.basePatchNum,
-      })
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.latestPatchNum,
+      this.basePatchNum
     );
   }
 
   // Private but used in tests.
   handleDiffRightAgainstLatest() {
-    if (!this.change) return;
-    if (!this.path) return;
-    if (!this.patchRange) return;
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (this.patchRange.patchNum === latestPatchNum) {
+    if (this.patchNum === this.latestPatchNum) {
       fireAlert(this, 'Right is already latest.');
       return;
     }
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.path,
-        patchNum: latestPatchNum,
-        basePatchNum: this.patchRange.patchNum as BasePatchSetNum,
-      })
+
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.latestPatchNum,
+      this.patchNum as BasePatchSetNum
     );
   }
 
   // Private but used in tests.
   handleDiffBaseAgainstLatest() {
-    if (!this.change) return;
-    if (!this.path) return;
-    if (!this.patchRange) return;
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
-    if (
-      this.patchRange.patchNum === latestPatchNum &&
-      this.patchRange.basePatchNum === PARENT
-    ) {
+    if (this.patchNum === this.latestPatchNum && this.basePatchNum === PARENT) {
       fireAlert(this, 'Already diffing base against latest.');
       return;
     }
-    this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        path: this.path,
-        patchNum: latestPatchNum,
-      })
+
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.latestPatchNum,
+      PARENT
     );
   }
 
@@ -2266,13 +1750,13 @@
 
   private navigateToNextFileWithCommentThread() {
     if (!this.path) return;
-    if (!this.files?.sortedFileList) return;
-    if (!this.patchRange) return;
+    if (!this.files?.sortedPaths) return;
+    const range = this.patchRange;
+    if (!range) return;
     if (!this.change) return;
     const hasComment = (path: string) =>
-      this.changeComments?.getCommentsForPath(path, this.patchRange!)?.length ??
-      0 > 0;
-    const filesWithComments = this.files.sortedFileList.filter(
+      this.changeComments?.getCommentsForPath(path, range)?.length ?? 0 > 0;
+    const filesWithComments = this.files.sortedPaths.filter(
       file => file === this.path || hasComment(file)
     );
     this.navToFile(filesWithComments, 1, true);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 6a565eb..889e9dd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -5,7 +5,6 @@
  */
 import '../../../test/common-test-setup';
 import './gr-diff-view';
-import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   ChangeStatus,
   DiffViewMode,
@@ -18,38 +17,29 @@
   query,
   queryAll,
   queryAndAssert,
-  stubReporting,
   stubRestApi,
   waitEventLoop,
   waitUntil,
 } from '../../../test/test-utils';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {
-  GerritView,
-  routerModelToken,
-} from '../../../services/router/router-model';
-import {
   createRevisions,
   createComment as createCommentGeneric,
-  TEST_NUMERIC_CHANGE_ID,
   createDiff,
-  createPatchRange,
   createServerInfo,
   createConfig,
   createParsedChange,
   createRevision,
-  createCommit,
   createFileInfo,
+  createDiffViewState,
+  TEST_NUMERIC_CHANGE_ID,
 } from '../../../test/test-data-generators';
 import {
   BasePatchSetNum,
   CommentInfo,
-  CommitId,
   EDIT,
-  FileInfo,
   NumericChangeId,
   PARENT,
-  PatchRange,
   PatchSetNum,
   PatchSetNumber,
   PathToCommentsInfoMap,
@@ -58,17 +48,15 @@
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {CursorMoveResult} from '../../../api/core';
-import {DiffInfo, Side} from '../../../api/diff';
+import {Side} from '../../../api/diff';
 import {Files, GrDiffView} from './gr-diff-view';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
-import {SinonFakeTimers, SinonStub, SinonSpy} from 'sinon';
+import {SinonFakeTimers, SinonStub} from 'sinon';
 import {
   changeModelToken,
   ChangeModel,
   LoadingStatus,
 } from '../../../models/change/change-model';
-import {CommentMap} from '../../../utils/comment-util';
-import {ParsedChangeInfo} from '../../../types/types';
 import {assertIsDefined} from '../../../utils/common-util';
 import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {fixture, html, assert} from '@open-wc/testing';
@@ -85,6 +73,11 @@
   BrowserModel,
   browserModelToken,
 } from '../../../models/browser/browser-model';
+import {
+  ChangeViewModel,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {FileNameToNormalizedFileInfoMap} from '../../../models/change/files-model';
 
 function createComment(
   id: string,
@@ -107,25 +100,27 @@
     let clock: SinonFakeTimers;
     let diffCommentsStub;
     let getDiffRestApiStub: SinonStub;
-    let setUrlStub: SinonStub;
+    let navToChangeStub: SinonStub;
+    let navToDiffStub: SinonStub;
+    let navToEditStub: SinonStub;
     let changeModel: ChangeModel;
+    let viewModel: ChangeViewModel;
     let commentsModel: CommentsModel;
     let browserModel: BrowserModel;
     let userModel: UserModel;
 
     function getFilesFromFileList(fileList: string[]): Files {
       const changeFilesByPath = fileList.reduce((files, path) => {
-        files[path] = createFileInfo();
+        files[path] = createFileInfo(path);
         return files;
-      }, {} as {[path: string]: FileInfo});
+      }, {} as FileNameToNormalizedFileInfoMap);
       return {
-        sortedFileList: fileList,
+        sortedPaths: fileList,
         changeFilesByPath,
       };
     }
 
     setup(async () => {
-      setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
       stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
@@ -144,14 +139,17 @@
       stubRestApi('getPortedComments').returns(Promise.resolve({}));
 
       element = await fixture(html`<gr-diff-view></gr-diff-view>`);
-      element.changeNum = 42 as NumericChangeId;
+      viewModel = testResolver(changeViewModelToken);
+      viewModel.setState(createDiffViewState());
+      await waitUntil(() => element.changeNum === TEST_NUMERIC_CHANGE_ID);
       element.path = 'some/path.txt';
       element.change = createParsedChange();
       element.diff = {...createDiff(), content: []};
       getDiffRestApiStub = stubRestApi('getDiff');
       // Delayed in case a test updates element.diff.
       getDiffRestApiStub.callsFake(() => Promise.resolve(element.diff));
-      element.patchRange = createPatchRange();
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.changeComments = new ChangeComments({
         '/COMMIT_MSG': [
           createComment('c1', 10, 2, '/COMMIT_MSG'),
@@ -163,6 +161,9 @@
       changeModel = testResolver(changeModelToken);
       browserModel = testResolver(browserModelToken);
       userModel = testResolver(userModelToken);
+      navToChangeStub = sinon.stub(changeModel, 'navigateToChange');
+      navToDiffStub = sinon.stub(changeModel, 'navigateToDiff');
+      navToEditStub = sinon.stub(changeModel, 'navigateToEdit');
 
       commentsModel.setState({
         comments: {},
@@ -179,279 +180,6 @@
       sinon.restore();
     });
 
-    test('viewState change triggers diffViewDisplayed()', () => {
-      const diffViewDisplayedStub = stubReporting('diffViewDisplayed');
-      assertIsDefined(element.diffHost);
-      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-      sinon.stub(element, 'initPatchRange');
-      sinon.stub(element, 'fetchFiles');
-      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        patchNum: 2 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-        path: '/COMMIT_MSG',
-      };
-      element.path = '/COMMIT_MSG';
-      element.patchRange = createPatchRange();
-      return viewStateChangedSpy.returnValues[0]?.then(() => {
-        assert.isTrue(diffViewDisplayedStub.calledOnce);
-      });
-    });
-
-    suite('comment route', () => {
-      let initLineOfInterestAndCursorStub: SinonStub;
-      let replaceStateStub: SinonStub;
-      let viewStateChangedSpy: SinonSpy;
-      setup(() => {
-        initLineOfInterestAndCursorStub = sinon.stub(
-          element,
-          'initLineOfInterestAndCursor'
-        );
-        replaceStateStub = sinon.stub(history, 'replaceState');
-        sinon.stub(element, 'fetchFiles');
-        stubReporting('diffViewDisplayed');
-        assertIsDefined(element.diffHost);
-        sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-        viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-        changeModel.setState({
-          change: {
-            ...createParsedChange(),
-            revisions: createRevisions(11),
-          },
-          loadingStatus: LoadingStatus.LOADED,
-        });
-      });
-
-      test('comment url resolves to comment.patch_set vs latest', () => {
-        commentsModel.setState({
-          comments: {
-            '/COMMIT_MSG': [
-              createComment('c1', 10, 2, '/COMMIT_MSG'),
-              createComment('c3', 10, PARENT, '/COMMIT_MSG'),
-            ],
-          },
-          robotComments: {},
-          drafts: {},
-          portedComments: {},
-          portedDrafts: {},
-          discardedDrafts: [],
-        });
-        element.viewState = {
-          view: GerritView.DIFF,
-          changeNum: 42 as NumericChangeId,
-          commentLink: true,
-          commentId: 'c1' as UrlEncodedCommentId,
-          path: 'abcd',
-          patchNum: 1 as RevisionPatchSetNum,
-        };
-        element.change = {
-          ...createParsedChange(),
-          revisions: createRevisions(11),
-        };
-        return viewStateChangedSpy.returnValues[0].then(() => {
-          assert.isTrue(
-            initLineOfInterestAndCursorStub.calledWithExactly(true)
-          );
-          assert.equal(element.focusLineNum, 10);
-          assert.equal(element.patchRange?.patchNum, 11 as RevisionPatchSetNum);
-          assert.equal(element.patchRange?.basePatchNum, 2 as BasePatchSetNum);
-          assert.isTrue(replaceStateStub.called);
-        });
-      });
-    });
-
-    test('viewState change causes blame to load if it was set to true', () => {
-      // Blame loads for subsequent files if it was loaded for one file
-      element.isBlameLoaded = true;
-      stubReporting('diffViewDisplayed');
-      const loadBlameStub = sinon.stub(element, 'loadBlame');
-      assertIsDefined(element.diffHost);
-      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-      sinon.stub(element, 'initPatchRange');
-      sinon.stub(element, 'fetchFiles');
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        patchNum: 2 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-        path: '/COMMIT_MSG',
-      };
-      element.path = '/COMMIT_MSG';
-      element.patchRange = createPatchRange();
-      return viewStateChangedSpy.returnValues[0]!.then(() => {
-        assert.isTrue(element.isBlameLoaded);
-        assert.isTrue(loadBlameStub.calledOnce);
-      });
-    });
-
-    test('unchanged diff X vs latest from comment links navigates to base vs X', async () => {
-      commentsModel.setState({
-        comments: {
-          '/COMMIT_MSG': [
-            createComment('c1', 10, 2, '/COMMIT_MSG'),
-            createComment('c3', 10, PARENT, '/COMMIT_MSG'),
-          ],
-        },
-        robotComments: {},
-        drafts: {},
-        portedComments: {},
-        portedDrafts: {},
-        discardedDrafts: [],
-      });
-      stubReporting('diffViewDisplayed');
-      sinon.stub(element, 'loadBlame');
-      assertIsDefined(element.diffHost);
-      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-      sinon.stub(element, 'isFileUnchanged').returns(true);
-      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-      changeModel.setState({
-        change: {
-          ...createParsedChange(),
-          revisions: createRevisions(11),
-        },
-        loadingStatus: LoadingStatus.LOADED,
-      });
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        path: '/COMMIT_MSG',
-        commentLink: true,
-        commentId: 'c1' as UrlEncodedCommentId,
-      };
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(11),
-      };
-      await viewStateChangedSpy.returnValues[0];
-      assert.isTrue(setUrlStub.calledOnce);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/2//COMMIT_MSG#10'
-      );
-    });
-
-    test('unchanged diff Base vs latest from comment does not navigate', async () => {
-      commentsModel.setState({
-        comments: {
-          '/COMMIT_MSG': [
-            createComment('c1', 10, 2, '/COMMIT_MSG'),
-            createComment('c3', 10, PARENT, '/COMMIT_MSG'),
-          ],
-        },
-        robotComments: {},
-        drafts: {},
-        portedComments: {},
-        portedDrafts: {},
-        discardedDrafts: [],
-      });
-      stubReporting('diffViewDisplayed');
-      sinon.stub(element, 'loadBlame');
-      assertIsDefined(element.diffHost);
-      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-      sinon.stub(element, 'isFileUnchanged').returns(true);
-      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-      changeModel.setState({
-        change: {
-          ...createParsedChange(),
-          revisions: createRevisions(11),
-        },
-        loadingStatus: LoadingStatus.LOADED,
-      });
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        path: '/COMMIT_MSG',
-        commentLink: true,
-        commentId: 'c3' as UrlEncodedCommentId,
-      };
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(11),
-      };
-      await viewStateChangedSpy.returnValues[0];
-      assert.isFalse(setUrlStub.calledOnce);
-    });
-
-    test('isFileUnchanged', () => {
-      let diff: DiffInfo = {
-        ...createDiff(),
-        content: [
-          {a: ['abcd'], ab: ['ef']},
-          {b: ['ancd'], a: ['xx']},
-        ],
-      };
-      assert.equal(element.isFileUnchanged(diff), false);
-      diff = {
-        ...createDiff(),
-        content: [{ab: ['abcd']}, {ab: ['ancd']}],
-      };
-      assert.equal(element.isFileUnchanged(diff), true);
-      diff = {
-        ...createDiff(),
-        content: [
-          {a: ['abcd'], ab: ['ef'], common: true},
-          {b: ['ancd'], ab: ['xx']},
-        ],
-      };
-      assert.equal(element.isFileUnchanged(diff), false);
-      diff = {
-        ...createDiff(),
-        content: [
-          {a: ['abcd'], ab: ['ef'], common: true},
-          {b: ['ancd'], ab: ['xx'], common: true},
-        ],
-      };
-      assert.equal(element.isFileUnchanged(diff), true);
-    });
-
-    test('diff toast to go to latest is shown and not base', async () => {
-      commentsModel.setState({
-        comments: {
-          '/COMMIT_MSG': [
-            createComment('c1', 10, 2, '/COMMIT_MSG'),
-            createComment('c3', 10, PARENT, '/COMMIT_MSG'),
-          ],
-        },
-        robotComments: {},
-        drafts: {},
-        portedComments: {},
-        portedDrafts: {},
-        discardedDrafts: [],
-      });
-
-      stubReporting('diffViewDisplayed');
-      sinon.stub(element, 'loadBlame');
-      assertIsDefined(element.diffHost);
-      sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
-      const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
-      element.change = undefined;
-      changeModel.setState({
-        change: {
-          ...createParsedChange(),
-          revisions: createRevisions(11),
-        },
-        loadingStatus: LoadingStatus.LOADED,
-      });
-      element.patchRange = {
-        patchNum: 2 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-      };
-      sinon.stub(element, 'isFileUnchanged').returns(false);
-      const toastStub = sinon.stub(element, 'displayDiffBaseAgainstLeftToast');
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        repo: 'p' as RepoName,
-        commentId: 'c1' as UrlEncodedCommentId,
-        commentLink: true,
-      };
-      await viewStateChangedSpy.returnValues[0];
-      assert.isTrue(toastStub.called);
-    });
-
     test('toggle left diff with a hotkey', () => {
       assertIsDefined(element.diffHost);
       const toggleLeftDiffStub = sinon.stub(element.diffHost, 'toggleLeftDiff');
@@ -460,20 +188,17 @@
     });
 
     test('renders', async () => {
-      clock = sinon.useFakeTimers();
-      element.changeNum = 42 as NumericChangeId;
       browserModel.setScreenWidth(0);
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
-      element.change = {
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      const change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
         revisions: {
           a: createRevision(10),
         },
       };
+      changeModel.updateStateChange(change);
       element.files = getFilesFromFileList([
         'chell.go',
         'glados.txt',
@@ -625,9 +350,8 @@
               </a>
             </div>
           </div>
-          <div class="loading">Loading...</div>
           <h2 class="assistive-tech-only">Diff view</h2>
-          <gr-diff-host hidden="" id="diffHost"> </gr-diff-host>
+          <gr-diff-host id="diffHost"> </gr-diff-host>
           <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
           <gr-diff-preferences-dialog id="diffPreferencesDialog">
           </gr-diff-preferences-dialog>
@@ -643,10 +367,8 @@
       clock = sinon.useFakeTimers();
       element.changeNum = 42 as NumericChangeId;
       browserModel.setScreenWidth(0);
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -662,51 +384,42 @@
       element.path = 'glados.txt';
       element.loggedIn = true;
       await element.updateComplete;
-      setUrlStub.reset();
+      navToChangeStub.reset();
 
       pressKey(element, 'u');
-      assert.equal(setUrlStub.callCount, 1);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42');
+      assert.isTrue(navToChangeStub.calledOnce);
       await element.updateComplete;
 
       pressKey(element, ']');
-      assert.equal(setUrlStub.callCount, 2);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/10/wheatley.md'
-      );
+      assert.equal(navToDiffStub.callCount, 1);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'wheatley.md', lineNum: undefined},
+      ]);
+
       element.path = 'wheatley.md';
       await element.updateComplete;
 
-      assert.isTrue(element.loading);
-
       pressKey(element, '[');
-      assert.equal(setUrlStub.callCount, 3);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/10/glados.txt'
-      );
+      assert.equal(navToDiffStub.callCount, 2);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'glados.txt', lineNum: undefined},
+      ]);
+
       element.path = 'glados.txt';
       await element.updateComplete;
 
-      assert.isTrue(element.loading);
-
       pressKey(element, '[');
-      assert.equal(setUrlStub.callCount, 4);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/10/chell.go'
-      );
+      assert.equal(navToDiffStub.callCount, 3);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'chell.go', lineNum: undefined},
+      ]);
+
       element.path = 'chell.go';
       await element.updateComplete;
 
-      assert.isTrue(element.loading);
-
       pressKey(element, '[');
-      assert.equal(setUrlStub.callCount, 5);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42');
+      assert.equal(navToChangeStub.callCount, 2);
       await element.updateComplete;
-      assert.isTrue(element.loading);
 
       assertIsDefined(element.diffPreferencesDialog);
       const showPrefsStub = sinon
@@ -756,12 +469,8 @@
       );
       assert.isFalse(element.diffHost.diffElement.displayLine);
 
-      // Note that stubbing setReviewed means that the value of the
-      // `element.reviewed` checkbox is not flipped.
       const setReviewedStub = sinon.stub(element, 'setReviewed');
       const handleToggleSpy = sinon.spy(element, 'handleToggleFileReviewed');
-      assertIsDefined(element.reviewed);
-      element.reviewed.checked = false;
       assert.isFalse(handleToggleSpy.called);
       assert.isFalse(setReviewedStub.called);
 
@@ -791,10 +500,8 @@
         'wheatley.md': [createComment('c2', 21, 10, 'wheatley.md')],
       };
       element.changeComments = new ChangeComments(comment);
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -810,23 +517,21 @@
       element.path = 'glados.txt';
       element.loggedIn = true;
       await element.updateComplete;
-      setUrlStub.reset();
+      navToDiffStub.reset();
 
       pressKey(element, 'N');
       await element.updateComplete;
-      assert.equal(setUrlStub.callCount, 1);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/10/wheatley.md#21'
-      );
+      assert.equal(navToDiffStub.callCount, 1);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'wheatley.md', lineNum: 21},
+      ]);
 
       element.path = 'wheatley.md'; // navigated to next file
 
       pressKey(element, 'N');
       await element.updateComplete;
 
-      assert.equal(setUrlStub.callCount, 2);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42');
+      assert.equal(navToChangeStub.callCount, 1);
     });
 
     test('shift+x shortcut toggles all diff context', async () => {
@@ -838,114 +543,61 @@
     });
 
     test('diff against base', async () => {
-      element.patchRange = {
-        basePatchNum: 5 as BasePatchSetNum,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = 5 as BasePatchSetNum;
       await element.updateComplete;
       element.handleDiffAgainstBase();
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/10/some/path.txt'
-      );
+      const expected = [{path: 'some/path.txt'}, 10, PARENT];
+      assert.deepEqual(navToDiffStub.lastCall.args, expected);
     });
 
     test('diff against latest', async () => {
       element.path = 'foo';
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(12),
-      };
-      element.patchRange = {
-        basePatchNum: 5 as BasePatchSetNum,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
+      element.latestPatchNum = 12 as PatchSetNumber;
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = 5 as BasePatchSetNum;
       await element.updateComplete;
       element.handleDiffAgainstLatest();
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/5..12/foo'
-      );
+      const expected = [{path: 'foo'}, 12, 5];
+      assert.deepEqual(navToDiffStub.lastCall.args, expected);
     });
 
     test('handleDiffBaseAgainstLeft', async () => {
       element.path = 'foo';
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(10),
-      };
-      element.patchRange = {
+      element.latestPatchNum = 10 as PatchSetNumber;
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = 1 as BasePatchSetNum;
+      viewModel.setState({
+        ...createDiffViewState(),
         patchNum: 3 as RevisionPatchSetNum,
         basePatchNum: 1 as BasePatchSetNum,
-      };
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        patchNum: 3 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-        path: 'foo',
-      };
+        diffView: {path: 'foo'},
+      });
       await element.updateComplete;
       element.handleDiffBaseAgainstLeft();
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1/foo');
-    });
-
-    test('handleDiffBaseAgainstLeft when initially navigating to a comment', () => {
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(10),
-      };
-      element.patchRange = {
-        patchNum: 3 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-      };
-      sinon.stub(element, 'viewStateChanged');
-      element.viewState = {
-        commentLink: true,
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-      };
-      element.focusLineNum = 10;
-      element.handleDiffBaseAgainstLeft();
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/1/some/path.txt#10'
-      );
+      const expected = [{path: 'foo'}, 1, PARENT];
+      assert.deepEqual(navToDiffStub.lastCall.args, expected);
     });
 
     test('handleDiffRightAgainstLatest', async () => {
       element.path = 'foo';
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(10),
-      };
-      element.patchRange = {
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: 3 as RevisionPatchSetNum,
-      };
+      element.latestPatchNum = 10 as PatchSetNumber;
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = 1 as BasePatchSetNum;
       await element.updateComplete;
       element.handleDiffRightAgainstLatest();
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/3..10/foo'
-      );
+      const expected = [{path: 'foo'}, 10, 3];
+      assert.deepEqual(navToDiffStub.lastCall.args, expected);
     });
 
     test('handleDiffBaseAgainstLatest', async () => {
-      element.change = {
-        ...createParsedChange(),
-        revisions: createRevisions(10),
-      };
-      element.patchRange = {
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: 3 as RevisionPatchSetNum,
-      };
+      element.latestPatchNum = 10 as PatchSetNumber;
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = 1 as BasePatchSetNum;
       await element.updateComplete;
       element.handleDiffBaseAgainstLatest();
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/10/some/path.txt'
-      );
+      const expected = [{path: 'some/path.txt'}, 10, PARENT];
+      assert.deepEqual(navToDiffStub.lastCall.args, expected);
     });
 
     test('A fires an error event when not logged in', async () => {
@@ -954,16 +606,14 @@
       element.addEventListener('show-auth-required', loggedInErrorSpy);
       pressKey(element, 'a');
       await element.updateComplete;
-      assert.isFalse(setUrlStub.calledOnce);
+      assert.isFalse(navToDiffStub.calledOnce);
       assert.isTrue(loggedInErrorSpy.called);
     });
 
     test('A navigates to change with logged in', async () => {
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: 5 as BasePatchSetNum,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = 5 as BasePatchSetNum;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -976,25 +626,20 @@
       await element.updateComplete;
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      setUrlStub.reset();
+      navToDiffStub.reset();
 
       pressKey(element, 'a');
 
       await element.updateComplete;
-      assert.equal(setUrlStub.callCount, 1);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/5..10?openReplyDialog=true'
-      );
+      assert.isTrue(navToChangeStub.calledOnce);
+      assert.deepEqual(navToChangeStub.lastCall.args, [true]);
       assert.isFalse(loggedInErrorSpy.called);
     });
 
     test('A navigates to change with old patch number with logged in', async () => {
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -1008,20 +653,15 @@
       element.addEventListener('show-auth-required', loggedInErrorSpy);
       pressKey(element, 'a');
       await element.updateComplete;
-      assert.isTrue(setUrlStub.calledOnce);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/1?openReplyDialog=true'
-      );
+      assert.isTrue(navToChangeStub.calledOnce);
+      assert.deepEqual(navToChangeStub.lastCall.args, [true]);
       assert.isFalse(loggedInErrorSpy.called);
     });
 
     test('keyboard shortcuts with patch range', () => {
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: 5 as BasePatchSetNum,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = 5 as BasePatchSetNum;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -1038,40 +678,31 @@
       element.path = 'glados.txt';
 
       pressKey(element, 'u');
-      assert.equal(setUrlStub.callCount, 1);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/5..10');
+      assert.equal(navToChangeStub.callCount, 1);
 
       pressKey(element, ']');
-      assert.isTrue(element.loading);
-      assert.equal(setUrlStub.callCount, 2);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/5..10/wheatley.md'
-      );
+      assert.equal(navToDiffStub.callCount, 1);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'wheatley.md', lineNum: undefined},
+      ]);
       element.path = 'wheatley.md';
 
       pressKey(element, '[');
-      assert.isTrue(element.loading);
-      assert.equal(setUrlStub.callCount, 3);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/5..10/glados.txt'
-      );
+      assert.equal(navToDiffStub.callCount, 2);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'glados.txt', lineNum: undefined},
+      ]);
       element.path = 'glados.txt';
 
       pressKey(element, '[');
-      assert.isTrue(element.loading);
-      assert.equal(setUrlStub.callCount, 4);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/5..10/chell.go'
-      );
+      assert.equal(navToDiffStub.callCount, 3);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'chell.go', lineNum: undefined},
+      ]);
       element.path = 'chell.go';
 
       pressKey(element, '[');
-      assert.isTrue(element.loading);
-      assert.equal(setUrlStub.callCount, 5);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/5..10');
+      assert.equal(navToChangeStub.callCount, 2);
 
       assertIsDefined(element.downloadModal);
       const downloadModalStub = sinon.stub(element.downloadModal, 'showModal');
@@ -1079,12 +710,10 @@
       assert.isTrue(downloadModalStub.called);
     });
 
-    test('keyboard shortcuts with old patch number', () => {
+    test('keyboard shortcuts with old patch number', async () => {
       element.changeNum = 42 as NumericChangeId;
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -1101,53 +730,57 @@
       element.path = 'glados.txt';
 
       pressKey(element, 'u');
-      assert.isTrue(setUrlStub.calledOnce);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
+      assert.isTrue(navToChangeStub.calledOnce);
 
       pressKey(element, ']');
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/1/wheatley.md'
-      );
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'wheatley.md', lineNum: undefined},
+      ]);
       element.path = 'wheatley.md';
 
       pressKey(element, '[');
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/1/glados.txt'
-      );
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'glados.txt', lineNum: undefined},
+      ]);
       element.path = 'glados.txt';
 
       pressKey(element, '[');
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/test-project/+/42/1/chell.go'
-      );
-      element.path = 'chell.go';
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'chell.go', lineNum: undefined},
+      ]);
 
-      setUrlStub.reset();
+      element.path = 'chell.go';
+      await element.updateComplete;
+      navToDiffStub.reset();
       pressKey(element, '[');
-      assert.isTrue(setUrlStub.calledOnce);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
+      assert.equal(navToChangeStub.callCount, 2);
+    });
+
+    test('reloadDiff is called when patchNum changes', async () => {
+      const reloadStub = sinon.stub(element, 'reloadDiff');
+      element.patchNum = 5 as RevisionPatchSetNum;
+      await element.updateComplete;
+      assert.isTrue(reloadStub.called);
+    });
+
+    test('initializePositions is called when view becomes active', async () => {
+      const reloadStub = sinon.stub(element, 'reloadDiff');
+      const initializeStub = sinon.stub(element, 'initializePositions');
+
+      element.isActiveChildView = false;
+      await element.updateComplete;
+      element.isActiveChildView = true;
+      await element.updateComplete;
+
+      assert.isTrue(initializeStub.calledOnce);
+      assert.isFalse(reloadStub.called);
     });
 
     test('edit should redirect to edit page', async () => {
       element.loggedIn = true;
       element.path = 't.txt';
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
-      element.change = {
-        ...createParsedChange(),
-        _number: 42 as NumericChangeId,
-        project: 'gerrit' as RepoName,
-        status: ChangeStatus.NEW,
-        revisions: {
-          a: createRevision(1),
-          b: createRevision(2),
-        },
-      };
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       await element.updateComplete;
       const editBtn = queryAndAssert<GrButton>(
         element,
@@ -1155,28 +788,18 @@
       );
       assert.isTrue(!!editBtn);
       editBtn.click();
-      assert.equal(setUrlStub.callCount, 1);
-      assert.equal(setUrlStub.lastCall.firstArg, '/c/gerrit/+/42/1/t.txt,edit');
+      assert.equal(navToEditStub.callCount, 1);
+      assert.deepEqual(navToEditStub.lastCall.args, [
+        {path: 't.txt', lineNum: undefined},
+      ]);
     });
 
     test('edit should redirect to edit page with line number', async () => {
       const lineNumber = 42;
       element.loggedIn = true;
       element.path = 't.txt';
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
-      element.change = {
-        ...createParsedChange(),
-        _number: 42 as NumericChangeId,
-        project: 'gerrit' as RepoName,
-        status: ChangeStatus.NEW,
-        revisions: {
-          a: createRevision(1),
-          b: createRevision(2),
-        },
-      };
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       assertIsDefined(element.cursor);
       sinon
         .stub(element.cursor, 'getAddress')
@@ -1188,11 +811,10 @@
       );
       assert.isTrue(!!editBtn);
       editBtn.click();
-      assert.equal(setUrlStub.callCount, 1);
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/gerrit/+/42/1/t.txt,edit#42'
-      );
+      assert.equal(navToEditStub.callCount, 1);
+      assert.deepEqual(navToEditStub.lastCall.args, [
+        {path: 't.txt', lineNum: 42},
+      ]);
     });
 
     async function isEditVisibile({
@@ -1204,10 +826,8 @@
     }): Promise<boolean> {
       element.loggedIn = loggedIn;
       element.path = 't.txt';
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
@@ -1304,16 +924,10 @@
     });
 
     suite('url parameters', () => {
-      setup(() => {
-        sinon.stub(element, 'fetchFiles');
-      });
-
       test('_formattedFiles', () => {
         element.changeNum = 42 as NumericChangeId;
-        element.patchRange = {
-          basePatchNum: PARENT,
-          patchNum: 10 as RevisionPatchSetNum,
-        };
+        element.patchNum = 10 as RevisionPatchSetNum;
+        element.basePatchNum = PARENT;
         element.change = {
           ...createParsedChange(),
           _number: 42 as NumericChangeId,
@@ -1386,18 +1000,19 @@
       });
 
       test('prev/up/next links', async () => {
-        element.changeNum = 42 as NumericChangeId;
-        element.patchRange = {
-          basePatchNum: PARENT,
-          patchNum: 10 as RevisionPatchSetNum,
-        };
-        element.change = {
+        viewModel.setState({
+          ...createDiffViewState(),
+        });
+        const change = {
           ...createParsedChange(),
           _number: 42 as NumericChangeId,
           revisions: {
             a: createRevision(10),
           },
         };
+        changeModel.updateStateChange(change);
+        await element.updateComplete;
+
         element.files = getFilesFromFileList([
           'chell.go',
           'glados.txt',
@@ -1417,24 +1032,30 @@
           linkEls[2].getAttribute('href'),
           '/c/test-project/+/42/10/wheatley.md'
         );
+
         element.path = 'wheatley.md';
         await element.updateComplete;
+
         assert.equal(
           linkEls[0].getAttribute('href'),
           '/c/test-project/+/42/10/glados.txt'
         );
         assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
         assert.equal(linkEls[2].getAttribute('href'), '/c/test-project/+/42');
+
         element.path = 'chell.go';
         await element.updateComplete;
+
         assert.equal(linkEls[0].getAttribute('href'), '/c/test-project/+/42');
         assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
         assert.equal(
           linkEls[2].getAttribute('href'),
           '/c/test-project/+/42/10/glados.txt'
         );
+
         element.path = 'not_a_real_file';
         await element.updateComplete;
+
         assert.equal(
           linkEls[0].getAttribute('href'),
           '/c/test-project/+/42/10/wheatley.md'
@@ -1447,26 +1068,30 @@
       });
 
       test('prev/up/next links with patch range', async () => {
-        element.changeNum = 42 as NumericChangeId;
-        element.patchRange = {
+        viewModel.setState({
+          ...createDiffViewState(),
           basePatchNum: 5 as BasePatchSetNum,
           patchNum: 10 as RevisionPatchSetNum,
-        };
-        element.change = {
+          diffView: {path: 'glados.txt'},
+        });
+        const change = {
           ...createParsedChange(),
           _number: 42 as NumericChangeId,
           revisions: {
             a: createRevision(5),
             b: createRevision(10),
+            c: createRevision(12),
           },
         };
+        changeModel.updateStateChange(change);
         element.files = getFilesFromFileList([
           'chell.go',
           'glados.txt',
           'wheatley.md',
         ]);
-        element.path = 'glados.txt';
-        await element.updateComplete;
+        await waitUntil(() => element.path === 'glados.txt');
+        await waitUntil(() => element.patchRange?.patchNum === 10);
+
         const linkEls = queryAll(element, '.navLink');
         assert.equal(linkEls.length, 3);
         assert.equal(
@@ -1481,8 +1106,10 @@
           linkEls[2].getAttribute('href'),
           '/c/test-project/+/42/5..10/wheatley.md'
         );
-        element.path = 'wheatley.md';
-        await element.updateComplete;
+
+        viewModel.updateState({diffView: {path: 'wheatley.md'}});
+        await waitUntil(() => element.path === 'wheatley.md');
+
         assert.equal(
           linkEls[0].getAttribute('href'),
           '/c/test-project/+/42/5..10/glados.txt'
@@ -1495,8 +1122,10 @@
           linkEls[2].getAttribute('href'),
           '/c/test-project/+/42/5..10'
         );
-        element.path = 'chell.go';
-        await element.updateComplete;
+
+        viewModel.updateState({diffView: {path: 'chell.go'}});
+        await waitUntil(() => element.path === 'chell.go');
+
         assert.equal(
           linkEls[0].getAttribute('href'),
           '/c/test-project/+/42/5..10'
@@ -1513,32 +1142,24 @@
     });
 
     test('handlePatchChange calls setUrl correctly', async () => {
-      element.change = {
-        ...createParsedChange(),
-        _number: 321 as NumericChangeId,
-        project: 'foo/bar' as RepoName,
-      };
       element.path = 'path/to/file.txt';
-
-      element.patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 3 as RevisionPatchSetNum,
-      };
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       await element.updateComplete;
 
       const detail = {
         basePatchNum: PARENT,
         patchNum: 1 as RevisionPatchSetNum,
       };
-
       queryAndAssert(element, '#rangeSelect').dispatchEvent(
         new CustomEvent('patch-range-change', {detail, bubbles: false})
       );
 
-      assert.equal(
-        setUrlStub.lastCall.firstArg,
-        '/c/foo/bar/+/321/1/path/to/file.txt'
-      );
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: element.path},
+        detail.patchNum,
+        detail.basePatchNum,
+      ]);
     });
 
     test(
@@ -1559,23 +1180,13 @@
           manual_review: true,
         };
         userModel.setDiffPreferences(diffPreferences);
+        viewModel.updateState({diffView: {path: 'wheatley.md'}});
         changeModel.setState({
           change: createParsedChange(),
-          diffPath: '/COMMIT_MSG',
           reviewedFiles: [],
           loadingStatus: LoadingStatus.LOADED,
         });
 
-        testResolver(routerModelToken).setState({
-          changeNum: TEST_NUMERIC_CHANGE_ID,
-          view: GerritView.DIFF,
-          patchNum: 2 as RevisionPatchSetNum,
-        });
-        element.patchRange = {
-          patchNum: 2 as RevisionPatchSetNum,
-          basePatchNum: 1 as BasePatchSetNum,
-        };
-
         await waitUntil(() => setReviewedStatusStub.called);
 
         assert.isFalse(setReviewedFileStatusStub.called);
@@ -1601,55 +1212,39 @@
         manual_review: false,
       };
       userModel.setDiffPreferences(diffPreferences);
+      viewModel.updateState({diffView: {path: 'wheatley.md'}});
       changeModel.setState({
         change: createParsedChange(),
-        diffPath: '/COMMIT_MSG',
         reviewedFiles: [],
         loadingStatus: LoadingStatus.LOADED,
       });
 
-      testResolver(routerModelToken).setState({
-        changeNum: TEST_NUMERIC_CHANGE_ID,
-        view: GerritView.DIFF,
-        patchNum: 22 as RevisionPatchSetNum,
-      });
-      element.patchRange = {
-        patchNum: 2 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-      };
-
       await waitUntil(() => setReviewedFileStatusStub.called);
 
       assert.isTrue(setReviewedFileStatusStub.called);
     });
 
     test('file review status', async () => {
+      const saveReviewedStub = sinon
+        .stub(changeModel, 'setReviewedFilesStatus')
+        .callsFake(() => Promise.resolve());
+      userModel.setDiffPreferences(createDefaultDiffPrefs());
+      viewModel.updateState({
+        patchNum: 1 as RevisionPatchSetNum,
+        basePatchNum: PARENT,
+        diffView: {path: '/COMMIT_MSG'},
+      });
       changeModel.setState({
         change: createParsedChange(),
-        diffPath: '/COMMIT_MSG',
         reviewedFiles: [],
         loadingStatus: LoadingStatus.LOADED,
       });
       element.loggedIn = true;
-      const saveReviewedStub = sinon
-        .stub(changeModel, 'setReviewedFilesStatus')
-        .callsFake(() => Promise.resolve());
+      await waitUntil(() => element.patchRange?.patchNum === 1);
+      await element.updateComplete;
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload');
 
-      userModel.setDiffPreferences(createDefaultDiffPrefs());
-
-      testResolver(routerModelToken).setState({
-        changeNum: TEST_NUMERIC_CHANGE_ID,
-        view: GerritView.DIFF,
-        patchNum: 2 as RevisionPatchSetNum,
-      });
-
-      element.patchRange = {
-        patchNum: 2 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-      };
-
       await waitUntil(() => saveReviewedStub.called);
 
       changeModel.updateStateFileReviewed('/COMMIT_MSG', true);
@@ -1657,13 +1252,13 @@
 
       const reviewedStatusCheckBox = queryAndAssert<HTMLInputElement>(
         element,
-        'input[type="checkbox"]'
+        'input#reviewed'
       );
 
       assert.isTrue(reviewedStatusCheckBox.checked);
       assert.deepEqual(saveReviewedStub.lastCall.args, [
         42,
-        2,
+        1,
         '/COMMIT_MSG',
         true,
       ]);
@@ -1672,7 +1267,7 @@
       assert.isFalse(reviewedStatusCheckBox.checked);
       assert.deepEqual(saveReviewedStub.lastCall.args, [
         42,
-        2,
+        1,
         '/COMMIT_MSG',
         false,
       ]);
@@ -1684,18 +1279,17 @@
       assert.isTrue(reviewedStatusCheckBox.checked);
       assert.deepEqual(saveReviewedStub.lastCall.args, [
         42,
-        2,
+        1,
         '/COMMIT_MSG',
         true,
       ]);
 
       const callCount = saveReviewedStub.callCount;
 
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
+      viewModel.setState({
+        ...createDiffViewState(),
         repo: 'test' as RepoName,
-      };
+      });
       await element.updateComplete;
 
       // saveReviewedState observer observes viewState, but should not fire when
@@ -1703,36 +1297,27 @@
       assert.equal(saveReviewedStub.callCount, callCount);
     });
 
-    test('file review status with edit loaded', async () => {
+    test('do not set file review status for EDIT patchset', async () => {
       const saveReviewedStub = sinon.stub(
         changeModel,
         'setReviewedFilesStatus'
       );
 
-      element.patchRange = {
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: EDIT,
-      };
+      element.patchNum = EDIT;
+      element.basePatchNum = 1 as BasePatchSetNum;
       await waitEventLoop();
 
-      assert.isTrue(element.computeEditMode());
       element.setReviewed(true);
+
       assert.isFalse(saveReviewedStub.called);
     });
 
     test('hash is determined from viewState', async () => {
       assertIsDefined(element.diffHost);
       sinon.stub(element.diffHost, 'reload');
-      const initLineStub = sinon.stub(element, 'initLineOfInterestAndCursor');
+      const initLineStub = sinon.stub(element, 'initCursor');
 
-      element.loggedIn = true;
-      element.viewState = {
-        view: GerritView.DIFF,
-        changeNum: 42 as NumericChangeId,
-        patchNum: 2 as RevisionPatchSetNum,
-        basePatchNum: 1 as BasePatchSetNum,
-        path: '/COMMIT_MSG',
-      };
+      element.focusLineNum = 123;
 
       await element.updateComplete;
       await waitEventLoop();
@@ -1780,115 +1365,56 @@
       assert.isTrue(diffModeSelector.classList.contains('hide'));
     });
 
-    suite('commitRange', () => {
-      const change: ParsedChangeInfo = {
-        ...createParsedChange(),
-        _number: 42 as NumericChangeId,
-        revisions: {
-          'commit-sha-1': {
-            ...createRevision(1),
-            commit: {
-              ...createCommit(),
-              parents: [{subject: 's1', commit: 'sha-1-parent' as CommitId}],
-            },
-          },
-          'commit-sha-2': createRevision(2),
-          'commit-sha-3': createRevision(3),
-          'commit-sha-4': createRevision(4),
-          'commit-sha-5': {
-            ...createRevision(5),
-            commit: {
-              ...createCommit(),
-              parents: [{subject: 's5', commit: 'sha-5-parent' as CommitId}],
-            },
-          },
-        },
-      };
-      setup(async () => {
-        assertIsDefined(element.diffHost);
-        sinon.stub(element.diffHost, 'reload');
-        sinon.stub(element, 'initCursor');
-        element.change = change;
-        await element.updateComplete;
-        await element.diffHost.updateComplete;
-      });
-
-      test('uses the patchNum and basePatchNum ', async () => {
-        element.viewState = {
-          view: GerritView.DIFF,
-          changeNum: 42 as NumericChangeId,
-          patchNum: 4 as RevisionPatchSetNum,
-          basePatchNum: 2 as BasePatchSetNum,
-          path: '/COMMIT_MSG',
-        };
-        element.change = change;
-        await element.updateComplete;
-        await waitEventLoop();
-        assert.deepEqual(element.commitRange, {
-          baseCommit: 'commit-sha-2' as CommitId,
-          commit: 'commit-sha-4' as CommitId,
-        });
-      });
-
-      test('uses the parent when there is no base patch num ', async () => {
-        element.viewState = {
-          view: GerritView.DIFF,
-          changeNum: 42 as NumericChangeId,
-          patchNum: 5 as RevisionPatchSetNum,
-          path: '/COMMIT_MSG',
-        };
-        element.change = change;
-        await element.updateComplete;
-        await waitEventLoop();
-        assert.deepEqual(element.commitRange, {
-          commit: 'commit-sha-5' as CommitId,
-          baseCommit: 'sha-5-parent' as CommitId,
-        });
-      });
-    });
-
     test('initCursor', () => {
       assertIsDefined(element.cursor);
       assert.isNotOk(element.cursor.initialLineNumber);
 
       // Does nothing when viewState specify no cursor address:
-      element.initCursor(false);
+      element.leftSide = false;
+      element.initCursor();
       assert.isNotOk(element.cursor.initialLineNumber);
 
       // Does nothing when viewState specify side but no number:
-      element.initCursor(true);
+      element.leftSide = true;
+      element.initCursor();
       assert.isNotOk(element.cursor.initialLineNumber);
 
       // Revision hash: specifies lineNum but not side.
 
       element.focusLineNum = 234;
-      element.initCursor(false);
+      element.leftSide = false;
+      element.initCursor();
       assert.equal(element.cursor.initialLineNumber, 234);
       assert.equal(element.cursor.side, Side.RIGHT);
 
       // Base hash: specifies lineNum and side.
       element.focusLineNum = 345;
-      element.initCursor(true);
+      element.leftSide = true;
+      element.initCursor();
       assert.equal(element.cursor.initialLineNumber, 345);
       assert.equal(element.cursor.side, Side.LEFT);
 
       // Specifies right side:
       element.focusLineNum = 123;
-      element.initCursor(false);
+      element.leftSide = false;
+      element.initCursor();
       assert.equal(element.cursor.initialLineNumber, 123);
       assert.equal(element.cursor.side, Side.RIGHT);
     });
 
     test('getLineOfInterest', () => {
-      assert.isUndefined(element.getLineOfInterest(false));
+      element.leftSide = false;
+      assert.isUndefined(element.getLineOfInterest());
 
       element.focusLineNum = 12;
-      let result = element.getLineOfInterest(false);
+      element.leftSide = false;
+      let result = element.getLineOfInterest();
       assert.isOk(result);
       assert.equal(result!.lineNum, 12);
       assert.equal(result!.side, Side.RIGHT);
 
-      result = element.getLineOfInterest(true);
+      element.leftSide = true;
+      result = element.getLineOfInterest();
       assert.isOk(result);
       assert.equal(result!.lineNum, 12);
       assert.equal(result!.side, Side.LEFT);
@@ -1907,10 +1433,8 @@
         _number: 321 as NumericChangeId,
         project: 'foo/bar' as RepoName,
       };
-      element.patchRange = {
-        basePatchNum: 3 as BasePatchSetNum,
-        patchNum: 5 as RevisionPatchSetNum,
-      };
+      element.patchNum = 5 as RevisionPatchSetNum;
+      element.basePatchNum = 3 as BasePatchSetNum;
       const e = {detail: {number: 123, side: Side.RIGHT}} as CustomEvent;
 
       element.onLineSelected(e);
@@ -1931,10 +1455,8 @@
         _number: 321 as NumericChangeId,
         project: 'foo/bar' as RepoName,
       };
-      element.patchRange = {
-        basePatchNum: 3 as BasePatchSetNum,
-        patchNum: 5 as RevisionPatchSetNum,
-      };
+      element.patchNum = 5 as RevisionPatchSetNum;
+      element.basePatchNum = 3 as BasePatchSetNum;
       const e = {detail: {number: 123, side: Side.LEFT}} as CustomEvent;
 
       element.onLineSelected(e);
@@ -1965,199 +1487,116 @@
       });
     });
 
-    suite('initPatchRange', () => {
-      setup(async () => {
-        getDiffRestApiStub.returns(Promise.resolve(createDiff()));
-        element.viewState = {
-          view: GerritView.DIFF,
-          changeNum: 42 as NumericChangeId,
-          patchNum: 3 as RevisionPatchSetNum,
-          path: 'abcd',
-        };
-        await element.updateComplete;
-      });
-      test('empty', () => {
-        sinon.stub(element, 'getPaths').returns({});
-        element.initPatchRange();
-        assert.equal(Object.keys(element.commentMap ?? {}).length, 0);
-      });
-
-      test('has paths', () => {
-        sinon.stub(element, 'fetchFiles');
-        sinon.stub(element, 'getPaths').returns({
-          'path/to/file/one.cpp': true,
-          'path-to/file/two.py': true,
-        });
-        element.changeNum = 42 as NumericChangeId;
-        element.patchRange = {
-          basePatchNum: 3 as BasePatchSetNum,
-          patchNum: 5 as RevisionPatchSetNum,
-        };
-        element.initPatchRange();
-        assert.deepEqual(Object.keys(element.commentMap ?? {}), [
-          'path/to/file/one.cpp',
-          'path-to/file/two.py',
-        ]);
-      });
-    });
-
-    suite('computeCommentSkips', () => {
+    suite('findFileWithComment', () => {
       test('empty file list', () => {
-        const commentMap = {
-          'path/one.jpg': true,
-          'path/three.wav': true,
-        };
-        const path = 'path/two.m4v';
-        const result = element.computeCommentSkips(commentMap, [], path);
-        assert.isOk(result);
-        assert.isNotOk(result!.previous);
-        assert.isNotOk(result!.next);
+        element.changeComments = new ChangeComments({
+          'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+          'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+        });
+        element.path = 'path/two.m4v';
+        assert.isUndefined(element.findFileWithComment(-1));
+        assert.isUndefined(element.findFileWithComment(1));
       });
 
       test('finds skips', () => {
         const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
-        let path = fileList[1];
-        const commentMap: CommentMap = {};
-        commentMap[fileList[0]] = true;
-        commentMap[fileList[1]] = false;
-        commentMap[fileList[2]] = true;
+        element.files = {sortedPaths: fileList, changeFilesByPath: {}};
+        element.path = fileList[1];
+        element.changeComments = new ChangeComments({
+          'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+          'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+        });
 
-        let result = element.computeCommentSkips(commentMap, fileList, path);
-        assert.isOk(result);
-        assert.equal(result!.previous, fileList[0]);
-        assert.equal(result!.next, fileList[2]);
+        assert.equal(element.findFileWithComment(-1), fileList[0]);
+        assert.equal(element.findFileWithComment(1), fileList[2]);
 
-        commentMap[fileList[1]] = true;
+        element.changeComments = new ChangeComments({
+          'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+          'path/two.m4v': [createComment('c1', 1, 1, 'path/two.m4v')],
+          'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+        });
 
-        result = element.computeCommentSkips(commentMap, fileList, path);
-        assert.isOk(result);
-        assert.equal(result!.previous, fileList[0]);
-        assert.equal(result!.next, fileList[2]);
+        assert.equal(element.findFileWithComment(-1), fileList[0]);
+        assert.equal(element.findFileWithComment(1), fileList[2]);
 
-        path = fileList[0];
+        element.path = fileList[0];
 
-        result = element.computeCommentSkips(commentMap, fileList, path);
-        assert.isOk(result);
-        assert.isNull(result!.previous);
-        assert.equal(result!.next, fileList[1]);
+        assert.isUndefined(element.findFileWithComment(-1));
+        assert.equal(element.findFileWithComment(1), fileList[1]);
 
-        path = fileList[2];
+        element.path = fileList[2];
 
-        result = element.computeCommentSkips(commentMap, fileList, path);
-        assert.isOk(result);
-        assert.equal(result!.previous, fileList[1]);
-        assert.isNull(result!.next);
+        assert.equal(element.findFileWithComment(-1), fileList[1]);
+        assert.isUndefined(element.findFileWithComment(1));
       });
 
       suite('skip next/previous', () => {
-        let navToChangeStub: SinonStub;
-
         setup(() => {
-          navToChangeStub = sinon.stub(element, 'navToChangeView');
           element.files = getFilesFromFileList([
             'path/one.jpg',
             'path/two.m4v',
             'path/three.wav',
           ]);
-          element.patchRange = {
-            patchNum: 2 as RevisionPatchSetNum,
-            basePatchNum: 1 as BasePatchSetNum,
-          };
+          element.patchNum = 2 as RevisionPatchSetNum;
+          element.basePatchNum = 1 as BasePatchSetNum;
         });
 
-        suite('moveToPreviousFileWithComment', () => {
-          test('no skips', () => {
-            element.moveToPreviousFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isFalse(setUrlStub.called);
-          });
-
+        suite('moveToFileWithComment previous', () => {
           test('no previous', async () => {
-            const commentMap: CommentMap = {};
-            commentMap[element.files.sortedFileList[0]!] = false;
-            commentMap[element.files.sortedFileList[1]!] = false;
-            commentMap[element.files.sortedFileList[2]!] = true;
-            element.commentMap = commentMap;
-            element.path = element.files.sortedFileList[1];
+            element.changeComments = new ChangeComments({
+              'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+            });
+            element.path = element.files.sortedPaths[1];
             await element.updateComplete;
 
-            element.moveToPreviousFileWithComment();
+            element.moveToFileWithComment(-1);
             assert.isTrue(navToChangeStub.calledOnce);
-            assert.isFalse(setUrlStub.called);
+            assert.isFalse(navToDiffStub.called);
           });
 
           test('w/ previous', async () => {
-            const commentMap: CommentMap = {};
-            commentMap[element.files.sortedFileList[0]!] = true;
-            commentMap[element.files.sortedFileList[1]!] = false;
-            commentMap[element.files.sortedFileList[2]!] = true;
-            element.commentMap = commentMap;
-            element.path = element.files.sortedFileList[1];
+            element.changeComments = new ChangeComments({
+              'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+              'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+            });
+            element.path = element.files.sortedPaths[1];
             await element.updateComplete;
 
-            element.moveToPreviousFileWithComment();
+            element.moveToFileWithComment(-1);
             assert.isFalse(navToChangeStub.called);
-            assert.isTrue(setUrlStub.calledOnce);
+            assert.isTrue(navToDiffStub.calledOnce);
           });
         });
 
-        suite('moveToNextFileWithComment', () => {
-          test('no skips', () => {
-            element.moveToNextFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isFalse(setUrlStub.called);
-          });
-
+        suite('moveToFileWithComment next', () => {
           test('no previous', async () => {
-            const commentMap: CommentMap = {};
-            commentMap[element.files.sortedFileList[0]!] = true;
-            commentMap[element.files.sortedFileList[1]!] = false;
-            commentMap[element.files.sortedFileList[2]!] = false;
-            element.commentMap = commentMap;
-            element.path = element.files.sortedFileList[1];
+            element.changeComments = new ChangeComments({
+              'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+            });
+            element.path = element.files.sortedPaths[1];
             await element.updateComplete;
 
-            element.moveToNextFileWithComment();
+            element.moveToFileWithComment(1);
             assert.isTrue(navToChangeStub.calledOnce);
-            assert.isFalse(setUrlStub.called);
+            assert.isFalse(navToDiffStub.called);
           });
 
           test('w/ previous', async () => {
-            const commentMap: CommentMap = {};
-            commentMap[element.files.sortedFileList[0]!] = true;
-            commentMap[element.files.sortedFileList[1]!] = false;
-            commentMap[element.files.sortedFileList[2]!] = true;
-            element.commentMap = commentMap;
-            element.path = element.files.sortedFileList[1];
+            element.changeComments = new ChangeComments({
+              'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+              'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+            });
+            element.path = element.files.sortedPaths[1];
             await element.updateComplete;
 
-            element.moveToNextFileWithComment();
+            element.moveToFileWithComment(1);
             assert.isFalse(navToChangeStub.called);
-            assert.isTrue(setUrlStub.calledOnce);
+            assert.isTrue(navToDiffStub.calledOnce);
           });
         });
       });
     });
 
-    test('_computeEditMode', () => {
-      const callCompute = (range: PatchRange) => {
-        element.patchRange = range;
-        return element.computeEditMode();
-      };
-      assert.isFalse(
-        callCompute({
-          basePatchNum: PARENT,
-          patchNum: 1 as RevisionPatchSetNum,
-        })
-      );
-      assert.isTrue(
-        callCompute({
-          basePatchNum: 1 as BasePatchSetNum,
-          patchNum: EDIT,
-        })
-      );
-    });
-
     test('computeFileNum', () => {
       element.path = '/foo';
       assert.equal(
@@ -2224,15 +1663,18 @@
 
       test('reviewed checkbox', async () => {
         sinon.stub(element, 'handlePatchChange');
-        element.patchRange = createPatchRange();
-        await element.updateComplete;
-        assertIsDefined(element.reviewed);
-        // Reviewed checkbox should be shown.
-        assert.isTrue(isVisible(element.reviewed));
-        element.patchRange = {...element.patchRange, patchNum: EDIT};
+        element.patchNum = 1 as RevisionPatchSetNum;
+        element.basePatchNum = PARENT;
         await element.updateComplete;
 
-        assert.isFalse(isVisible(element.reviewed));
+        let checkbox = queryAndAssert(element, '#reviewed');
+        assert.isTrue(isVisible(checkbox));
+
+        element.patchNum = EDIT;
+        await element.updateComplete;
+
+        checkbox = queryAndAssert(element, '#reviewed');
+        assert.isFalse(isVisible(checkbox));
       });
     });
 
@@ -2377,48 +1819,43 @@
 
     test('File change should trigger setUrl once', async () => {
       element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      sinon.stub(element, 'initLineOfInterestAndCursor');
+      sinon.stub(element, 'initCursor');
 
       // Load file1
-      element.viewState = {
-        view: GerritView.DIFF,
+      viewModel.setState({
+        ...createDiffViewState(),
         patchNum: 1 as RevisionPatchSetNum,
-        changeNum: 101 as NumericChangeId,
         repo: 'test-project' as RepoName,
-        path: 'file1',
-      };
-      element.patchRange = {
-        patchNum: 1 as RevisionPatchSetNum,
-        basePatchNum: PARENT,
-      };
+        diffView: {path: 'file1'},
+      });
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.change = {
         ...createParsedChange(),
         revisions: createRevisions(1),
       };
       await element.updateComplete;
-      assert.isFalse(setUrlStub.called);
+      assert.isFalse(navToDiffStub.called);
 
       // Switch to file2
       element.handleFileChange(
         new CustomEvent('value-change', {detail: {value: 'file2'}})
       );
-      assert.isTrue(setUrlStub.calledOnce);
+      assert.isTrue(navToDiffStub.calledOnce);
+      assert.deepEqual(navToDiffStub.lastCall.firstArg, {path: 'file2'});
 
       // This is to mock the param change triggered by above navigate
-      element.viewState = {
-        view: GerritView.DIFF,
+      viewModel.setState({
+        ...createDiffViewState(),
         patchNum: 1 as RevisionPatchSetNum,
-        changeNum: 101 as NumericChangeId,
         repo: 'test-project' as RepoName,
-        path: 'file2',
-      };
-      element.patchRange = {
-        patchNum: 1 as RevisionPatchSetNum,
-        basePatchNum: PARENT,
-      };
+        diffView: {path: 'file2'},
+      });
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
 
       // No extra call
-      assert.isTrue(setUrlStub.calledOnce);
+      assert.isTrue(navToDiffStub.calledOnce);
     });
 
     test('_computeDownloadDropdownLinks', () => {
@@ -2440,10 +1877,8 @@
       element.change = createParsedChange();
       element.change.project = 'test' as RepoName;
       element.changeNum = 12 as NumericChangeId;
-      element.patchRange = {
-        patchNum: 1 as RevisionPatchSetNum,
-        basePatchNum: PARENT,
-      };
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
       element.path = 'index.php';
       element.diff = createDiff();
       assert.deepEqual(element.computeDownloadDropdownLinks(), downloadLinks);
@@ -2472,10 +1907,8 @@
       element.change = createParsedChange();
       element.change.project = 'test' as RepoName;
       element.changeNum = 12 as NumericChangeId;
-      element.patchRange = {
-        patchNum: 3 as RevisionPatchSetNum,
-        basePatchNum: 2 as BasePatchSetNum,
-      };
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = 2 as BasePatchSetNum;
       element.path = 'index.php';
       element.diff = diff;
       assert.deepEqual(element.computeDownloadDropdownLinks(), downloadLinks);
@@ -2539,49 +1972,4 @@
       );
     });
   });
-
-  suite('unmodified files with comments', () => {
-    let element: GrDiffView;
-
-    setup(async () => {
-      const changedFiles = {
-        'file1.txt': createFileInfo(),
-        'a/b/test.c': createFileInfo(),
-      };
-      stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
-      stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
-      stubRestApi('getChangeFiles').returns(Promise.resolve(changedFiles));
-      stubRestApi('saveFileReviewed').returns(Promise.resolve(new Response()));
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      stubRestApi('getReviewedFiles').returns(Promise.resolve([]));
-      element = await fixture(html`<gr-diff-view></gr-diff-view>`);
-      element.changeNum = 42 as NumericChangeId;
-    });
-
-    test('fetchFiles add files with comments without changes', () => {
-      element.patchRange = {
-        basePatchNum: 5 as BasePatchSetNum,
-        patchNum: 10 as RevisionPatchSetNum,
-      };
-      element.changeComments = {
-        getPaths: sinon.stub().returns({
-          'file2.txt': {},
-          'file1.txt': {},
-        }),
-      } as unknown as ChangeComments;
-      element.changeNum = 23 as NumericChangeId;
-      return element.fetchFiles().then(() => {
-        assert.deepEqual(element.files, {
-          sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
-          changeFilesByPath: {
-            'file1.txt': createFileInfo(),
-            'file2.txt': {status: 'U'} as FileInfo,
-            'a/b/test.c': createFileInfo(),
-          },
-        });
-      });
-    });
-  });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index d9f88f7..325798c 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -21,6 +21,7 @@
 import {
   BasePatchSetNum,
   EDIT,
+  NumericChangeId,
   PARENT,
   PatchSetNum,
   RevisionInfo,
@@ -36,7 +37,7 @@
 import {EditRevisionInfo} from '../../../types/types';
 import {a11yStyles} from '../../../styles/gr-a11y-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, PropertyValues, css, html} from 'lit';
+import {LitElement, css, html, nothing} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {commentsModelToken} from '../../../models/comments/comments-model';
@@ -44,6 +45,8 @@
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {ValueChangedEvent} from '../../../types/events';
 import {GeneratedWebLink} from '../../../utils/weblink-util';
+import {changeModelToken} from '../../../models/change/change-model';
+import {changeViewModelToken} from '../../../models/views/change';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -83,33 +86,27 @@
   @query('#patchNumDropdown')
   patchNumDropdown?: GrDropdownList;
 
-  @property({type: Array})
-  availablePatches?: PatchSet[];
+  @state()
+  availablePatches: PatchSet[] = [];
 
-  @property({type: String})
-  changeNum?: string;
+  @state()
+  changeNum?: NumericChangeId;
 
   @property({type: Object})
   filesWeblinks?: FilesWebLinks;
 
-  @property({type: String})
+  @state()
   patchNum?: RevisionPatchSetNum;
 
-  @property({type: String})
+  @state()
   basePatchNum?: BasePatchSetNum;
 
-  /** Not used directly. Translated into `sortedRevisions` in willUpdate(). */
-  @property({type: Object})
-  revisions: (RevisionInfo | EditRevisionInfo)[] = [];
-
-  @property({type: Object})
+  @state()
   revisionInfo?: RevisionInfoClass;
 
-  /** Private internal state, derived from `revisions` in willUpdate(). */
   @state()
-  private sortedRevisions: (RevisionInfo | EditRevisionInfo)[] = [];
+  sortedRevisions: (RevisionInfo | EditRevisionInfo)[] = [];
 
-  /** Private internal state, visible for testing. */
   @state()
   changeComments?: ChangeComments;
 
@@ -118,10 +115,44 @@
 
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   constructor() {
     super();
     subscribe(
       this,
+      () => this.getViewModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.revisionInfo = x ? new RevisionInfoClass(x) : undefined)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().patchNum$,
+      x => (this.patchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().basePatchNum$,
+      x => (this.basePatchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().patchsets$,
+      x => (this.availablePatches = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().revisions$,
+      x => (this.sortedRevisions = sortRevisions(Object.values(x || {})))
+    );
+    subscribe(
+      this,
       () => this.getCommentsModel().changeComments$,
       x => (this.changeComments = x)
     );
@@ -164,6 +195,9 @@
   }
 
   override render() {
+    if (!this.changeNum || !this.patchNum || !this.basePatchNum) {
+      return nothing;
+    }
     return html`
       <h3 class="assistive-tech-only">Patchset Range Selection</h3>
       <span class="patchRange" aria-label="patch range starts with">
@@ -203,16 +237,9 @@
     > `;
   }
 
-  override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('revisions')) {
-      this.sortedRevisions = sortRevisions(Object.values(this.revisions || {}));
-    }
-  }
-
   // Private method, but visible for testing.
   computeBaseDropdownContent(): DropdownItem[] {
     if (
-      this.availablePatches === undefined ||
       this.patchNum === undefined ||
       this.changeComments === undefined ||
       this.revisionInfo === undefined
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index c90992f..584fcd7 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -9,7 +9,7 @@
 import {GrPatchRangeSelect} from './gr-patch-range-select';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
-import {stubReporting, stubRestApi} from '../../../test/test-utils';
+import {stubReporting} from '../../../test/test-utils';
 import {
   BasePatchSetNum,
   EDIT,
@@ -25,8 +25,11 @@
 import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
 import {SpecialFilePath} from '../../../constants/constants';
 import {
+  createChangeViewState,
   createEditRevision,
+  createParsedChange,
   createRevision,
+  createRevisions,
 } from '../../../test/test-data-generators';
 import {PatchSet} from '../../../utils/patch-set-util';
 import {
@@ -36,6 +39,9 @@
 import {queryAndAssert} from '../../../test/test-utils';
 import {fire} from '../../../utils/event-util';
 import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {changeViewModelToken} from '../../../models/views/change';
+import {changeModelToken} from '../../../models/change/change-model';
 
 type RevIdToRevisionInfo = {
   [revisionId: string]: RevisionInfo | EditRevisionInfo;
@@ -53,16 +59,23 @@
   }
 
   setup(async () => {
-    stubRestApi('getDiffComments').returns(Promise.resolve({}));
-    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-
     // Element must be wrapped in an element with direct access to the
     // comment API.
     element = await fixture(
       html`<gr-patch-range-select></gr-patch-range-select>`
     );
 
+    const viewModel = testResolver(changeViewModelToken);
+    viewModel.setState({
+      ...createChangeViewState(),
+      patchNum: 1 as RevisionPatchSetNum,
+      basePatchNum: PARENT,
+    });
+    const changeModel = testResolver(changeModelToken);
+    changeModel.updateStateChange({
+      ...createParsedChange(),
+      revisions: createRevisions(5),
+    });
     // Stub methods on the changeComments object after changeComments has
     // been initialized.
     element.changeComments = new ChangeComments();
@@ -86,7 +99,7 @@
   });
 
   test('enabled/disabled options', async () => {
-    element.revisions = [
+    element.sortedRevisions = [
       createRevision(3),
       createEditRevision(2),
       createRevision(2),
@@ -119,13 +132,13 @@
       {num: 2, sha: '3'} as PatchSet,
       {num: 1, sha: '4'} as PatchSet,
     ];
-    element.revisions = [
-      createRevision(2),
-      createRevision(3),
-      createRevision(1),
+    element.sortedRevisions = [
       createRevision(4),
+      createRevision(3),
+      createRevision(2),
+      createRevision(1),
     ];
-    element.revisionInfo = getInfo(element.revisions);
+    element.revisionInfo = getInfo(element.sortedRevisions);
     const expectedResult: DropdownItem[] = [
       {
         disabled: true,
@@ -175,13 +188,13 @@
   });
 
   test('computeBaseDropdownContent called when patchNum updates', async () => {
-    element.revisions = [
-      createRevision(2),
-      createRevision(3),
-      createRevision(1),
+    element.sortedRevisions = [
       createRevision(4),
+      createRevision(3),
+      createRevision(2),
+      createRevision(1),
     ];
-    element.revisionInfo = getInfo(element.revisions);
+    element.revisionInfo = getInfo(element.sortedRevisions);
     element.availablePatches = [
       {num: 1, sha: '1'} as PatchSet,
       {num: 2, sha: '2'} as PatchSet,
@@ -201,13 +214,13 @@
   });
 
   test('computeBaseDropdownContent called when changeComments update', async () => {
-    element.revisions = [
-      createRevision(2),
-      createRevision(3),
-      createRevision(1),
+    element.sortedRevisions = [
       createRevision(4),
+      createRevision(3),
+      createRevision(2),
+      createRevision(1),
     ];
-    element.revisionInfo = getInfo(element.revisions);
+    element.revisionInfo = getInfo(element.sortedRevisions);
     element.availablePatches = [
       {num: 3, sha: '2'} as PatchSet,
       {num: 2, sha: '3'} as PatchSet,
@@ -226,13 +239,13 @@
   });
 
   test('computePatchDropdownContent called when basePatchNum updates', async () => {
-    element.revisions = [
+    element.sortedRevisions = [
       createRevision(2),
       createRevision(3),
       createRevision(1),
       createRevision(4),
     ];
-    element.revisionInfo = getInfo(element.revisions);
+    element.revisionInfo = getInfo(element.sortedRevisions);
     element.availablePatches = [
       {num: 1, sha: '1'} as PatchSet,
       {num: 2, sha: '2'} as PatchSet,
@@ -258,7 +271,7 @@
       {num: 1, sha: '4'} as PatchSet,
     ];
     element.basePatchNum = 1 as BasePatchSetNum;
-    element.revisions = [
+    element.sortedRevisions = [
       createRevision(3),
       createEditRevision(2),
       createRevision(2, 'description'),
@@ -402,13 +415,13 @@
       {num: 2, sha: '3'} as PatchSet,
       {num: 1, sha: '4'} as PatchSet,
     ];
-    element.revisions = [
+    element.sortedRevisions = [
       createRevision(2),
       createRevision(3),
       createRevision(1),
       createRevision(4),
     ];
-    element.revisionInfo = getInfo(element.revisions);
+    element.revisionInfo = getInfo(element.sortedRevisions);
     await element.updateComplete;
 
     element.addEventListener('patch-range-change', handler);
@@ -444,13 +457,13 @@
       {num: 2, sha: '3'} as PatchSet,
       {num: 1, sha: '4'} as PatchSet,
     ];
-    element.revisions = [
+    element.sortedRevisions = [
       createRevision(2),
       createRevision(3),
       createRevision(1),
       createRevision(4),
     ];
-    element.revisionInfo = getInfo(element.revisions);
+    element.revisionInfo = getInfo(element.sortedRevisions);
     element.patchNum = 1 as PatchSetNumber;
     element.basePatchNum = PARENT;
     await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index d1d05b7..ec1e48e 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -29,7 +29,7 @@
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
-import {createEditUrl} from '../../../models/views/edit';
+import {createEditUrl} from '../../../models/views/change';
 import {resolve} from '../../../models/dependency';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {whenVisible} from '../../../utils/dom-util';
@@ -429,8 +429,8 @@
     const url = createEditUrl({
       changeNum: this.change._number,
       repo: this.change.project,
-      path: this.path,
       patchNum: this.patchNum,
+      editView: {path: this.path},
     });
 
     this.getNavigation().setUrl(url);
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 8e346c3..acc4c9e 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -31,8 +31,12 @@
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
 import {ShortcutController} from '../../lit/shortcut-controller';
-import {editViewModelToken, EditViewState} from '../../../models/views/edit';
-import {createChangeUrl} from '../../../models/views/change';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+  ChangeViewState,
+  createChangeUrl,
+} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 
@@ -60,7 +64,7 @@
    */
 
   @property({type: Object})
-  viewState?: EditViewState;
+  viewState?: ChangeViewState;
 
   // private but used in test
   @state() change?: ParsedChangeInfo;
@@ -95,7 +99,7 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly getEditViewModel = resolve(this, editViewModelToken);
+  private readonly getViewModel = resolve(this, changeViewModelToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
@@ -116,8 +120,10 @@
     );
     subscribe(
       this,
-      () => this.getEditViewModel().state$,
+      () => this.getViewModel().state$,
       state => {
+        // TODO: Add a setter for `viewState` instead of relying on the
+        // `viewStateChanged()` call here.
         this.viewState = state;
         this.viewStateChanged();
       }
@@ -206,7 +212,7 @@
   }
 
   override render() {
-    if (!this.viewState) return;
+    if (this.viewState?.childView !== ChangeChildView.EDIT) return nothing;
     return html` ${this.renderHeader()} ${this.renderEndpoint()} `;
   }
 
@@ -220,7 +226,7 @@
             <span class="separator"></span>
             <gr-editable-label
               labelText="File path"
-              .value=${this.viewState?.path}
+              .value=${this.viewState?.editView?.path}
               placeholder="File path..."
               @changed=${this.handlePathChanged}
             ></gr-editable-label>
@@ -277,7 +283,7 @@
           ></gr-endpoint-param>
           <gr-endpoint-param
             name="lineNum"
-            .value=${this.viewState?.lineNum}
+            .value=${this.viewState?.editView?.lineNum}
           ></gr-endpoint-param>
           <gr-default-editor
             id="file"
@@ -298,19 +304,21 @@
   }
 
   get storageKey() {
-    return `c${this.viewState?.changeNum}_ps${this.viewState?.patchNum}_${this.viewState?.path}`;
+    return `c${this.viewState?.changeNum}_ps${this.viewState?.patchNum}_${this.viewState?.editView?.path}`;
   }
 
   // private but used in test
   viewStateChanged() {
-    if (!this.viewState) return;
+    if (this.viewState?.childView !== ChangeChildView.EDIT) return;
 
     // NOTE: This may be called before attachment (e.g. while parentElement is
     // null). Fire title-change in an async so that, if attachment to the DOM
     // has been queued, the event can bubble up to the handler in gr-app.
     setTimeout(() => {
       if (!this.viewState) return;
-      const title = `Editing ${computeTruncatedPath(this.viewState.path)}`;
+      const title = `Editing ${computeTruncatedPath(
+        this.viewState.editView?.path
+      )}`;
       fireTitleChange(this, title);
     });
 
@@ -347,7 +355,7 @@
   // private but used in test
   async handlePathChanged(e: CustomEvent<string>): Promise<void> {
     const changeNum = this.viewState?.changeNum;
-    const currentPath = this.viewState?.path;
+    const currentPath = this.viewState?.editView?.path;
     assertIsDefined(changeNum, 'change number');
     assertIsDefined(currentPath, 'path');
 
@@ -376,7 +384,7 @@
   getFileData() {
     const changeNum = this.viewState?.changeNum;
     const patchNum = this.viewState?.patchNum;
-    const path = this.viewState?.path;
+    const path = this.viewState?.editView?.path;
     assertIsDefined(changeNum, 'change number');
     assertIsDefined(patchNum, 'patchset number');
     assertIsDefined(path, 'path');
@@ -416,7 +424,7 @@
   // private but used in test
   saveEdit() {
     const changeNum = this.viewState?.changeNum;
-    const path = this.viewState?.path;
+    const path = this.viewState?.editView?.path;
     assertIsDefined(changeNum, 'change number');
     assertIsDefined(path, 'path');
 
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index d428e18..1a4879d 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -72,7 +72,7 @@
                 labeltext="File path"
                 placeholder="File path..."
                 tabindex="0"
-                title="${element.viewState?.path}"
+                title="${element.viewState?.editView?.path}"
               >
               </gr-editable-label>
             </span>
@@ -373,7 +373,7 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: EDIT,
-        path: 'test/path',
+        editView: {path: 'test/path'},
       };
 
       // Ensure no data is set with a bad response.
@@ -392,7 +392,7 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: EDIT,
-        path: 'test/path',
+        editView: {path: 'test/path'},
       };
 
       // Ensure no data is set with a bad response.
@@ -415,7 +415,7 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: EDIT,
-        path: 'test/path',
+        editView: {path: 'test/path'},
       };
 
       return element.getFileData().then(() => {
@@ -433,7 +433,7 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: EDIT,
-        path: 'test/path',
+        editView: {path: 'test/path'},
       };
 
       return element.getFileData().then(() => {
@@ -530,7 +530,7 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: 1 as RevisionPatchSetNum,
-        path: 'test',
+        editView: {path: 'test'},
       };
 
       const alertStub = sinon.stub();
@@ -562,7 +562,7 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: 1 as RevisionPatchSetNum,
-        path: 'test',
+        editView: {path: 'test'},
       };
 
       const alertStub = sinon.stub();
@@ -583,7 +583,7 @@
         ...createEditViewState(),
         changeNum: 1 as NumericChangeId,
         patchNum: 1 as RevisionPatchSetNum,
-        path: 'test',
+        editView: {path: 'test'},
       };
       assert.equal(element.storageKey, 'c1_ps1_test');
     });
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index c00def6..eceb05e 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -23,7 +23,6 @@
 import './plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import './plugins/gr-endpoint-param/gr-endpoint-param';
 import './plugins/gr-endpoint-slot/gr-endpoint-slot';
-import './plugins/gr-external-style/gr-external-style';
 import './plugins/gr-plugin-host/gr-plugin-host';
 import './settings/gr-cla-view/gr-cla-view';
 import './settings/gr-registration-dialog/gr-registration-dialog';
@@ -74,6 +73,8 @@
 import {createDashboardUrl} from '../models/views/dashboard';
 import {userModelToken} from '../models/user/user-model';
 import {modalStyles} from '../styles/gr-modal-styles';
+import {AdminChildView, createAdminUrl} from '../models/views/admin';
+import {ChangeChildView, changeViewModelToken} from '../models/views/change';
 
 interface ErrorInfo {
   text: string;
@@ -119,6 +120,9 @@
 
   @state() private view?: GerritView;
 
+  // TODO: Introduce a wrapper element for CHANGE, DIFF, EDIT view.
+  @state() private childView?: ChangeChildView;
+
   @state() private lastError?: ErrorInfo;
 
   // private but used in test
@@ -152,8 +156,6 @@
 
   @state() private theme = AppTheme.AUTO;
 
-  @state() private themeEndpoint = 'app-theme-light';
-
   readonly getRouter = resolve(this, routerToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
@@ -170,6 +172,8 @@
 
   private readonly getRouterModel = resolve(this, routerModelToken);
 
+  private readonly getChangeViewModel = resolve(this, changeViewModelToken);
+
   constructor() {
     super();
 
@@ -212,6 +216,16 @@
         createSearchUrl({query: 'is:watched is:open'})
       )
     );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_REPOS, () =>
+      this.getNavigation().setUrl(
+        createAdminUrl({adminView: AdminChildView.REPOS})
+      )
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_GROUPS, () =>
+      this.getNavigation().setUrl(
+        createAdminUrl({adminView: AdminChildView.GROUPS})
+      )
+    );
 
     subscribe(
       this,
@@ -229,6 +243,13 @@
         if (view) this.errorView?.classList.remove('show');
       }
     );
+    subscribe(
+      this,
+      () => this.getChangeViewModel().childView$,
+      childView => {
+        this.childView = childView;
+      }
+    );
 
     prefersDarkColorScheme().addEventListener('change', () => {
       if (this.theme === AppTheme.AUTO) {
@@ -374,14 +395,6 @@
         .loginUrl=${this.loginUrl}
       ></gr-error-manager>
       <gr-plugin-host id="plugins"></gr-plugin-host>
-      <gr-external-style
-        id="externalStyleForAll"
-        name="app-theme"
-      ></gr-external-style>
-      <gr-external-style
-        id="externalStyleForTheme"
-        name=${this.themeEndpoint}
-      ></gr-external-style>
     `;
   }
 
@@ -464,9 +477,7 @@
       this.updateComplete.then(() => (this.invalidateChangeViewCache = false));
       return nothing;
     }
-    return cache(
-      this.view === GerritView.CHANGE ? this.changeViewTemplate() : nothing
-    );
+    return cache(this.isChangeView() ? this.changeViewTemplate() : nothing);
   }
 
   // Template as not to create duplicates, for renderChangeView() only.
@@ -476,8 +487,27 @@
     `;
   }
 
+  private isChangeView() {
+    return (
+      this.view === GerritView.CHANGE &&
+      this.childView === ChangeChildView.OVERVIEW
+    );
+  }
+
+  private isDiffView() {
+    return (
+      this.view === GerritView.CHANGE && this.childView === ChangeChildView.DIFF
+    );
+  }
+
+  private isEditorView() {
+    return (
+      this.view === GerritView.CHANGE && this.childView === ChangeChildView.EDIT
+    );
+  }
+
   private renderEditorView() {
-    if (this.view !== GerritView.EDIT) return nothing;
+    if (!this.isEditorView()) return nothing;
     return html`<gr-editor-view></gr-editor-view>`;
   }
 
@@ -486,9 +516,7 @@
       this.updateComplete.then(() => (this.invalidateDiffViewCache = false));
       return nothing;
     }
-    return cache(
-      this.view === GerritView.DIFF ? this.diffViewTemplate() : nothing
-    );
+    return cache(this.isDiffView() ? this.diffViewTemplate() : nothing);
   }
 
   private diffViewTemplate() {
@@ -622,11 +650,12 @@
     const showDarkTheme = isDarkTheme(this.theme);
     document.documentElement.classList.toggle('darkTheme', showDarkTheme);
     document.documentElement.classList.toggle('lightTheme', !showDarkTheme);
+    // TODO: Remove this code for adding/removing dark theme style. We should
+    // be able to just always add them once we have changed its css selector
+    // from `html` to `html.darkTheme`.
     if (showDarkTheme) {
-      this.themeEndpoint = 'app-theme-dark';
       applyDarkTheme();
     } else {
-      this.themeEndpoint = 'app-theme-light';
       removeDarkTheme();
     }
   }
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 3008236..0261992 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -13,8 +13,6 @@
 import {SearchViewState} from '../models/views/search';
 import {DashboardViewState} from '../models/views/dashboard';
 import {ChangeViewState} from '../models/views/change';
-import {DiffViewState} from '../models/views/diff';
-import {EditViewState} from '../models/views/edit';
 
 export interface AppElement extends HTMLElement {
   params: AppElementParams;
@@ -41,8 +39,6 @@
   | SearchViewState
   | SettingsViewState
   | AgreementViewState
-  | DiffViewState
-  | EditViewState
   | AppElementJustRegisteredParams;
 
 export function isAppElementJustRegisteredParams(
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index d5fc9f8..9cacaea 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -5,7 +5,10 @@
  */
 import {html, LitElement} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
-import {ModuleInfo} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {
+  EndpointType,
+  ModuleInfo,
+} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, PluginElement} from '../../../api/hook';
 import {getAppContext} from '../../../services/app-context';
@@ -195,10 +198,10 @@
     }
     let initPromise;
     switch (type) {
-      case 'decorate':
+      case EndpointType.DECORATE:
         initPromise = this.initDecoration(moduleName, plugin, slot);
         break;
-      case 'replace':
+      case EndpointType.REPLACE:
         initPromise = this.initReplacement(moduleName, plugin);
         break;
     }
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
deleted file mode 100644
index 5a0e8df..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin';
-import {LitElement, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {resolve} from '../../../models/dependency';
-
-@customElement('gr-external-style')
-export class GrExternalStyle extends LitElement {
-  // This is a required value for this component.
-  @property({type: String, reflect: true})
-  name!: string;
-
-  // private but used in test
-  stylesApplied: string[] = [];
-
-  stylesElements: HTMLElement[] = [];
-
-  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
-
-  override render() {
-    return html`<slot></slot>`;
-  }
-
-  override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('name')) {
-      // We remove all styles defined for different name.
-      this.removeStyles();
-      this.importAndApply();
-      this.getPluginLoader()
-        .awaitPluginsLoaded()
-        .then(() => this.importAndApply());
-    }
-  }
-
-  // private but used in test
-  applyStyle(name: string) {
-    if (this.stylesApplied.includes(name)) {
-      return;
-    }
-    this.stylesApplied.push(name);
-
-    const s = document.createElement('style');
-    s.setAttribute('include', name);
-    const cs = document.createElement('custom-style');
-    this.stylesElements.push(cs);
-    cs.appendChild(s);
-    // When using Shadow DOM <custom-style> must be added to the <body>.
-    // Within <gr-external-style> itself the styles would have no effect.
-    const topEl = document.getElementsByTagName('body')[0];
-    topEl.insertBefore(cs, topEl.firstChild);
-    updateStyles();
-  }
-
-  removeStyles() {
-    this.stylesElements.forEach(el => el.remove());
-    this.stylesElements = [];
-    this.stylesApplied = [];
-  }
-
-  private importAndApply() {
-    const moduleNames = this.getPluginLoader().pluginEndPoints.getModules(
-      this.name
-    );
-    for (const name of moduleNames) {
-      this.applyStyle(name);
-    }
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-external-style': GrExternalStyle;
-  }
-}
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts
deleted file mode 100644
index 6c0e789..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import {mockPromise, MockPromise} from '../../../test/test-utils';
-import './gr-external-style';
-import {GrExternalStyle} from './gr-external-style';
-import {PluginApi} from '../../../api/plugin';
-import {fixture, html, assert} from '@open-wc/testing';
-import {testResolver} from '../../../test/common-test-setup';
-import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-
-suite('gr-external-style integration tests', () => {
-  const TEST_URL = 'http://some.com/plugins/url.js';
-
-  let element: GrExternalStyle;
-  let plugin: PluginApi | undefined;
-  let pluginsLoaded: MockPromise<void>;
-  let applyStyleSpy: sinon.SinonSpy;
-
-  const installPlugin = () => {
-    if (plugin) {
-      return;
-    }
-    window.Gerrit.install(
-      p => {
-        plugin = p;
-      },
-      '0.1',
-      TEST_URL
-    );
-  };
-
-  const createElement = async () => {
-    applyStyleSpy = sinon.spy(GrExternalStyle.prototype, 'applyStyle');
-    element = await fixture(
-      html`<gr-external-style .name=${'foo'}></gr-external-style>`
-    );
-    await element.updateComplete;
-  };
-
-  /**
-   * Installs the plugin, creates the element, registers style module.
-   */
-  const lateRegister = async () => {
-    installPlugin();
-    await createElement();
-    plugin!.registerStyleModule('foo', 'some-module');
-  };
-
-  /**
-   * Installs the plugin, registers style module, creates the element.
-   */
-  const earlyRegister = async () => {
-    installPlugin();
-    plugin!.registerStyleModule('foo', 'some-module');
-    await createElement();
-  };
-
-  setup(() => {
-    pluginsLoaded = mockPromise();
-    sinon
-      .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded')
-      .returns(pluginsLoaded);
-  });
-
-  teardown(() => {
-    plugin = undefined;
-    document.body
-      .querySelectorAll('custom-style')
-      .forEach(style => style.remove());
-  });
-
-  test('applies plugin-provided styles', async () => {
-    await lateRegister();
-    pluginsLoaded.resolve();
-    await element.updateComplete;
-    assert.isTrue(applyStyleSpy.calledWith('some-module'));
-  });
-
-  test('does not double apply', async () => {
-    await earlyRegister();
-    await element.updateComplete;
-    plugin!.registerStyleModule('foo', 'some-module');
-    await element.updateComplete;
-    const stylesApplied = element.stylesApplied.filter(
-      name => name === 'some-module'
-    );
-    assert.strictEqual(stylesApplied.length, 1);
-  });
-
-  test('loads and applies preloaded modules', async () => {
-    await earlyRegister();
-    await element.updateComplete;
-    assert.isTrue(applyStyleSpy.calledWith('some-module'));
-  });
-
-  test('removes old custom-style if name is changed', async () => {
-    installPlugin();
-    plugin!.registerStyleModule('bar', 'some-module');
-    await earlyRegister();
-    await element.updateComplete;
-    let customStyles = document.body.querySelectorAll('custom-style');
-    assert.strictEqual(customStyles.length, 1);
-    element.name = 'bar';
-    await element.updateComplete;
-    customStyles = document.body.querySelectorAll('custom-style');
-    assert.strictEqual(customStyles.length, 1);
-    element.name = 'baz';
-    await element.updateComplete;
-    customStyles = document.body.querySelectorAll('custom-style');
-    assert.strictEqual(customStyles.length, 0);
-  });
-
-  test('can apply more than one style', async () => {
-    await earlyRegister();
-    await element.updateComplete;
-    plugin!.registerStyleModule('foo', 'some-module2');
-    pluginsLoaded.resolve();
-    await element.updateComplete;
-    assert.strictEqual(element.stylesApplied.length, 2);
-    const customStyles = document.body.querySelectorAll('custom-style');
-    assert.strictEqual(customStyles.length, 2);
-  });
-});
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index 1549da5..0e75abb 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -95,12 +95,26 @@
       .lengthCounter {
         font-weight: var(--font-weight-normal);
       }
+      p {
+        max-width: 65ch;
+        margin-bottom: var(--spacing-m);
+      }
     `,
   ];
 
   override render() {
     if (!this.account || this.loading) return nothing;
     return html`<div class="gr-form-styles">
+      <p>
+        All profile fields below may be publicly displayed to others, including
+        on changes you are associated with, as well as in search and
+        autocompletion.
+        <a
+          href="https://gerrit-review.googlesource.com/Documentation/user-privacy.html"
+          >Learn more</a
+        >
+      </p>
+      <gr-endpoint-decorator name="profile"></gr-endpoint-decorator>
       <section>
         <span class="title"></span>
         <span class="value">
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index e968b12..f954960 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -69,6 +69,16 @@
       element,
       /* HTML */ `
         <div class="gr-form-styles">
+          <p>
+            All profile fields below may be publicly displayed to others,
+            including on changes you are associated with, as well as in search
+            and autocompletion.
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-privacy.html"
+              >Learn more</a
+            >
+          </p>
+          <gr-endpoint-decorator name="profile"></gr-endpoint-decorator>
           <section>
             <span class="title"></span>
             <span class="value">
@@ -134,7 +144,8 @@
             </span>
           </section>
         </div>
-      `
+      `,
+      {ignoreChildren: ['p']}
     );
   });
 
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
index 17ac2a6..9c323aa 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
@@ -6,7 +6,6 @@
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-overlay/gr-overlay';
 import {SshKeyInfo} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index df41b1f4..dd0fbca 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -72,8 +72,7 @@
 import {whenRendered} from '../../../utils/dom-util';
 import {Interaction} from '../../../constants/reporting';
 import {HtmlPatched} from '../../../utils/lit-util';
-import {createDiffUrl} from '../../../models/views/diff';
-import {createChangeUrl} from '../../../models/views/change';
+import {createChangeUrl, createDiffUrl} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {highlightServiceToken} from '../../../services/highlight/highlight-service';
 
@@ -746,8 +745,8 @@
     return createDiffUrl({
       changeNum: this.changeNum,
       repo: this.repoName,
-      path: this.thread.path,
       patchNum: this.thread.patchNum,
+      diffView: {path: this.thread.path},
     });
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 5306cea..c1cc863 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -60,7 +60,7 @@
 import {Interaction} from '../../../constants/reporting';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {isBase64FileContent} from '../../../api/rest-api';
-import {createDiffUrl} from '../../../models/views/diff';
+import {createDiffUrl} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 
@@ -130,6 +130,9 @@
   @query('#confirmDeleteModal')
   confirmDeleteModal?: HTMLDialogElement;
 
+  @query('#confirmDeleteCommentDialog')
+  confirmDeleteDialog?: GrConfirmDeleteCommentDialog;
+
   @property({type: Object})
   comment?: Comment;
 
@@ -200,9 +203,6 @@
   unresolved = true;
 
   @property({type: Boolean})
-  showConfirmDeleteModal = false;
-
-  @property({type: Boolean})
   unableToSave = false;
 
   @property({type: Boolean, attribute: 'show-patchset'})
@@ -275,9 +275,7 @@
         this.save();
       });
     }
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      this.messagePlaceholder = 'Mention others with @';
-    }
+    this.messagePlaceholder = 'Mention others with @';
     subscribe(
       this,
       () => this.getUserModel().account$,
@@ -547,8 +545,9 @@
           ${this.renderDraftLabel()}
         </div>
         <div class="headerMiddle">${this.renderCollapsedContent()}</div>
-        ${this.renderRunDetails()} ${this.renderDeleteButton()}
-        ${this.renderPatchset()} ${this.renderDate()} ${this.renderToggle()}
+        ${this.renderSuggestEditButton()} ${this.renderRunDetails()}
+        ${this.renderDeleteButton()} ${this.renderPatchset()}
+        ${this.renderDate()} ${this.renderToggle()}
       </div>
     `;
   }
@@ -644,7 +643,10 @@
         title="Delete Comment"
         link
         class="action delete"
-        @click=${this.openDeleteCommentModal}
+        @click=${(e: MouseEvent) => {
+          e.stopPropagation();
+          this.openDeleteCommentModal();
+        }}
       >
         <gr-icon id="icon" icon="delete" filled></gr-icon>
       </gr-button>
@@ -774,10 +776,9 @@
     return html`
       <div class="rightActions">
         ${this.autoSaving ? html`.&nbsp;&nbsp;` : ''}
-        ${this.renderDiscardButton()} ${this.renderSuggestEditButton()}
-        ${this.renderPreviewSuggestEditButton()} ${this.renderEditButton()}
-        ${this.renderCancelButton()} ${this.renderSaveButton()}
-        ${this.renderCopyLinkIcon()}
+        ${this.renderDiscardButton()} ${this.renderPreviewSuggestEditButton()}
+        ${this.renderEditButton()} ${this.renderCancelButton()}
+        ${this.renderSaveButton()} ${this.renderCopyLinkIcon()}
       </div>
     `;
   }
@@ -806,6 +807,7 @@
       return nothing;
     }
     if (
+      !this.editing ||
       this.permanentEditingMode ||
       this.comment?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
     ) {
@@ -937,11 +939,10 @@
   }
 
   private renderConfirmDialog() {
-    if (!this.showConfirmDeleteModal) return;
     return html`
       <dialog id="confirmDeleteModal" tabindex="-1">
         <gr-confirm-delete-comment-dialog
-          id="confirmDeleteComment"
+          id="confirmDeleteCommentDialog"
           @confirm=${this.handleConfirmDeleteComment}
           @cancel=${this.closeDeleteCommentModal}
         >
@@ -1137,7 +1138,8 @@
     fire(this, 'open-fix-preview', await this.createFixPreview());
   }
 
-  async createSuggestEdit() {
+  async createSuggestEdit(e: MouseEvent) {
+    e.stopPropagation();
     const line = await this.getCommentedCode();
     this.messageText += `${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`;
   }
@@ -1261,51 +1263,35 @@
     }
   }
 
-  private async openDeleteCommentModal() {
-    this.showConfirmDeleteModal = true;
-    await this.updateComplete;
-    await this.confirmDeleteModal?.showModal();
+  private openDeleteCommentModal() {
+    this.confirmDeleteModal?.showModal();
+    whenVisible(this.confirmDeleteDialog!, () => {
+      this.confirmDeleteDialog!.resetFocus();
+    });
   }
 
   private closeDeleteCommentModal() {
-    this.showConfirmDeleteModal = false;
-    this.confirmDeleteModal?.remove();
     this.confirmDeleteModal?.close();
   }
 
   /**
    * Deleting a *published* comment is an admin feature. It means more than just
    * discarding a draft.
-   *
-   * TODO: Also move this into the comments-service.
-   * TODO: Figure out a good reloading strategy when deleting was successful.
-   *       `this.comment = newComment` does not seem sufficient.
    */
   // private, but visible for testing
-  handleConfirmDeleteComment() {
-    const dialog = this.confirmDeleteModal?.querySelector(
-      '#confirmDeleteComment'
-    ) as GrConfirmDeleteCommentDialog | null;
-    if (!dialog || !dialog.message) {
+  async handleConfirmDeleteComment() {
+    if (!this.confirmDeleteDialog || !this.confirmDeleteDialog.message) {
       throw new Error('missing confirm delete dialog');
     }
     assertIsDefined(this.changeNum, 'changeNum');
     assertIsDefined(this.comment, 'comment');
-    assertIsDefined(this.comment.patch_set, 'comment.patch_set');
-    if (isDraftOrUnsaved(this.comment)) {
-      throw new Error('Admin deletion is only for published comments.');
-    }
-    this.restApiService
-      .deleteComment(
-        this.changeNum,
-        this.comment.patch_set,
-        this.comment.id,
-        dialog.message
-      )
-      .then(newComment => {
-        this.closeDeleteCommentModal();
-        this.comment = newComment;
-      });
+
+    await this.getCommentsModel().deleteComment(
+      this.changeNum,
+      this.comment,
+      this.confirmDeleteDialog.message
+    );
+    this.closeDeleteCommentModal();
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 5b191c8..3390369 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -127,6 +127,10 @@
               </div>
             </div>
           </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
         `
       );
     });
@@ -166,6 +170,10 @@
               </div>
             </div>
           </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
         `
       );
     });
@@ -238,6 +246,10 @@
               </div>
             </div>
           </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
         `
       );
     });
@@ -336,6 +348,10 @@
               </div>
             </div>
           </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
         `
       );
     });
@@ -421,6 +437,10 @@
               </div>
             </div>
           </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
         `
       );
     });
@@ -503,7 +523,7 @@
     assertIsDefined(element.confirmDeleteModal, 'confirmDeleteModal');
     const dialog = queryAndAssert<GrConfirmDeleteCommentDialog>(
       element.confirmDeleteModal,
-      '#confirmDeleteComment'
+      '#confirmDeleteCommentDialog'
     );
     dialog.message = 'removal reason';
     await element.updateComplete;
@@ -834,12 +854,12 @@
           .initiallyCollapsed=${false}
         ></gr-comment>`
       );
+      element.editing = true;
     });
     test('renders suggest fix button', () => {
       assert.dom.equal(
         queryAndAssert(element, 'gr-button.suggestEdit'),
         /* HTML */ `<gr-button
-          aria-disabled="false"
           class="action suggestEdit"
           link=""
           role="button"
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
index b6512b3..285a41a 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -75,6 +75,7 @@
   override render() {
     return html` <gr-dialog
       confirm-label="Delete"
+      ?disabled=${this.message === ''}
       @confirm=${this.handleConfirmTap}
       @cancel=${this.handleCancelTap}
     >
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
index bd84ac4..b7551c1 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
@@ -7,6 +7,7 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrConfirmDeleteCommentDialog} from './gr-confirm-delete-comment-dialog';
 import './gr-confirm-delete-comment-dialog';
+import {GrDialog} from '../gr-dialog/gr-dialog';
 
 suite('gr-confirm-delete-comment-dialog tests', () => {
   let element: GrConfirmDeleteCommentDialog;
@@ -17,7 +18,10 @@
     );
   });
 
-  test('render', () => {
+  test('render', async () => {
+    element.message = 'Just cause';
+    await element.updateComplete;
+
     // prettier and shadowDom string disagree about wrapping in <p> tag.
     assert.shadowDom.equal(
       element,
@@ -43,4 +47,13 @@
     `
     );
   });
+
+  test('dialog is disabled when message is empty', async () => {
+    element.message = '';
+    await element.updateComplete;
+
+    assert.isTrue(
+      (element.shadowRoot!.querySelector('gr-dialog') as GrDialog).disabled
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index c8663e6..45eca40 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -18,8 +18,6 @@
 import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
 import '../gr-account-chip/gr-account-chip';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {getAppContext} from '../../../services/app-context';
 
 /**
  * This element optionally renders markdown and also applies some regex
@@ -36,8 +34,6 @@
   @state()
   private repoCommentLinks: CommentLinks = {};
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly getConfigModel = resolve(this, configModelToken);
 
   // Private const but used in tests.
@@ -198,16 +194,23 @@
     text = htmlEscape(text).toString();
     // Unescape block quotes '>'. This is slightly dangerous as '>' can be used
     // in HTML fragments, but it is insufficient on it's own.
-    text = text.replace(/(^|\n)&gt;/g, '$1>');
+    for (;;) {
+      const newText = text.replace(
+        /(^|\n)((?:\s{0,3}&gt;)*\s{0,3})&gt;/g,
+        '$1$2>'
+      );
+      if (newText === text) {
+        break;
+      }
+      text = newText;
+    }
 
     return text;
   }
 
   override updated() {
     // Look for @mentions and replace them with an account-label chip.
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      this.convertEmailsToAccountChips();
-    }
+    this.convertEmailsToAccountChips();
   }
 
   private convertEmailsToAccountChips() {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 9acca0f..3187ada 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -15,14 +15,9 @@
 import './gr-formatted-text';
 import {GrFormattedText} from './gr-formatted-text';
 import {createConfig} from '../../../test/test-data-generators';
-import {
-  queryAndAssert,
-  stubFlags,
-  waitUntilObserved,
-} from '../../../test/test-utils';
+import {queryAndAssert, waitUntilObserved} from '../../../test/test-utils';
 import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {testResolver} from '../../../test/common-test-setup';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 
 suite('gr-formatted-text tests', () => {
@@ -412,38 +407,7 @@
       );
     });
 
-    test('does not handle @mentions if not enabled', async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(false);
-      element.content = '@someone@google.com';
-      await element.updateComplete;
-
-      assert.shadowDom.equal(
-        element,
-        /* HTML */ `
-          <marked-element>
-            <div slot="markdown-html" class="markdown-html">
-              <p>
-                @
-                <a
-                  href="mailto:someone@google.com"
-                  rel="noopener"
-                  target="_blank"
-                >
-                  someone@google.com
-                </a>
-              </p>
-            </div>
-          </marked-element>
-        `
-      );
-    });
-
-    test('handles @mentions if enabled', async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(true);
+    test('handles @mentions', async () => {
       element.content = '@someone@google.com';
       await element.updateComplete;
 
@@ -470,9 +434,6 @@
     });
 
     test('does not handle @mentions that is part of a code block', async () => {
-      stubFlags('isEnabled')
-        .withArgs(KnownExperimentId.MENTION_USERS)
-        .returns(true);
       element.content = '`@`someone@google.com';
       await element.updateComplete;
 
@@ -593,5 +554,27 @@
         `
       );
     });
+
+    test('renders nested block quotes', async () => {
+      element.content = '> > > block quote';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <blockquote>
+                <blockquote>
+                  <blockquote>
+                    <p>block quote</p>
+                  </blockquote>
+                </blockquote>
+              </blockquote>
+            </div>
+          </marked-element>
+        `
+      );
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index af744c6..b1b66ad 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -4,7 +4,6 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {PluginApi} from '../../../api/plugin';
-import {isDefined} from '../../../types/types';
 import {HookApi, PluginElement} from '../../../api/hook';
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -14,16 +13,29 @@
   moduleName: string;
   plugin: PluginApi;
   pluginUrl?: URL;
-  type?: string;
+  type?: EndpointType;
   domHook?: HookApi<PluginElement>;
   slot?: string;
 }
 
+/**
+ * Plugin-provided custom components can affect content in extension
+ * points using one of following methods:
+ * - DECORATE: custom component is set with `content` attribute and may
+ *   decorate (e.g. style) DOM element.
+ * - REPLACE: contents of extension point are replaced with the custom
+ *   component.
+ */
+export enum EndpointType {
+  DECORATE = 'decorate',
+  REPLACE = 'replace',
+}
+
 interface Options {
   endpoint: string;
   dynamicEndpoint?: string;
   slot?: string;
-  type?: string;
+  type?: EndpointType;
   moduleName?: string;
   domHook?: HookApi<PluginElement>;
 }
@@ -125,43 +137,7 @@
    * Get detailed information about modules registered with an extension
    * endpoint.
    */
-  getDetails(name: string, options?: Options): ModuleInfo[] {
-    const type = options && options.type;
-    const moduleName = options && options.moduleName;
-    if (!this._endpoints.has(name)) {
-      return [];
-    } else {
-      return this._endpoints
-        .get(name)!
-        .filter(
-          (item: ModuleInfo) =>
-            (!type || item.type === type) &&
-            (!moduleName || moduleName === item.moduleName)
-        );
-    }
-  }
-
-  /**
-   * Get detailed module names for instantiating at the endpoint.
-   */
-  getModules(name: string, options?: Options): string[] {
-    const modulesData = this.getDetails(name, options);
-    if (!modulesData.length) {
-      return [];
-    }
-    return modulesData.map(m => m.moduleName);
-  }
-
-  /**
-   * Get plugin URLs with element and module definitions.
-   */
-  getPlugins(name: string, options?: Options): URL[] {
-    const modulesData = this.getDetails(name, options);
-    if (!modulesData.length) {
-      return [];
-    }
-    return Array.from(new Set(modulesData.map(m => m.pluginUrl))).filter(
-      isDefined
-    );
+  getDetails(name: string): ModuleInfo[] {
+    return this._endpoints.get(name) ?? [];
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
index 2ef86ed..ddba546 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
@@ -5,7 +5,7 @@
  */
 import '../../../test/common-test-setup';
 import './gr-js-api-interface';
-import {GrPluginEndpoints} from './gr-plugin-endpoints';
+import {EndpointType, GrPluginEndpoints} from './gr-plugin-endpoints';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
 import {assert} from '@open-wc/testing';
@@ -39,7 +39,7 @@
 suite('gr-plugin-endpoints tests', () => {
   let instance: GrPluginEndpoints;
   let decoratePlugin: PluginApi;
-  let stylePlugin: PluginApi;
+  let replacePlugin: PluginApi;
   let domHook: HookApi<PluginElement>;
 
   setup(() => {
@@ -52,19 +52,19 @@
     );
     instance.registerModule(decoratePlugin, {
       endpoint: 'my-endpoint',
-      type: 'decorate',
+      type: EndpointType.DECORATE,
       moduleName: 'decorate-module',
       domHook,
     });
     window.Gerrit.install(
-      plugin => (stylePlugin = plugin),
+      plugin => (replacePlugin = plugin),
       '0.1',
-      'http://test.com/plugins/testplugin/static/style.js'
+      'http://test.com/plugins/testplugin/static/replace.js'
     );
-    instance.registerModule(stylePlugin, {
+    instance.registerModule(replacePlugin, {
       endpoint: 'my-endpoint',
-      type: 'style',
-      moduleName: 'style-module',
+      type: EndpointType.REPLACE,
+      moduleName: 'replace-module',
       domHook,
     });
   });
@@ -75,75 +75,28 @@
         moduleName: 'decorate-module',
         plugin: decoratePlugin,
         pluginUrl: decoratePlugin._url,
-        type: 'decorate',
+        type: EndpointType.DECORATE,
         domHook,
         slot: undefined,
       },
       {
-        moduleName: 'style-module',
-        plugin: stylePlugin,
-        pluginUrl: stylePlugin._url,
-        type: 'style',
+        moduleName: 'replace-module',
+        plugin: replacePlugin,
+        pluginUrl: replacePlugin._url,
+        type: EndpointType.REPLACE,
         domHook,
         slot: undefined,
       },
     ]);
   });
 
-  test('getDetails by type', () => {
-    assert.deepEqual(
-      instance.getDetails('my-endpoint', {endpoint: 'a-place', type: 'style'}),
-      [
-        {
-          moduleName: 'style-module',
-          plugin: stylePlugin,
-          pluginUrl: stylePlugin._url,
-          type: 'style',
-          domHook,
-          slot: undefined,
-        },
-      ]
-    );
-  });
-
-  test('getDetails by module', () => {
-    assert.deepEqual(
-      instance.getDetails('my-endpoint', {
-        endpoint: 'my-endpoint',
-        moduleName: 'decorate-module',
-      }),
-      [
-        {
-          moduleName: 'decorate-module',
-          plugin: decoratePlugin,
-          pluginUrl: decoratePlugin._url,
-          type: 'decorate',
-          domHook,
-          slot: undefined,
-        },
-      ]
-    );
-  });
-
-  test('getModules', () => {
-    assert.deepEqual(instance.getModules('my-endpoint'), [
-      'decorate-module',
-      'style-module',
-    ]);
-  });
-
-  test('getPlugins URLs are unique', () => {
-    assert.equal(decoratePlugin._url, stylePlugin._url);
-    assert.deepEqual(instance.getPlugins('my-endpoint'), [decoratePlugin._url]);
-  });
-
   test('onNewEndpoint', () => {
     const newModuleStub = sinon.stub();
     instance.setPluginsReady();
     instance.onNewEndpoint('my-endpoint', newModuleStub);
     instance.registerModule(decoratePlugin, {
       endpoint: 'my-endpoint',
-      type: 'replace',
+      type: EndpointType.REPLACE,
       moduleName: 'replace-module',
       domHook,
     });
@@ -151,7 +104,7 @@
       moduleName: 'replace-module',
       plugin: decoratePlugin,
       pluginUrl: decoratePlugin._url,
-      type: 'replace',
+      type: EndpointType.REPLACE,
       domHook,
       slot: undefined,
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 7170051..832b97e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -13,7 +13,7 @@
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
 import {GrEventHelper} from '../../plugins/gr-event-helper/gr-event-helper';
 import {GrPluginRestApi} from './gr-plugin-rest-api';
-import {GrPluginEndpoints} from './gr-plugin-endpoints';
+import {EndpointType, GrPluginEndpoints} from './gr-plugin-endpoints';
 import {getPluginNameFromUrl, send} from './gr-api-utils';
 import {GrReportingJsApi} from './gr-reporting-js-api';
 import {EventType, PluginApi, TargetElement} from '../../../api/plugin';
@@ -38,22 +38,6 @@
 import {GrPluginStyleApi} from './gr-plugin-style-api';
 import {StylePluginApi} from '../../../api/styles';
 
-/**
- * Plugin-provided custom components can affect content in extension
- * points using one of following methods:
- * - DECORATE: custom component is set with `content` attribute and may
- *   decorate (e.g. style) DOM element.
- * - REPLACE: contents of extension point are replaced with the custom
- *   component.
- * - STYLE: custom component is a shared styles module that is inserted
- *   into the extension point.
- */
-enum EndpointType {
-  DECORATE = 'decorate',
-  REPLACE = 'replace',
-  STYLE = 'style',
-}
-
 const PLUGIN_NAME_NOT_SET = 'NULL';
 
 export type SendCallback = (response: unknown) => void;
@@ -94,18 +78,6 @@
     return this._name;
   }
 
-  registerStyleModule(endpoint: string, moduleName: string) {
-    console.warn(
-      `The deprecated plugin API 'registerStyleModule()' was called with parameters '${endpoint}' and '${moduleName}'.`
-    );
-    this.report.trackApi(this, 'plugin', 'registerStyleModule');
-    this.pluginEndpoints.registerModule(this, {
-      endpoint,
-      type: EndpointType.STYLE,
-      moduleName,
-    });
-  }
-
   /**
    * Registers an endpoint for the plugin.
    */
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index fd43869..dad802a 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -121,30 +121,18 @@
       </div>
       <slot></slot>
       <nav>
-        Page ${this.computePage(this.offset, this.itemsPerPage)}
+        Page ${this.computePage()}
         <a
           id="prevArrow"
-          href=${this.computeNavLink(
-            this.offset,
-            -1,
-            this.itemsPerPage,
-            this.filter,
-            this.path
-          )}
+          href=${this.computeNavLink(-1)}
           ?hidden=${this.loading || this.offset === 0}
         >
           <gr-icon icon="chevron_left"></gr-icon>
         </a>
         <a
           id="nextArrow"
-          href=${this.computeNavLink(
-            this.offset,
-            1,
-            this.itemsPerPage,
-            this.filter,
-            this.path
-          )}
-          ?hidden=${this.hideNextArrow(this.loading, this.items)}
+          href=${this.computeNavLink(1)}
+          ?hidden=${this.hideNextArrow()}
         >
           <gr-icon icon="chevron_right"></gr-icon>
         </a>
@@ -177,9 +165,11 @@
       () => {
         if (!this.isConnected || !this.path) return;
         if (filter) {
+          // TODO: Use navigation service instead of `page.show()` directly.
           page.show(`${this.path}/q/filter:${encodeURL(filter, false)}`);
           return;
         }
+        // TODO: Use navigation service instead of `page.show()` directly.
         page.show(this.path);
       },
       REQUEST_DEBOUNCE_INTERVAL_MS
@@ -191,19 +181,13 @@
   }
 
   // private but used in test
-  computeNavLink(
-    offset: number,
-    direction: number,
-    itemsPerPage: number,
-    filter: string | undefined,
-    path = ''
-  ) {
+  computeNavLink(direction: number) {
     // Offset could be a string when passed from the router.
-    offset = +(offset || 0);
-    const newOffset = Math.max(0, offset + itemsPerPage * direction);
-    let href = getBaseUrl() + path;
-    if (filter) {
-      href += '/q/filter:' + encodeURL(filter, false);
+    const offset = +(this.offset || 0);
+    const newOffset = Math.max(0, offset + this.itemsPerPage * direction);
+    let href = getBaseUrl() + (this.path ?? '');
+    if (this.filter) {
+      href += '/q/filter:' + encodeURL(this.filter, false);
     }
     if (newOffset > 0) {
       href += `,${newOffset}`;
@@ -212,11 +196,9 @@
   }
 
   // private but used in test
-  hideNextArrow(loading?: boolean, items?: unknown[]) {
-    if (loading || !items || !items.length) {
-      return true;
-    }
-    const lastPage = items.length < this.itemsPerPage + 1;
+  hideNextArrow() {
+    if (this.loading || !this.items?.length) return true;
+    const lastPage = this.items.length < this.itemsPerPage + 1;
     return lastPage;
   }
 
@@ -224,8 +206,8 @@
   // to either support a decimal or make it go to the nearest
   // whole number (e.g 3).
   // private but used in test
-  computePage(offset: number, itemsPerPage: number) {
-    return offset / itemsPerPage + 1;
+  computePage() {
+    return this.offset / this.itemsPerPage + 1;
   }
 
   private handleFilterBindValueChanged(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
index bbbef72..bf94e8f 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
@@ -57,36 +57,25 @@
   });
 
   test('computeNavLink', () => {
-    const offset = 25;
-    const projectsPerPage = 25;
-    let filter = 'test';
-    const path = '/admin/projects';
+    element.offset = 25;
+    element.itemsPerPage = 25;
+    element.filter = 'test';
+    element.path = '/admin/projects';
 
     stubBaseUrl('');
 
-    assert.equal(
-      element.computeNavLink(offset, 1, projectsPerPage, filter, path),
-      '/admin/projects/q/filter:test,50'
-    );
+    assert.equal(element.computeNavLink(1), '/admin/projects/q/filter:test,50');
 
-    assert.equal(
-      element.computeNavLink(offset, -1, projectsPerPage, filter, path),
-      '/admin/projects/q/filter:test'
-    );
+    assert.equal(element.computeNavLink(-1), '/admin/projects/q/filter:test');
 
-    assert.equal(
-      element.computeNavLink(offset, 1, projectsPerPage, undefined, path),
-      '/admin/projects,50'
-    );
+    element.filter = undefined;
+    assert.equal(element.computeNavLink(1), '/admin/projects,50');
 
-    assert.equal(
-      element.computeNavLink(offset, -1, projectsPerPage, undefined, path),
-      '/admin/projects'
-    );
+    assert.equal(element.computeNavLink(-1), '/admin/projects');
 
-    filter = 'plugins/';
+    element.filter = 'plugins/';
     assert.equal(
-      element.computeNavLink(offset, 1, projectsPerPage, filter, path),
+      element.computeNavLink(1),
       '/admin/projects/q/filter:plugins%252F,50'
     );
   });
@@ -113,19 +102,19 @@
 
   test('next button', async () => {
     element.itemsPerPage = 25;
-    let projects = new Array(26);
+    element.items = new Array(26);
+    element.loading = false;
     await element.updateComplete;
 
-    let loading;
-    assert.isFalse(element.hideNextArrow(loading, projects));
-    loading = true;
-    assert.isTrue(element.hideNextArrow(loading, projects));
-    loading = false;
-    assert.isFalse(element.hideNextArrow(loading, projects));
-    projects = [];
-    assert.isTrue(element.hideNextArrow(loading, projects));
-    projects = new Array(4);
-    assert.isTrue(element.hideNextArrow(loading, projects));
+    assert.isFalse(element.hideNextArrow());
+    element.loading = true;
+    assert.isTrue(element.hideNextArrow());
+    element.loading = false;
+    assert.isFalse(element.hideNextArrow());
+    element.items = [];
+    assert.isTrue(element.hideNextArrow());
+    element.items = new Array(4);
+    assert.isTrue(element.hideNextArrow());
   });
 
   test('prev button', async () => {
@@ -186,20 +175,40 @@
   test('next/prev links change when path changes', async () => {
     const BRANCHES_PATH = '/path/to/branches';
     const TAGS_PATH = '/path/to/tags';
-    const computeNavLinkStub = sinon.stub(element, 'computeNavLink');
     element.offset = 0;
     element.itemsPerPage = 25;
     element.filter = '';
     element.path = BRANCHES_PATH;
     await element.updateComplete;
-    assert.equal(computeNavLinkStub.lastCall.args[4], BRANCHES_PATH);
+
+    assert.dom.equal(
+      queryAndAssert(element, 'nav a'),
+      /* HTML */ `
+        <a hidden="" href="${BRANCHES_PATH}" id="prevArrow">
+          <gr-icon icon="chevron_left"> </gr-icon>
+        </a>
+      `
+    );
+
     element.path = TAGS_PATH;
     await element.updateComplete;
-    assert.equal(computeNavLinkStub.lastCall.args[4], TAGS_PATH);
+
+    assert.dom.equal(
+      queryAndAssert(element, 'nav a'),
+      /* HTML */ `
+        <a hidden="" href="${TAGS_PATH}" id="prevArrow">
+          <gr-icon icon="chevron_left"> </gr-icon>
+        </a>
+      `
+    );
   });
 
   test('computePage', () => {
-    assert.equal(element.computePage(0, 25), 1);
-    assert.equal(element.computePage(50, 25), 3);
+    element.offset = 0;
+    element.itemsPerPage = 25;
+    assert.equal(element.computePage(), 1);
+    element.offset = 50;
+    element.itemsPerPage = 25;
+    assert.equal(element.computePage(), 3);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
deleted file mode 100644
index 0e0f143..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-overlay_html';
-import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin';
-import {customElement} from '@polymer/decorators';
-import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
-import {findActiveElement} from '../../../utils/dom-util';
-import {fireEvent} from '../../../utils/event-util';
-import {getFocusableElements} from '../../../utils/focusable';
-
-const AWAIT_MAX_ITERS = 10;
-const AWAIT_STEP = 5;
-const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-overlay': GrOverlay;
-  }
-}
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = IronOverlayMixin(
-  PolymerElement,
-  IronOverlayBehavior as IronOverlayBehavior
-);
-
-/**
- * @attr {Boolean} with-backdrop - inherited from IronOverlay
- * @attr {Boolean} always-on-top - inherited from IronOverlay
- * @attr {Boolean} no-cancel-on-esc-key - inherited from IronOverlay
- * @attr {Boolean} no-cancel-on-outside-click - inherited from IronOverlay
- * @attr {String} scroll-action - inherited from IronOverlay
- */
-@customElement('gr-overlay')
-export class GrOverlay extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when a fullscreen overlay is closed
-   *
-   * @event fullscreen-overlay-closed
-   */
-
-  /**
-   * Fired when an overlay is opened in full screen mode
-   *
-   * @event fullscreen-overlay-opened
-   */
-
-  // private but used in test
-  fullScreenOpen = false;
-
-  // private but used in test
-  _boundHandleClose: () => void = () => super.close();
-
-  private returnFocusTo?: HTMLElement;
-
-  override get _focusableNodes() {
-    return Array.from(getFocusableElements(this));
-  }
-
-  constructor() {
-    super();
-    this.addEventListener('iron-overlay-closed', () => this._overlayClosed());
-    this.addEventListener('iron-overlay-cancelled', () =>
-      this._overlayClosed()
-    );
-  }
-
-  override open() {
-    this.returnFocusTo = findActiveElement(document, true) ?? undefined;
-    window.addEventListener('popstate', this._boundHandleClose);
-    return new Promise<void>((resolve, reject) => {
-      super.open.apply(this);
-      if (this._isMobile()) {
-        fireEvent(this, 'fullscreen-overlay-opened');
-        this.fullScreenOpen = true;
-      }
-      this._awaitOpen(resolve, reject);
-    });
-  }
-
-  _isMobile() {
-    return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
-  }
-
-  // called after iron-overlay is closed. Does not actually close the overlay
-  _overlayClosed() {
-    window.removeEventListener('popstate', this._boundHandleClose);
-    if (this.fullScreenOpen) {
-      fireEvent(this, 'fullscreen-overlay-closed');
-      this.fullScreenOpen = false;
-    }
-    if (this.returnFocusTo) {
-      this.returnFocusTo.focus();
-      this.returnFocusTo = undefined;
-    }
-  }
-
-  /**
-   * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
-   * opening. Eventually replace with a direct way to listen to the overlay.
-   */
-  _awaitOpen(fn: (this: GrOverlay) => void, reject: (error: Error) => void) {
-    let iters = 0;
-    const step = () => {
-      setTimeout(() => {
-        if (this.style.display !== 'none') {
-          fn.call(this);
-        } else if (iters++ < AWAIT_MAX_ITERS) {
-          step.call(this);
-        } else {
-          reject(new Error('gr-overlay _awaitOpen failed to resolve'));
-        }
-      }, AWAIT_STEP);
-    };
-    step.call(this);
-  }
-
-  _id() {
-    return this.getAttribute('id') || 'global';
-  }
-}
-
-export interface GrOverlayStops {
-  start: Node;
-  end: Node;
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
deleted file mode 100644
index f6818a5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      background: var(--dialog-background-color);
-      border-radius: var(--border-radius);
-      box-shadow: var(--elevation-level-5);
-    }
-
-    @media screen and (max-width: 50em) {
-      :host {
-        height: 100%;
-        left: 0;
-        position: fixed;
-        right: 0;
-        top: 0;
-        border-radius: 0;
-        box-shadow: none;
-      }
-    }
-  </style>
-  <slot></slot>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
deleted file mode 100644
index dc98745..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import './gr-overlay';
-import {GrOverlay} from './gr-overlay';
-import {fixture, html, assert} from '@open-wc/testing';
-
-suite('gr-overlay tests', () => {
-  let element: GrOverlay;
-
-  setup(async () => {
-    element = await fixture(html`<gr-overlay><div>content</div></gr-overlay>`);
-  });
-
-  test('render', async () => {
-    await element.open();
-    assert.shadowDom.equal(element, /* HTML */ ' <slot></slot> ');
-  });
-
-  test('popstate listener is attached on open and removed on close', () => {
-    const addEventListenerStub = sinon.stub(window, 'addEventListener');
-    const removeEventListenerStub = sinon.stub(window, 'removeEventListener');
-    element.open();
-    assert.isTrue(addEventListenerStub.called);
-    assert.equal(addEventListenerStub.lastCall.args[0], 'popstate');
-    assert.equal(
-      addEventListenerStub.lastCall.args[1],
-      element._boundHandleClose
-    );
-    element._overlayClosed();
-    assert.isTrue(removeEventListenerStub.called);
-    assert.equal(removeEventListenerStub.lastCall.args[0], 'popstate');
-    assert.equal(
-      removeEventListenerStub.lastCall.args[1],
-      element._boundHandleClose
-    );
-  });
-
-  test('events are fired on fullscreen view', async () => {
-    const isMobileStub = sinon.stub(element, '_isMobile').returns(true as any);
-    const openHandler = sinon.stub();
-    const closeHandler = sinon.stub();
-    element.addEventListener('fullscreen-overlay-opened', openHandler);
-    element.addEventListener('fullscreen-overlay-closed', closeHandler);
-
-    await element.open();
-
-    assert.isTrue(isMobileStub.called);
-    assert.isTrue(element.fullScreenOpen);
-    assert.isTrue(openHandler.called);
-
-    element._overlayClosed();
-    assert.isFalse(element.fullScreenOpen);
-    assert.isTrue(closeHandler.called);
-  });
-
-  test('events are not fired on desktop view', async () => {
-    const isMobileStub = sinon.stub(element, '_isMobile').returns(false as any);
-    const openHandler = sinon.stub();
-    const closeHandler = sinon.stub();
-    element.addEventListener('fullscreen-overlay-opened', openHandler);
-    element.addEventListener('fullscreen-overlay-closed', closeHandler);
-
-    await element.open();
-
-    assert.isTrue(isMobileStub.called);
-    assert.isFalse(element.fullScreenOpen);
-    assert.isFalse(openHandler.called);
-
-    element._overlayClosed();
-    assert.isFalse(element.fullScreenOpen);
-    assert.isFalse(closeHandler.called);
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 9f52517..e1e4ca5 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -5,7 +5,6 @@
  */
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
-import '../gr-overlay/gr-overlay';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
 import {getAppContext} from '../../../services/app-context';
@@ -18,12 +17,11 @@
 import {Key} from '../../../utils/dom-util';
 import {ValueChangedEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
-import {LitElement, css, html, nothing} from 'lit';
+import {LitElement, css, html} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {PropertyValues} from 'lit';
 import {classMap} from 'lit/directives/class-map.js';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {NumericChangeId, ServerInfo} from '../../../api/rest-api';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
@@ -116,8 +114,6 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getConfigModel = resolve(this, configModelToken);
@@ -266,8 +262,6 @@
   }
 
   private renderMentionsDropdown() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
-      return nothing;
     return html` <gr-autocomplete-dropdown
       id="mentionsSuggestions"
       .suggestions=${this.suggestions}
@@ -525,8 +519,6 @@
   }
 
   private isMentionsDropdownActive() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
-      return false;
     return (
       this.specialCharIndex !== -1 && this.text[this.specialCharIndex] === '@'
     );
@@ -541,10 +533,8 @@
   private computeSpecialCharIndex() {
     const charAtCursor = this.text[this.textarea!.selectionStart - 1];
 
-    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
-      if (charAtCursor === '@' && this.specialCharIndex === -1) {
-        this.specialCharIndex = this.getSpecialCharIndex(this.text);
-      }
+    if (charAtCursor === '@' && this.specialCharIndex === -1) {
+      this.specialCharIndex = this.getSpecialCharIndex(this.text);
     }
     if (charAtCursor === ':' && this.specialCharIndex === -1) {
       this.specialCharIndex = this.getSpecialCharIndex(this.text);
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 1460246..0400e85 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -7,12 +7,7 @@
 import './gr-textarea';
 import {GrTextarea} from './gr-textarea';
 import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {
-  pressKey,
-  stubFlags,
-  stubRestApi,
-  waitUntil,
-} from '../../../test/test-utils';
+import {pressKey, stubRestApi, waitUntil} from '../../../test/test-utils';
 import {fixture, html, assert} from '@open-wc/testing';
 import {createAccountWithEmail} from '../../../test/test-data-generators';
 import {Key} from '../../../utils/dom-util';
@@ -31,14 +26,16 @@
       element,
       /* HTML */ `<div id="hiddenText"></div>
         <span id="caratSpan"> </span>
+        <gr-autocomplete-dropdown id="emojiSuggestions" is-hidden="">
+        </gr-autocomplete-dropdown>
         <gr-autocomplete-dropdown
-          id="emojiSuggestions"
+          id="mentionsSuggestions"
           is-hidden=""
-          style="position: fixed; top: 150px; left: 392.5px; box-sizing: border-box; max-height: 300px; max-width: 785px;"
+          role="listbox"
         >
         </gr-autocomplete-dropdown>
         <iron-autogrow-textarea aria-disabled="false" focused="" id="textarea">
-        </iron-autogrow-textarea> `,
+        </iron-autogrow-textarea>`,
       {
         // gr-autocomplete-dropdown sizing seems to vary between local & CI
         ignoreAttributes: [
@@ -49,47 +46,6 @@
   });
 
   suite('mention users', () => {
-    setup(async () => {
-      stubFlags('isEnabled').returns(true);
-      element.requestUpdate();
-      await element.updateComplete;
-    });
-
-    test('renders', () => {
-      assert.shadowDom.equal(
-        element,
-        /* HTML */ `
-          <div id="hiddenText"></div>
-          <span id="caratSpan"> </span>
-          <gr-autocomplete-dropdown
-            id="emojiSuggestions"
-            is-hidden=""
-            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
-          >
-          </gr-autocomplete-dropdown>
-          <gr-autocomplete-dropdown
-            id="mentionsSuggestions"
-            is-hidden=""
-            role="listbox"
-            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
-          >
-          </gr-autocomplete-dropdown>
-          <iron-autogrow-textarea
-            focused=""
-            aria-disabled="false"
-            id="textarea"
-          >
-          </iron-autogrow-textarea>
-        `,
-        {
-          // gr-autocomplete-dropdown sizing seems to vary between local & CI
-          ignoreAttributes: [
-            {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
-          ],
-        }
-      );
-    });
-
     test('mentions selector is open when @ is typed & the textarea has focus', async () => {
       // Needed for Safari tests. selectionStart is not updated when text is
       // updated.
@@ -553,7 +509,7 @@
     assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
   });
 
-  test('emoji dropdown is closed when iron-overlay-closed is fired', async () => {
+  test('emoji dropdown is closed when dropdown-closed is fired', async () => {
     const resetSpy = sinon.spy(element, 'closeDropdown');
     element.emojiSuggestions!.dispatchEvent(
       new CustomEvent('dropdown-closed', {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index 1c09372..b952a3d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -16,7 +16,11 @@
   DiffPreferencesInfo,
 } from '../../../api/diff';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {countLines, diffClasses} from '../gr-diff/gr-diff-utils';
+import {
+  countLines,
+  diffClasses,
+  getResponsiveMode,
+} from '../gr-diff/gr-diff-utils';
 import {GrDiffRow} from './gr-diff-row';
 import '../gr-context-controls/gr-context-controls-section';
 import '../gr-context-controls/gr-context-controls';
@@ -71,6 +75,10 @@
     if (this.group.ignoredWhitespaceOnly) extras.push('ignoredWhitespaceOnly');
 
     const pairs = this.getLinePairs();
+    const responsiveMode = getResponsiveMode(this.diffPrefs, this.renderPrefs);
+    const hideFileCommentButton =
+      this.diffPrefs?.show_file_comment_button === false ||
+      this.renderPrefs?.show_file_comment_button === false;
     const body = html`
       <tbody class=${diffClasses(...extras)}>
         ${this.renderContextControls()} ${this.renderMoveControls()}
@@ -86,6 +94,8 @@
               .lineLength=${this.diffPrefs?.line_length ?? 80}
               .tabSize=${this.diffPrefs?.tab_size ?? 2}
               .unifiedDiff=${this.isUnifiedDiff()}
+              .responsiveMode=${responsiveMode}
+              .hideFileCommentButton=${hideFileCommentButton}
             >
             </gr-diff-row>
           `;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
index d5f0e1b..c1b13ac 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
@@ -5,6 +5,7 @@
  */
 import {LitElement, html, TemplateResult} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
+import {styleMap} from 'lit/directives/style-map.js';
 import {diffClasses} from '../gr-diff/gr-diff-utils';
 
 const SURROGATE_PAIR = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
@@ -114,7 +115,7 @@
     }
     const piece = html`<span
       class=${diffClasses('tab')}
-      style="tab-size: ${tabSize}; -moz-tab-size: ${tabSize};"
+      style=${styleMap({'tab-size': `${tabSize}`})}
       >${TAB}</span
     >`;
     this.pieces.push(piece);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
index aec7fe0..a0e7840 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
@@ -75,20 +75,25 @@
 
     test('tab wrapper style', async () => {
       element.lineLimit = 100;
-      for (const size of [1, 3, 8, 55]) {
-        element.tabSize = size;
-        await element.updateComplete;
-        await check(
-          '\t',
-          /* HTML */ `
-            <span
-              class="gr-diff tab"
-              style="tab-size: ${size}; -moz-tab-size: ${size};"
-            >
-            </span>
-          `
-        );
-      }
+      element.tabSize = 4;
+      await check(
+        '\t',
+        /* HTML */ '<span class="gr-diff tab" style="tab-size:4;"></span>'
+      );
+      await check(
+        'abc\t',
+        /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:1;"></span>'
+      );
+
+      element.tabSize = 8;
+      await check(
+        '\t',
+        /* HTML */ '<span class="gr-diff tab" style="tab-size:8;"></span>'
+      );
+      await check(
+        'abc\t',
+        /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:5;"></span>'
+      );
     });
 
     test('tab wrapper insertion', async () => {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index f583c2e..87fd5ca 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -48,15 +48,21 @@
   }, 0);
 }
 
+export function isFileUnchanged(diff: DiffInfo) {
+  return !diff.content.some(
+    content => (content.a && !content.common) || (content.b && !content.common)
+  );
+}
+
 export function getResponsiveMode(
-  prefs: DiffPreferencesInfo,
+  prefs?: DiffPreferencesInfo,
   renderPrefs?: RenderPreferences
 ): DiffResponsiveMode {
   if (renderPrefs?.responsive_mode) {
     return renderPrefs.responsive_mode;
   }
   // Backwards compatibility to the line_wrapping param.
-  if (prefs.line_wrapping) {
+  if (prefs?.line_wrapping) {
     return 'FULL_RESPONSIVE';
   }
   return 'NONE';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
index 98b4586..25dc768 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -4,8 +4,15 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {assert} from '@open-wc/testing';
+import {DiffInfo} from '../../../api/diff';
 import '../../../test/common-test-setup';
-import {createElementDiff, formatText, createTabWrapper} from './gr-diff-utils';
+import {createDiff} from '../../../test/test-data-generators';
+import {
+  createElementDiff,
+  formatText,
+  createTabWrapper,
+  isFileUnchanged,
+} from './gr-diff-utils';
 
 const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
 
@@ -156,4 +163,36 @@
     expectTextLength('abc\tde\t', 10, 20);
     expectTextLength('\t\t\t\t\t', 20, 100);
   });
+
+  test('isFileUnchanged', () => {
+    let diff: DiffInfo = {
+      ...createDiff(),
+      content: [
+        {a: ['abcd'], ab: ['ef']},
+        {b: ['ancd'], a: ['xx']},
+      ],
+    };
+    assert.equal(isFileUnchanged(diff), false);
+    diff = {
+      ...createDiff(),
+      content: [{ab: ['abcd']}, {ab: ['ancd']}],
+    };
+    assert.equal(isFileUnchanged(diff), true);
+    diff = {
+      ...createDiff(),
+      content: [
+        {a: ['abcd'], ab: ['ef'], common: true},
+        {b: ['ancd'], ab: ['xx']},
+      ],
+    };
+    assert.equal(isFileUnchanged(diff), false);
+    diff = {
+      ...createDiff(),
+      content: [
+        {a: ['abcd'], ab: ['ef'], common: true},
+        {b: ['ancd'], ab: ['xx'], common: true},
+      ],
+    };
+    assert.equal(isFileUnchanged(diff), true);
+  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 2097170..a0526ed 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -755,9 +755,9 @@
           rule wins in case of same specificity.
         */
         .trailing-whitespace,
-        .content .trailing-whitespace,
+        .content .contentText .trailing-whitespace,
         .trailing-whitespace .intraline,
-        .content .trailing-whitespace .intraline {
+        .content .contentText .trailing-whitespace .intraline {
           border-radius: var(--border-radius, 4px);
           background-color: var(--diff-trailing-whitespace-indicator);
         }
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
deleted file mode 100644
index 30adedf..0000000
--- a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {Constructor} from '../../utils/common-util';
-
-// The mixinBehaviors clears all type information about superClass.
-// As a workaround, we define IronFitMixin with correct type.
-// Due to the following issues:
-// https://github.com/microsoft/TypeScript/issues/15870
-// https://github.com/microsoft/TypeScript/issues/9944
-// we have to import IronFitBehavior in the same file where IronFitMixin
-// is used. To ensure that this import can't be avoided, the second parameter
-// is added. Usage example:
-// class Element extends IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior)
-// The code 'IronFitBehavior as IronFitBehavior' required, because IronFitBehavior
-// defined as an object, not as IronFitBehavior instance.
-
-export const IronFitMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T,
-  _: IronFitBehavior
-): T & Constructor<IronFitBehavior> =>
-  // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
-  // which will fail the type check due to missing IronFitBehavior interface
-  // eslint-disable-next-line
-  mixinBehaviors([IronFitBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
deleted file mode 100644
index 3625228..0000000
--- a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {Constructor} from '../../utils/common-util';
-
-// The mixinBehaviors clears all type information about superClass.
-// As a workaround, we define IronOverlayMixin with correct type.
-// Due to the following issues:
-// https://github.com/microsoft/TypeScript/issues/15870
-// https://github.com/microsoft/TypeScript/issues/9944
-// we have to import IronOverlayBehavior in the same file where IronOverlayMixin
-// is used. To ensure that this import can't be avoided, the second parameter
-// is added. Usage example:
-// class Element extends IronOverlayMixin(PolymerElement, IronOverlayBehavior as IronOverlayBehavior)
-// The code 'IronOverlayBehavior as IronOverlayBehavior' required, because
-// IronOverlayBehavior defined as an object, not as IronOverlayBehavior instance.
-export const IronOverlayMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T,
-  _: IronOverlayBehavior
-): T & Constructor<IronOverlayBehavior> =>
-  // TODO(TS): mixinBehaviors in some lib is returning: `new () => T`
-  // instead which will fail the type check due to missing
-  // IronOverlayBehavior interface
-  // eslint-disable-next-line
-  mixinBehaviors([IronOverlayBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 8282a3f..446822f 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -12,6 +12,7 @@
   PatchSetNum,
   PreferencesInfo,
   RevisionPatchSetNum,
+  PatchSetNumber,
 } from '../../types/common';
 import {DefaultBase} from '../../constants/constants';
 import {combineLatest, from, fromEvent, Observable, forkJoin, of} from 'rxjs';
@@ -22,7 +23,6 @@
   startWith,
   switchMap,
 } from 'rxjs/operators';
-import {RouterModel} from '../../services/router/router-model';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
@@ -38,6 +38,13 @@
 import {UserModel} from '../user/user-model';
 import {define} from '../dependency';
 import {isOwner} from '../../utils/change-util';
+import {
+  ChangeViewModel,
+  createChangeUrl,
+  createDiffUrl,
+  createEditUrl,
+} from '../views/change';
+import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
 
 export enum LoadingStatus {
   NOT_LOADED = 'NOT_LOADED',
@@ -56,12 +63,6 @@
   loadingStatus: LoadingStatus;
   change?: ParsedChangeInfo;
   /**
-   * The name of the file user is viewing in the diff view mode. File path is
-   * specified in the url or derived from the commentId.
-   * Does not apply to change-view or edit-view.
-   */
-  diffPath?: string;
-  /**
    * The list of reviewed files, kept in the model because we want changes made
    * in one view to reflect on other views without re-rendering the other views.
    * Undefined means it's still loading and empty set means no files reviewed.
@@ -76,7 +77,7 @@
 export function updateChangeWithEdit(
   change?: ParsedChangeInfo,
   edit?: EditInfo,
-  routerPatchNum?: PatchSetNum
+  viewModelPatchNum?: PatchSetNum
 ): ParsedChangeInfo | undefined {
   if (!change || !edit) return change;
   assertIsDefined(edit.commit.commit, 'edit.commit.commit');
@@ -95,7 +96,7 @@
   // which is still done in change-view. `_patchRange.patchNum` should
   // eventually also be model managed, so we can reconcile these two code
   // snippets into one location.
-  if (routerPatchNum === undefined) {
+  if (viewModelPatchNum === undefined) {
     change.current_revision = edit.commit.commit;
   }
   return change;
@@ -103,20 +104,20 @@
 
 /**
  * Derives the base patchset number from all the data that can potentially
- * influence it. Mostly just returns `routerBasePatchNum` or PARENT, but has
+ * influence it. Mostly just returns `viewModelBasePatchNum` or PARENT, but has
  * some special logic when looking at merge commits.
  *
- * NOTE: At the moment this returns just `routerBasePatchNum ?? PARENT`, see
+ * NOTE: At the moment this returns just `viewModelBasePatchNum ?? PARENT`, see
  * TODO below.
  */
 function computeBase(
-  routerBasePatchNum: BasePatchSetNum | undefined,
+  viewModelBasePatchNum: BasePatchSetNum | undefined,
   patchNum: RevisionPatchSetNum | undefined,
   change: ParsedChangeInfo | undefined,
   preferences: PreferencesInfo
 ): BasePatchSetNum {
-  if (routerBasePatchNum && routerBasePatchNum !== PARENT) {
-    return routerBasePatchNum;
+  if (viewModelBasePatchNum && viewModelBasePatchNum !== PARENT) {
+    return viewModelBasePatchNum;
   }
   if (!change || !patchNum) return PARENT;
 
@@ -129,7 +130,7 @@
   // but we are not sure whether this was ever 100% working correctly. A
   // major challenge is being able to select PARENT explicitly even if your
   // preference for the default choice is FIRST_PARENT. <gr-file-list-header>
-  // just uses `navigation.setUrl()` and the router does not have any
+  // just uses `navigation.setUrl()` and the view model does not have any
   // way of forcing the basePatchSetNum to stick to PARENT without being
   // altered back to FIRST_PARENT here.
   // See also corresponding TODO in gr-settings-view.
@@ -150,7 +151,11 @@
 export class ChangeModel extends Model<ChangeState> {
   private change?: ParsedChangeInfo;
 
-  private patchNum?: PatchSetNum;
+  private patchNum?: RevisionPatchSetNum;
+
+  private basePatchNum?: BasePatchSetNum;
+
+  private latestPatchNum?: PatchSetNumber;
 
   public readonly change$ = select(
     this.state$,
@@ -162,11 +167,6 @@
     changeState => changeState.loadingStatus
   );
 
-  public readonly diffPath$ = select(
-    this.state$,
-    changeState => changeState?.diffPath
-  );
-
   public readonly reviewedFiles$ = select(
     this.state$,
     changeState => changeState?.reviewedFiles
@@ -178,8 +178,17 @@
 
   public readonly labels$ = select(this.change$, change => change?.labels);
 
-  public readonly latestPatchNum$ = select(this.change$, change =>
-    computeLatestPatchNum(computeAllPatchSets(change))
+  public readonly revisions$ = select(
+    this.change$,
+    change => change?.revisions
+  );
+
+  public readonly patchsets$ = select(this.change$, change =>
+    computeAllPatchSets(change)
+  );
+
+  public readonly latestPatchNum$ = select(this.patchsets$, patchsets =>
+    computeLatestPatchNum(patchsets)
   );
 
   /**
@@ -192,57 +201,57 @@
   public readonly patchNum$: Observable<RevisionPatchSetNum | undefined> =
     select(
       combineLatest([
-        this.routerModel.state$,
+        this.viewModel.state$,
         this.state$,
         this.latestPatchNum$,
       ]).pipe(
         /**
-         * If you depend on both, router and change state, then you want to
-         * filter out inconsistent state, e.g. router changeNum already updated,
-         * change not yet reset to undefined.
+         * If you depend on both, view model and change state, then you want to
+         * filter out inconsistent state, e.g. view model changeNum already
+         * updated, change not yet reset to undefined.
          */
-        filter(([routerState, changeState, _latestPatchN]) => {
+        filter(([viewModelState, changeState, _latestPatchN]) => {
           const changeNum = changeState.change?._number;
-          const routerChangeNum = routerState.changeNum;
-          return changeNum === undefined || changeNum === routerChangeNum;
+          const viewModelChangeNum = viewModelState?.changeNum;
+          return changeNum === undefined || changeNum === viewModelChangeNum;
         })
       ),
-      ([routerState, _changeState, latestPatchN]) =>
-        routerState?.patchNum || latestPatchN
+      ([viewModelState, _changeState, latestPatchN]) =>
+        viewModelState?.patchNum || latestPatchN
     );
 
   /**
    * Emits the base patchset number. This is identical to the
-   * `routerBasePatchNum$`, but has some special logic for merges.
+   * `viewModel.basePatchNum$`, but has some special logic for merges.
    *
    * Note that this selector can emit without the change being available!
    */
   public readonly basePatchNum$: Observable<BasePatchSetNum> =
     /**
-     * If you depend on both, router and change state, then you want to filter
-     * out inconsistent state, e.g. router changeNum already updated, change not
-     * yet reset to undefined.
+     * If you depend on both, view model and change state, then you want to
+     * filter out inconsistent state, e.g. view model changeNum already
+     * updated, change not yet reset to undefined.
      */
     select(
       combineLatest([
-        this.routerModel.state$,
+        this.viewModel.state$,
         this.state$,
         this.userModel.state$,
       ]).pipe(
-        filter(([routerState, changeState, _]) => {
+        filter(([viewModelState, changeState, _]) => {
           const changeNum = changeState.change?._number;
-          const routerChangeNum = routerState.changeNum;
-          return changeNum === undefined || changeNum === routerChangeNum;
+          const viewModelChangeNum = viewModelState?.changeNum;
+          return changeNum === undefined || changeNum === viewModelChangeNum;
         }),
         withLatestFrom(
-          this.routerModel.routerBasePatchNum$,
+          this.viewModel.basePatchNum$,
           this.patchNum$,
           this.change$,
           this.userModel.preferences$
         )
       ),
-      ([_, routerBasePatchNum, patchNum, change, preferences]) =>
-        computeBase(routerBasePatchNum, patchNum, change, preferences)
+      ([_, viewModelBasePatchNum, patchNum, change, preferences]) =>
+        computeBase(viewModelBasePatchNum, patchNum, change, preferences)
     );
 
   public readonly isOwner$: Observable<boolean> = select(
@@ -257,13 +266,14 @@
   );
 
   constructor(
-    private readonly routerModel: RouterModel,
+    private readonly navigation: NavigationService,
+    private readonly viewModel: ChangeViewModel,
     private readonly restApiService: RestApiService,
     private readonly userModel: UserModel
   ) {
     super(initialState);
     this.subscriptions = [
-      combineLatest([this.routerModel.routerChangeNum$, this.reload$])
+      combineLatest([this.viewModel.changeNum$, this.reload$])
         .pipe(
           map(([changeNum, _]) => changeNum),
           switchMap(changeNum => {
@@ -272,7 +282,7 @@
             const edit = from(this.restApiService.getChangeEdit(changeNum));
             return forkJoin([change, edit]);
           }),
-          withLatestFrom(this.routerModel.routerPatchNum$),
+          withLatestFrom(this.viewModel.patchNum$),
           map(([[change, edit], patchNum]) =>
             updateChangeWithEdit(change, edit, patchNum)
           )
@@ -289,6 +299,12 @@
         }),
       this.change$.subscribe(change => (this.change = change)),
       this.patchNum$.subscribe(patchNum => (this.patchNum = patchNum)),
+      this.basePatchNum$.subscribe(
+        basePatchNum => (this.basePatchNum = basePatchNum)
+      ),
+      this.latestPatchNum$.subscribe(
+        latestPatchNum => (this.latestPatchNum = latestPatchNum)
+      ),
       combineLatest([this.patchNum$, this.changeNum$, this.userModel.loggedIn$])
         .pipe(
           switchMap(([patchNum, changeNum, loggedIn]) => {
@@ -303,11 +319,6 @@
     ];
   }
 
-  // Temporary workaround until path is derived in the model itself.
-  updatePath(diffPath?: string) {
-    this.updateState({diffPath});
-  }
-
   updateStateReviewedFiles(reviewedFiles: string[]) {
     this.updateState({reviewedFiles});
   }
@@ -372,6 +383,65 @@
     return this.getState().change;
   }
 
+  diffUrl(
+    diffView: {path: string; lineNum?: number},
+    patchNum = this.patchNum,
+    basePatchNum = this.basePatchNum
+  ) {
+    if (!this.change) return;
+    if (!this.patchNum) return;
+    return createDiffUrl({
+      change: this.change,
+      patchNum,
+      basePatchNum,
+      diffView,
+    });
+  }
+
+  navigateToDiff(
+    diffView: {path: string; lineNum?: number},
+    patchNum = this.patchNum,
+    basePatchNum = this.basePatchNum
+  ) {
+    const url = this.diffUrl(diffView, patchNum, basePatchNum);
+    if (!url) return;
+    this.navigation.setUrl(url);
+  }
+
+  changeUrl(openReplyDialog = false) {
+    if (!this.change) return;
+    const isLatest = this.latestPatchNum === this.patchNum;
+    return createChangeUrl({
+      change: this.change,
+      patchNum:
+        isLatest && this.basePatchNum === PARENT ? undefined : this.patchNum,
+      basePatchNum: this.basePatchNum,
+      openReplyDialog,
+    });
+  }
+
+  navigateToChange(openReplyDialog = false) {
+    const url = this.changeUrl(openReplyDialog);
+    if (!url) return;
+    this.navigation.setUrl(url);
+  }
+
+  editUrl(editView: {path: string; lineNum?: number}) {
+    if (!this.change) return;
+    return createEditUrl({
+      changeNum: this.change._number,
+      repo: this.change.project,
+      patchNum: this.patchNum,
+      editView,
+    });
+  }
+
+  navigateToEdit(editView: {path: string; lineNum?: number}) {
+    const url = this.editUrl(editView);
+    if (!url) return;
+    this.navigation.setUrl(url);
+  }
+
   /**
    * Check whether there is no newer patch than the latest patch that was
    * available when this change was loaded.
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index a2fc7c9..c11c15b 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -9,6 +9,7 @@
 import {
   createChange,
   createChangeMessageInfo,
+  createChangeViewState,
   createEditInfo,
   createParsedChange,
   createRevision,
@@ -28,12 +29,13 @@
 } from '../../types/common';
 import {ParsedChangeInfo} from '../../types/types';
 import {getAppContext} from '../../services/app-context';
-import {GerritView, routerModelToken} from '../../services/router/router-model';
 import {ChangeState, LoadingStatus, updateChangeWithEdit} from './change-model';
 import {ChangeModel} from './change-model';
 import {assert} from '@open-wc/testing';
 import {testResolver} from '../../test/common-test-setup';
 import {userModelToken} from '../user/user-model';
+import {changeViewModelToken} from '../views/change';
+import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
 
 suite('updateChangeWithEdit() tests', () => {
   test('undefined change', async () => {
@@ -83,7 +85,8 @@
 
   setup(() => {
     changeModel = new ChangeModel(
-      testResolver(routerModelToken),
+      testResolver(navigationToken),
+      testResolver(changeViewModelToken),
       getAppContext().restApiService,
       testResolver(userModelToken)
     );
@@ -121,10 +124,7 @@
     assert.equal(stub.callCount, 0);
     assert.isUndefined(state?.change);
 
-    testResolver(routerModelToken).setState({
-      view: GerritView.CHANGE,
-      changeNum: knownChange._number,
-    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
     state = await waitForLoadingStatus(LoadingStatus.LOADING);
     assert.equal(stub.callCount, 1);
     assert.isUndefined(state?.change);
@@ -140,10 +140,7 @@
     const promise = mockPromise<ParsedChangeInfo | undefined>();
     const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
     let state: ChangeState;
-    testResolver(routerModelToken).setState({
-      view: GerritView.CHANGE,
-      changeNum: knownChange._number,
-    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
 
@@ -164,10 +161,7 @@
     let promise = mockPromise<ParsedChangeInfo | undefined>();
     const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
     let state: ChangeState;
-    testResolver(routerModelToken).setState({
-      view: GerritView.CHANGE,
-      changeNum: knownChange._number,
-    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
 
@@ -178,8 +172,8 @@
       _number: 123 as NumericChangeId,
     };
     promise = mockPromise<ParsedChangeInfo | undefined>();
-    testResolver(routerModelToken).setState({
-      view: GerritView.CHANGE,
+    testResolver(changeViewModelToken).setState({
+      ...createChangeViewState(),
       changeNum: otherChange._number,
     });
     state = await waitForLoadingStatus(LoadingStatus.LOADING);
@@ -197,10 +191,7 @@
     let promise = mockPromise<ParsedChangeInfo | undefined>();
     const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
     let state: ChangeState;
-    testResolver(routerModelToken).setState({
-      view: GerritView.CHANGE,
-      changeNum: knownChange._number,
-    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
 
@@ -208,10 +199,7 @@
 
     promise = mockPromise<ParsedChangeInfo | undefined>();
     promise.resolve(undefined);
-    testResolver(routerModelToken).setState({
-      view: GerritView.CHANGE,
-      changeNum: undefined,
-    });
+    testResolver(changeViewModelToken).setState(undefined);
     state = await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
     assert.equal(stub.callCount, 2);
     assert.isUndefined(state?.change);
@@ -220,10 +208,7 @@
 
     promise = mockPromise<ParsedChangeInfo | undefined>();
     promise.resolve(knownChange);
-    testResolver(routerModelToken).setState({
-      view: GerritView.CHANGE,
-      changeNum: knownChange._number,
-    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
     assert.equal(stub.callCount, 3);
     assert.equal(state?.change, knownChange);
@@ -299,7 +284,7 @@
     assert.equal(spy.lastCall.firstArg, PARENT);
 
     // test update
-    testResolver(routerModelToken).updateState({
+    testResolver(changeViewModelToken).updateState({
       basePatchNum: 1 as PatchSetNumber,
     });
     assert.equal(spy.callCount, 2);
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
index 07e64a2..0683af0 100644
--- a/polygerrit-ui/app/models/change/files-model.ts
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -23,6 +23,9 @@
 import {ChangeModel} from './change-model';
 import {CommentsModel} from '../comments/comments-model';
 
+export type FileNameToNormalizedFileInfoMap = {
+  [name: string]: NormalizedFileInfo;
+};
 export interface NormalizedFileInfo extends FileInfo {
   __path: string;
   // Compared to `FileInfo` these four props are required here.
@@ -115,7 +118,12 @@
 export class FilesModel extends Model<FilesState> {
   public readonly files$ = select(this.state$, state => state.files);
 
-  public readonly filesWithUnmodified$ = select(
+  /**
+   * `files$` only includes the files that were modified. Here we also include
+   * all unmodified files that have comments with
+   * `status: FileInfoStatus.UNMODIFIED`.
+   */
+  public readonly filesIncludingUnmodified$ = select(
     combineLatest([this.files$, this.commentsModel.commentedPaths$]),
     ([files, commentedPaths]) => addUnmodified(files, commentedPaths)
   );
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 6a5933c..ba43eb4 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -14,12 +14,13 @@
   Replacement,
   RunStatus,
 } from '../../api/checks';
-import {PatchSetNumber} from '../../api/rest-api';
+import {PatchSetNumber, RevisionPatchSetNum} from '../../api/rest-api';
+import {CommentSide} from '../../constants/constants';
 import {FixSuggestionInfo, FixReplacementInfo} from '../../types/common';
 import {OpenFixPreviewEventDetail} from '../../types/events';
 import {isDefined} from '../../types/types';
-import {PROVIDED_FIX_ID} from '../../utils/comment-util';
-import {assert, assertNever} from '../../utils/common-util';
+import {PROVIDED_FIX_ID, UnsavedInfo} from '../../utils/comment-util';
+import {assert, assertIsDefined, assertNever} from '../../utils/common-util';
 import {fire} from '../../utils/event-util';
 import {CheckResult, CheckRun, RunResult} from './checks-model';
 
@@ -86,6 +87,27 @@
   }
 }
 
+function pleaseFixMessage(result: RunResult) {
+  return `Please fix this ${result.category} reported by ${result.checkName}: ${result.summary}
+
+${result.message}`;
+}
+
+export function createPleaseFixComment(result: RunResult): UnsavedInfo {
+  const pointer = result.codePointers?.[0];
+  assertIsDefined(pointer, 'codePointer');
+  return {
+    __unsaved: true,
+    path: pointer.path,
+    patch_set: result.patchset as RevisionPatchSetNum,
+    side: CommentSide.REVISION,
+    line: pointer.range.end_line ?? pointer.range.start_line,
+    range: pointer.range,
+    message: pleaseFixMessage(result),
+    unresolved: true,
+  };
+}
+
 export function createFixAction(
   target: EventTarget,
   result?: RunResult
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index a7b43ca..1fdf342 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -18,8 +18,10 @@
 } from '../../types/common';
 import {
   addPath,
+  Comment,
   DraftInfo,
   isDraft,
+  isDraftOrUnsaved,
   isDraftThread,
   isUnsaved,
   reportingDetails,
@@ -27,7 +29,6 @@
 } from '../../utils/comment-util';
 import {deepEqual} from '../../utils/deep-util';
 import {select} from '../../utils/observable-util';
-import {RouterModel} from '../../services/router/router-model';
 import {define} from '../dependency';
 import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
 import {fire, fireAlert, fireEvent} from '../../utils/event-util';
@@ -52,6 +53,7 @@
   switchMap,
 } from 'rxjs/operators';
 import {isDefined} from '../../types/types';
+import {ChangeViewModel} from '../views/change';
 
 export interface CommentState {
   /** undefined means 'still loading' */
@@ -111,6 +113,35 @@
   return nextState;
 }
 
+/** Updates a single comment in a state. */
+export function updateComment(
+  state: CommentState,
+  comment: CommentInfo
+): CommentState {
+  if (!comment.path || !state.comments) {
+    return state;
+  }
+  const newCommentsAtPath = [...state.comments[comment.path]];
+  for (let i = 0; i < newCommentsAtPath.length; ++i) {
+    if (newCommentsAtPath[i].id === comment.id) {
+      // TODO: In "delete comment" the returned comment is missing some of the
+      // fields (for example patch_set), which would throw errors when
+      // rendering. Remove merging with the old comment, once that is fixed in
+      // server code.
+      newCommentsAtPath[i] = {...newCommentsAtPath[i], ...comment};
+
+      return {
+        ...state,
+        comments: {
+          ...state.comments,
+          [comment.path]: newCommentsAtPath,
+        },
+      };
+    }
+  }
+  throw new Error('Comment to be updated does not exist');
+}
+
 // Private but used in tests.
 export function setRobotComments(
   state: CommentState,
@@ -384,7 +415,7 @@
   private discardedDrafts: DraftInfo[] = [];
 
   constructor(
-    private readonly routerModel: RouterModel,
+    private readonly changeViewModel: ChangeViewModel,
     private readonly changeModel: ChangeModel,
     private readonly accountsModel: AccountsModel,
     private readonly restApiService: RestApiService,
@@ -401,7 +432,7 @@
       this.changeModel.patchNum$.subscribe(x => (this.patchNum = x))
     );
     this.subscriptions.push(
-      this.routerModel.routerChangeNum$.subscribe(changeNum => {
+      this.changeViewModel.changeNum$.subscribe(changeNum => {
         this.changeNum = changeNum;
         this.setState({...initialState});
         this.reloadAllComments();
@@ -621,6 +652,25 @@
     this.report(Interaction.COMMENT_DISCARDED, draft);
   }
 
+  async deleteComment(
+    changeNum: NumericChangeId,
+    comment: Comment,
+    reason: string
+  ) {
+    assertIsDefined(comment.patch_set, 'comment.patch_set');
+    if (isDraftOrUnsaved(comment)) {
+      throw new Error('Admin deletion is only for published comments.');
+    }
+
+    const newComment = await this.restApiService.deleteComment(
+      changeNum,
+      comment.patch_set,
+      comment.id,
+      reason
+    );
+    this.modifyState(s => updateComment(s, newComment));
+  }
+
   private report(interaction: Interaction, comment: CommentBasics) {
     const details = reportingDetails(comment);
     this.reporting.reportInteraction(interaction, details);
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index 4db5d57..a689e42 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -6,11 +6,13 @@
 import '../../test/common-test-setup';
 import {
   createAccountWithEmail,
+  createChangeViewState,
   createDraft,
 } from '../../test/test-data-generators';
 import {
   AccountInfo,
   EmailAddress,
+  NumericChangeId,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../types/common';
@@ -19,16 +21,16 @@
 import {
   createComment,
   createParsedChange,
-  TEST_NUMERIC_CHANGE_ID,
 } from '../../test/test-data-generators';
 import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {getAppContext} from '../../services/app-context';
-import {GerritView, routerModelToken} from '../../services/router/router-model';
 import {PathToCommentsInfoMap} from '../../types/common';
 import {changeModelToken} from '../change/change-model';
 import {assert} from '@open-wc/testing';
 import {testResolver} from '../../test/common-test-setup';
 import {accountsModelToken} from '../accounts-model/accounts-model';
+import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
+import {changeViewModelToken} from '../views/change';
 
 suite('comments model tests', () => {
   test('updateStateDeleteDraft', () => {
@@ -70,7 +72,7 @@
 
   test('loads comments', async () => {
     const model = new CommentsModel(
-      testResolver(routerModelToken),
+      testResolver(changeViewModelToken),
       testResolver(changeModelToken),
       testResolver(accountsModelToken),
       getAppContext().restApiService,
@@ -98,10 +100,7 @@
       model.portedComments$.subscribe(c => (portedComments = c ?? {}))
     );
 
-    testResolver(routerModelToken).setState({
-      view: GerritView.CHANGE,
-      changeNum: TEST_NUMERIC_CHANGE_ID,
-    });
+    testResolver(changeViewModelToken).setState(createChangeViewState());
     testResolver(changeModelToken).updateStateChange(createParsedChange());
 
     await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
@@ -131,7 +130,7 @@
     };
     stubRestApi('getAccountDetails').returns(Promise.resolve(account));
     const model = new CommentsModel(
-      testResolver(routerModelToken),
+      testResolver(changeViewModelToken),
       testResolver(changeModelToken),
       testResolver(accountsModelToken),
       getAppContext().restApiService,
@@ -159,7 +158,7 @@
     };
     stubRestApi('getAccountDetails').returns(Promise.resolve(account));
     const model = new CommentsModel(
-      testResolver(routerModelToken),
+      testResolver(changeViewModelToken),
       testResolver(changeModelToken),
       testResolver(accountsModelToken),
       getAppContext().restApiService,
@@ -187,4 +186,36 @@
     });
     await waitUntil(() => mentionedUsers.length === 0);
   });
+
+  test('delete comment change is emitted', async () => {
+    const comment = createComment();
+    stubRestApi('deleteComment').returns(
+      Promise.resolve({
+        ...comment,
+        message: 'Comment is deleted',
+      })
+    );
+    const model = new CommentsModel(
+      testResolver(changeViewModelToken),
+      testResolver(changeModelToken),
+      testResolver(accountsModelToken),
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
+
+    let changeComments: ChangeComments | undefined = undefined;
+    model.changeComments$.subscribe(x => (changeComments = x));
+    model.setState({
+      comments: {[comment.path!]: [comment]},
+      discardedDrafts: [],
+    });
+
+    model.deleteComment(123 as NumericChangeId, comment, 'Comment is deleted');
+
+    await waitUntil(
+      () =>
+        changeComments?.getAllCommentsForPath(comment.path!)[0].message ===
+        'Comment is deleted'
+    );
+  });
 });
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index 2ad95a2..3380637 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GerritView} from '../../services/router/router-model';
+import {getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
 import {Model} from '../model';
 import {ViewState} from './base';
@@ -21,6 +22,17 @@
   offset?: number | string;
 }
 
+export function createAdminUrl(state: Omit<AdminViewState, 'view'>) {
+  switch (state.adminView) {
+    case AdminChildView.REPOS:
+      return `${getBaseUrl()}/admin/repos`;
+    case AdminChildView.GROUPS:
+      return `${getBaseUrl()}/admin/groups`;
+    case AdminChildView.PLUGINS:
+      return `${getBaseUrl()}/admin/plugins`;
+  }
+}
+
 export const adminViewModelToken = define<AdminViewModel>('admin-view-model');
 
 export class AdminViewModel extends Model<AdminViewState | undefined> {
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 31d511a..a206037 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -10,6 +10,7 @@
   BasePatchSetNum,
   ChangeInfo,
   PatchSetNumber,
+  EDIT,
 } from '../../api/rest-api';
 import {Tab} from '../../constants/constants';
 import {GerritView} from '../../services/router/router-model';
@@ -26,18 +27,31 @@
 import {Model} from '../model';
 import {ViewState} from './base';
 
+export enum ChangeChildView {
+  OVERVIEW = 'OVERVIEW',
+  DIFF = 'DIFF',
+  EDIT = 'EDIT',
+}
+
 export interface ChangeViewState extends ViewState {
   view: GerritView.CHANGE;
+  childView: ChangeChildView;
 
   changeNum: NumericChangeId;
   repo: RepoName;
-  edit?: boolean;
   patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
+  /** Refers to comment on COMMENTS tab in OVERVIEW. */
   commentId?: UrlEncodedCommentId;
+
+  // TODO: Move properties that only apply to OVERVIEW into a submessage.
+
+  edit?: boolean;
   /** This can be a string only for plugin provided tabs. */
   tab?: Tab | string;
 
+  // TODO: Move properties that only apply to CHECKS tab into a submessage.
+
   /** Checks related view state */
 
   /** selected patchset for check runs (undefined=latest) */
@@ -61,6 +75,19 @@
   forceReload?: boolean;
   /** triggers opening the reply dialog */
   openReplyDialog?: boolean;
+
+  /** These properties apply to the DIFF child view only. */
+  diffView?: {
+    path?: string;
+    lineNum?: number;
+    leftSide?: boolean;
+  };
+
+  /** These properties apply to the EDIT child view only. */
+  editView?: {
+    path?: string;
+    lineNum?: number;
+  };
 }
 
 /**
@@ -70,7 +97,7 @@
  */
 export type CreateChangeUrlObject = Omit<
   ChangeViewState,
-  'view' | 'changeNum' | 'repo'
+  'view' | 'childView' | 'changeNum' | 'repo'
 > & {
   change: Pick<ChangeInfo, '_number' | 'project'>;
 };
@@ -82,7 +109,9 @@
 }
 
 export function objToState(
-  obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view'>
+  obj:
+    | (CreateChangeUrlObject & {childView: ChangeChildView})
+    | Omit<ChangeViewState, 'view'>
 ): ChangeViewState {
   if (isCreateChangeUrlObject(obj)) {
     return {
@@ -95,15 +124,26 @@
   return {...obj, view: GerritView.CHANGE};
 }
 
-export function createChangeUrl(
-  obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view'>
-) {
-  const state: ChangeViewState = objToState(obj);
-  let range = getPatchRangeExpression(state);
-  if (range.length) {
-    range = '/' + range;
+export function createChangeViewUrl(state: ChangeViewState): string {
+  switch (state.childView) {
+    case ChangeChildView.OVERVIEW:
+      return createChangeUrl(state);
+    case ChangeChildView.DIFF:
+      return createDiffUrl(state);
+    case ChangeChildView.EDIT:
+      return createEditUrl(state);
   }
-  let suffix = `${range}`;
+}
+
+export function createChangeUrl(
+  obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
+) {
+  const state: ChangeViewState = objToState({
+    ...obj,
+    childView: ChangeChildView.OVERVIEW,
+  });
+
+  let suffix = '';
   const queries = [];
   if (state.checksPatchset && state.checksPatchset > 0) {
     queries.push(`checksPatchset=${state.checksPatchset}`);
@@ -136,7 +176,7 @@
     suffix += ',edit';
   }
   if (state.commentId) {
-    suffix = suffix + `/comments/${state.commentId}`;
+    suffix += `/comments/${state.commentId}`;
   }
   if (queries.length > 0) {
     suffix += '?' + queries.join('&');
@@ -144,18 +184,99 @@
   if (state.messageHash) {
     suffix += state.messageHash;
   }
-  if (state.repo) {
-    const encodedProject = encodeURL(state.repo, true);
-    return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
-  } else {
-    return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
+
+  return `${createChangeUrlCommon(state)}${suffix}`;
+}
+
+export function createDiffUrl(
+  obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
+) {
+  const state: ChangeViewState = objToState({
+    ...obj,
+    childView: ChangeChildView.DIFF,
+  });
+
+  const path = `/${encodeURL(state.diffView?.path ?? '', true)}`;
+
+  let suffix = '';
+  // TODO: Move creating of comment URLs to a separate function. We are
+  // "abusing" the `commentId` property, which should only be used for pointing
+  // to comment in the COMMENTS tab of the OVERVIEW page.
+  if (state.commentId) {
+    suffix += `comment/${state.commentId}/`;
   }
+
+  if (state.diffView?.lineNum) {
+    suffix += '#';
+    if (state.diffView?.leftSide) {
+      suffix += 'b';
+    }
+    suffix += state.diffView.lineNum;
+  }
+
+  return `${createChangeUrlCommon(state)}${path}${suffix}`;
+}
+
+export function createEditUrl(
+  obj: Omit<ChangeViewState, 'view' | 'childView'>
+): string {
+  const state: ChangeViewState = objToState({
+    ...obj,
+    childView: ChangeChildView.DIFF,
+    patchNum: obj.patchNum ?? EDIT,
+  });
+
+  const path = `/${encodeURL(state.editView?.path ?? '', true)}`;
+  const line = state.editView?.lineNum;
+  const suffix = line ? `#${line}` : '';
+
+  return `${createChangeUrlCommon(state)}${path},edit${suffix}`;
+}
+
+/**
+ * The shared part of creating a change URL between OVERVIEW, DIFF and EDIT
+ * child views.
+ */
+function createChangeUrlCommon(state: ChangeViewState) {
+  let range = getPatchRangeExpression(state);
+  if (range.length) range = '/' + range;
+
+  let repo = '';
+  if (state.repo) repo = `${encodeURL(state.repo, true)}/+/`;
+
+  return `${getBaseUrl()}/c/${repo}${state.changeNum}${range}`;
 }
 
 export const changeViewModelToken =
   define<ChangeViewModel>('change-view-model');
 
 export class ChangeViewModel extends Model<ChangeViewState | undefined> {
+  public readonly changeNum$ = select(this.state$, state => state?.changeNum);
+
+  public readonly patchNum$ = select(this.state$, state => state?.patchNum);
+
+  public readonly basePatchNum$ = select(
+    this.state$,
+    state => state?.basePatchNum
+  );
+
+  public readonly diffPath$ = select(
+    this.state$,
+    state => state?.diffView?.path
+  );
+
+  public readonly diffLine$ = select(
+    this.state$,
+    state => state?.diffView?.lineNum
+  );
+
+  public readonly diffLeftSide$ = select(
+    this.state$,
+    state => state?.diffView?.leftSide ?? false
+  );
+
+  public readonly childView$ = select(this.state$, state => state?.childView);
+
   public readonly tab$ = select(this.state$, state => state?.tab);
 
   public readonly checksPatchset$ = select(
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
index b34a1ba..837e362 100644
--- a/polygerrit-ui/app/models/views/change_test.ts
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -6,73 +6,145 @@
 import {assert} from '@open-wc/testing';
 import {
   BasePatchSetNum,
-  NumericChangeId,
   RepoName,
   RevisionPatchSetNum,
 } from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
 import '../../test/common-test-setup';
-import {createChangeUrl, ChangeViewState} from './change';
-
-const STATE: ChangeViewState = {
-  view: GerritView.CHANGE,
-  changeNum: 1234 as NumericChangeId,
-  repo: 'test' as RepoName,
-};
+import {
+  createChangeViewState,
+  createDiffViewState,
+  createEditViewState,
+} from '../../test/test-data-generators';
+import {
+  createChangeUrl,
+  createDiffUrl,
+  createEditUrl,
+  ChangeViewState,
+} from './change';
 
 suite('change view state tests', () => {
   test('createChangeUrl()', () => {
-    const state: ChangeViewState = {...STATE};
+    const state: ChangeViewState = createChangeViewState();
 
-    assert.equal(createChangeUrl(state), '/c/test/+/1234');
+    assert.equal(createChangeUrl(state), '/c/test-project/+/42');
 
     state.patchNum = 10 as RevisionPatchSetNum;
-    assert.equal(createChangeUrl(state), '/c/test/+/1234/10');
+    assert.equal(createChangeUrl(state), '/c/test-project/+/42/10');
 
     state.basePatchNum = 5 as BasePatchSetNum;
-    assert.equal(createChangeUrl(state), '/c/test/+/1234/5..10');
+    assert.equal(createChangeUrl(state), '/c/test-project/+/42/5..10');
 
     state.messageHash = '#123';
-    assert.equal(createChangeUrl(state), '/c/test/+/1234/5..10#123');
+    assert.equal(createChangeUrl(state), '/c/test-project/+/42/5..10#123');
   });
 
   test('createChangeUrl() baseUrl', () => {
     window.CANONICAL_PATH = '/base';
-    const state: ChangeViewState = {...STATE};
+    const state: ChangeViewState = createChangeViewState();
     assert.equal(createChangeUrl(state).substring(0, 5), '/base');
     window.CANONICAL_PATH = undefined;
   });
 
   test('createChangeUrl() checksRunsSelected', () => {
     const state: ChangeViewState = {
-      ...STATE,
+      ...createChangeViewState(),
       checksRunsSelected: new Set(['asdf']),
     };
 
     assert.equal(
       createChangeUrl(state),
-      '/c/test/+/1234?checksRunsSelected=asdf'
+      '/c/test-project/+/42?checksRunsSelected=asdf'
     );
   });
 
   test('createChangeUrl() checksResultsFilter', () => {
     const state: ChangeViewState = {
-      ...STATE,
+      ...createChangeViewState(),
       checksResultsFilter: 'asdf.*qwer',
     };
 
     assert.equal(
       createChangeUrl(state),
-      '/c/test/+/1234?checksResultsFilter=asdf.*qwer'
+      '/c/test-project/+/42?checksResultsFilter=asdf.*qwer'
     );
   });
 
   test('createChangeUrl() with repo name encoding', () => {
     const state: ChangeViewState = {
-      view: GerritView.CHANGE,
-      changeNum: 1234 as NumericChangeId,
+      ...createChangeViewState(),
       repo: 'x+/y+/z+/w' as RepoName,
     };
-    assert.equal(createChangeUrl(state), '/c/x%252B/y%252B/z%252B/w/+/1234');
+    assert.equal(createChangeUrl(state), '/c/x%252B/y%252B/z%252B/w/+/42');
+  });
+
+  test('createDiffUrl', () => {
+    const params: ChangeViewState = {
+      ...createDiffViewState(),
+      patchNum: 12 as RevisionPatchSetNum,
+      diffView: {path: 'x+y/path.cpp'},
+    };
+    assert.equal(
+      createDiffUrl(params),
+      '/c/test-project/+/42/12/x%252By/path.cpp'
+    );
+
+    window.CANONICAL_PATH = '/base';
+    assert.equal(createDiffUrl(params).substring(0, 5), '/base');
+    window.CANONICAL_PATH = undefined;
+
+    params.repo = 'test' as RepoName;
+    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
+
+    params.basePatchNum = 6 as BasePatchSetNum;
+    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
+
+    params.diffView = {
+      path: 'foo bar/my+file.txt%',
+    };
+    params.patchNum = 2 as RevisionPatchSetNum;
+    delete params.basePatchNum;
+    assert.equal(
+      createDiffUrl(params),
+      '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
+    );
+
+    params.diffView = {
+      path: 'file.cpp',
+      lineNum: 123,
+    };
+    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#123');
+
+    params.diffView = {
+      path: 'file.cpp',
+      lineNum: 123,
+      leftSide: true,
+    };
+    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#b123');
+  });
+
+  test('diff with repo name encoding', () => {
+    const params: ChangeViewState = {
+      ...createDiffViewState(),
+      patchNum: 12 as RevisionPatchSetNum,
+      repo: 'x+/y' as RepoName,
+      diffView: {path: 'x+y/path.cpp'},
+    };
+    assert.equal(createDiffUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+  });
+
+  test('createEditUrl', () => {
+    const params: ChangeViewState = {
+      ...createEditViewState(),
+      patchNum: 12 as RevisionPatchSetNum,
+      editView: {path: 'x+y/path.cpp' as RepoName, lineNum: 31},
+    };
+    assert.equal(
+      createEditUrl(params),
+      '/c/test-project/+/42/12/x%252By/path.cpp,edit#31'
+    );
+
+    window.CANONICAL_PATH = '/base';
+    assert.equal(createEditUrl(params).substring(0, 5), '/base');
+    window.CANONICAL_PATH = undefined;
   });
 });
diff --git a/polygerrit-ui/app/models/views/diff.ts b/polygerrit-ui/app/models/views/diff.ts
deleted file mode 100644
index 34f4ee7..0000000
--- a/polygerrit-ui/app/models/views/diff.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {
-  NumericChangeId,
-  RepoName,
-  RevisionPatchSetNum,
-  BasePatchSetNum,
-  ChangeInfo,
-} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
-import {UrlEncodedCommentId} from '../../types/common';
-import {
-  encodeURL,
-  getBaseUrl,
-  getPatchRangeExpression,
-} from '../../utils/url-util';
-import {define} from '../dependency';
-import {Model} from '../model';
-import {ViewState} from './base';
-
-export interface DiffViewState extends ViewState {
-  view: GerritView.DIFF;
-  changeNum: NumericChangeId;
-  repo?: RepoName;
-  commentId?: UrlEncodedCommentId;
-  path?: string;
-  patchNum?: RevisionPatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-  lineNum?: number;
-  leftSide?: boolean;
-  commentLink?: boolean;
-}
-
-/**
- * This is a convenience type such that you can pass a `ChangeInfo` object
- * as the `change` property instead of having to set both the `changeNum` and
- * `project` properties explicitly.
- */
-export type CreateChangeUrlObject = Omit<
-  DiffViewState,
-  'view' | 'changeNum' | 'project'
-> & {
-  change: Pick<ChangeInfo, '_number' | 'project'>;
-};
-
-export function isCreateChangeUrlObject(
-  state: CreateChangeUrlObject | Omit<DiffViewState, 'view'>
-): state is CreateChangeUrlObject {
-  return !!(state as CreateChangeUrlObject).change;
-}
-
-export function objToState(
-  obj: CreateChangeUrlObject | Omit<DiffViewState, 'view'>
-): DiffViewState {
-  if (isCreateChangeUrlObject(obj)) {
-    return {
-      ...obj,
-      view: GerritView.DIFF,
-      changeNum: obj.change._number,
-      repo: obj.change.project,
-    };
-  }
-  return {...obj, view: GerritView.DIFF};
-}
-
-export function createDiffUrl(
-  obj: CreateChangeUrlObject | Omit<DiffViewState, 'view'>
-) {
-  const state: DiffViewState = objToState(obj);
-  let range = getPatchRangeExpression(state);
-  if (range.length) range = '/' + range;
-
-  let suffix = `${range}/${encodeURL(state.path || '', true)}`;
-
-  if (state.lineNum) {
-    suffix += '#';
-    if (state.leftSide) {
-      suffix += 'b';
-    }
-    suffix += state.lineNum;
-  }
-
-  if (state.commentId) {
-    suffix = `/comment/${state.commentId}` + suffix;
-  }
-
-  if (state.repo) {
-    const encodedProject = encodeURL(state.repo, true);
-    return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
-  } else {
-    return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
-  }
-}
-
-export const diffViewModelToken = define<DiffViewModel>('diff-view-model');
-
-export class DiffViewModel extends Model<DiffViewState | undefined> {
-  constructor() {
-    super(undefined);
-  }
-}
diff --git a/polygerrit-ui/app/models/views/diff_test.ts b/polygerrit-ui/app/models/views/diff_test.ts
deleted file mode 100644
index 7fab2a4..0000000
--- a/polygerrit-ui/app/models/views/diff_test.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {assert} from '@open-wc/testing';
-import {
-  BasePatchSetNum,
-  NumericChangeId,
-  RepoName,
-  RevisionPatchSetNum,
-} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
-import '../../test/common-test-setup';
-import {createDiffUrl, DiffViewState} from './diff';
-
-suite('diff view state tests', () => {
-  test('createDiffUrl', () => {
-    const params: DiffViewState = {
-      view: GerritView.DIFF,
-      changeNum: 42 as NumericChangeId,
-      path: 'x+y/path.cpp' as RepoName,
-      patchNum: 12 as RevisionPatchSetNum,
-      repo: '' as RepoName,
-    };
-    assert.equal(createDiffUrl(params), '/c/42/12/x%252By/path.cpp');
-
-    window.CANONICAL_PATH = '/base';
-    assert.equal(createDiffUrl(params).substring(0, 5), '/base');
-    window.CANONICAL_PATH = undefined;
-
-    params.repo = 'test' as RepoName;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
-
-    params.basePatchNum = 6 as BasePatchSetNum;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
-
-    params.path = 'foo bar/my+file.txt%';
-    params.patchNum = 2 as RevisionPatchSetNum;
-    delete params.basePatchNum;
-    assert.equal(
-      createDiffUrl(params),
-      '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
-    );
-
-    params.path = 'file.cpp';
-    params.lineNum = 123;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#123');
-
-    params.leftSide = true;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#b123');
-  });
-
-  test('diff with repo name encoding', () => {
-    const params: DiffViewState = {
-      view: GerritView.DIFF,
-      changeNum: 42 as NumericChangeId,
-      path: 'x+y/path.cpp',
-      patchNum: 12 as RevisionPatchSetNum,
-      repo: 'x+/y' as RepoName,
-    };
-    assert.equal(createDiffUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
-  });
-});
diff --git a/polygerrit-ui/app/models/views/edit.ts b/polygerrit-ui/app/models/views/edit.ts
deleted file mode 100644
index 3893576..0000000
--- a/polygerrit-ui/app/models/views/edit.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {
-  EDIT,
-  NumericChangeId,
-  RepoName,
-  RevisionPatchSetNum,
-} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
-import {
-  encodeURL,
-  getBaseUrl,
-  getPatchRangeExpression,
-} from '../../utils/url-util';
-import {define} from '../dependency';
-import {Model} from '../model';
-import {ViewState} from './base';
-
-export interface EditViewState extends ViewState {
-  view: GerritView.EDIT;
-  changeNum: NumericChangeId;
-  repo: RepoName;
-  path: string;
-  patchNum: RevisionPatchSetNum;
-  lineNum?: number;
-}
-
-export function createEditUrl(state: Omit<EditViewState, 'view'>): string {
-  if (state.patchNum === undefined) {
-    state = {...state, patchNum: EDIT};
-  }
-  let range = getPatchRangeExpression(state);
-  if (range.length) range = '/' + range;
-
-  let suffix = `${range}/${encodeURL(state.path || '', true)}`;
-  suffix += ',edit';
-
-  if (state.lineNum) {
-    suffix += '#';
-    suffix += state.lineNum;
-  }
-
-  if (state.repo) {
-    const encodedProject = encodeURL(state.repo, true);
-    return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
-  } else {
-    return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
-  }
-}
-
-export const editViewModelToken = define<EditViewModel>('edit-view-model');
-
-export class EditViewModel extends Model<EditViewState | undefined> {
-  constructor() {
-    super(undefined);
-  }
-}
diff --git a/polygerrit-ui/app/models/views/edit_test.ts b/polygerrit-ui/app/models/views/edit_test.ts
deleted file mode 100644
index 00bc805..0000000
--- a/polygerrit-ui/app/models/views/edit_test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {assert} from '@open-wc/testing';
-import {
-  NumericChangeId,
-  RepoName,
-  RevisionPatchSetNum,
-} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
-import '../../test/common-test-setup';
-import {createEditUrl, EditViewState} from './edit';
-
-suite('edit view state tests', () => {
-  test('createEditUrl', () => {
-    const params: EditViewState = {
-      view: GerritView.EDIT,
-      changeNum: 42 as NumericChangeId,
-      repo: 'test-project' as RepoName,
-      path: 'x+y/path.cpp' as RepoName,
-      patchNum: 12 as RevisionPatchSetNum,
-      lineNum: 31,
-    };
-    assert.equal(
-      createEditUrl(params),
-      '/c/test-project/+/42/12/x%252By/path.cpp,edit#31'
-    );
-
-    window.CANONICAL_PATH = '/base';
-    assert.equal(createEditUrl(params).substring(0, 5), '/base');
-    window.CANONICAL_PATH = undefined;
-  });
-});
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 3df17c1..392b5a8 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -12,7 +12,6 @@
     "@polymer/iron-icon": "^3.0.1",
     "@polymer/iron-iconset-svg": "^3.0.1",
     "@polymer/iron-input": "^3.0.1",
-    "@polymer/iron-overlay-behavior": "^3.0.3",
     "@polymer/iron-selector": "^3.0.1",
     "@polymer/marked-element": "^3.0.1",
     "@polymer/paper-button": "^3.0.1",
diff --git a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
index 573b24a..494acd9 100644
--- a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
@@ -58,7 +58,3 @@
 import 'polymer-bridges/polymer/lib/elements/custom-style_bridge.js';
 import 'polymer-bridges/polymer/lib/legacy/mutable-data-behavior_bridge.js';
 import 'polymer-bridges/polymer/polymer-legacy_bridge.js';
-
-// This is needed due to the Polymer.IronFocusablesHelper in gr-overlay.ts
-import 'polymer-bridges/iron-overlay-behavior/iron-focusables-helper_bridge.js';
-
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 1857bad..14fb253 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -50,12 +50,10 @@
   agreementViewModelToken,
 } from '../models/views/agreement';
 import {ChangeViewModel, changeViewModelToken} from '../models/views/change';
-import {DiffViewModel, diffViewModelToken} from '../models/views/diff';
 import {
   DocumentationViewModel,
   documentationViewModelToken,
 } from '../models/views/documentation';
-import {EditViewModel, editViewModelToken} from '../models/views/edit';
 import {GroupViewModel, groupViewModelToken} from '../models/views/group';
 import {PluginViewModel, pluginViewModelToken} from '../models/views/plugin';
 import {RepoViewModel, repoViewModelToken} from '../models/views/repo';
@@ -112,9 +110,7 @@
     [agreementViewModelToken, () => new AgreementViewModel()],
     [changeViewModelToken, () => new ChangeViewModel()],
     [dashboardViewModelToken, () => new DashboardViewModel()],
-    [diffViewModelToken, () => new DiffViewModel()],
     [documentationViewModelToken, () => new DocumentationViewModel()],
-    [editViewModelToken, () => new EditViewModel()],
     [groupViewModelToken, () => new GroupViewModel()],
     [pluginViewModelToken, () => new PluginViewModel()],
     [repoViewModelToken, () => new RepoViewModel()],
@@ -139,9 +135,7 @@
           resolver(agreementViewModelToken),
           resolver(changeViewModelToken),
           resolver(dashboardViewModelToken),
-          resolver(diffViewModelToken),
           resolver(documentationViewModelToken),
-          resolver(editViewModelToken),
           resolver(groupViewModelToken),
           resolver(pluginViewModelToken),
           resolver(repoViewModelToken),
@@ -154,7 +148,8 @@
       changeModelToken,
       () =>
         new ChangeModel(
-          resolver(routerModelToken),
+          resolver(navigationToken),
+          resolver(changeViewModelToken),
           appContext.restApiService,
           resolver(userModelToken)
         ),
@@ -163,7 +158,7 @@
       commentsModelToken,
       () =>
         new CommentsModel(
-          resolver(routerModelToken),
+          resolver(changeViewModelToken),
           resolver(changeModelToken),
           resolver(accountsModelToken),
           appContext.restApiService,
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 572e107..29e9259 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -17,9 +17,7 @@
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
-  DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
   PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
   SUGGEST_EDIT = 'UiFeature__suggest_edit',
-  MENTION_USERS = 'UiFeature__mention_users',
-  RENDER_MARKDOWN = 'UiFeature__render_markdown',
+  REBASE_CHAIN = 'UiFeature__rebase_chain',
 }
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 6ad03a3..0d0c88f 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -143,7 +143,7 @@
 import {ParsedChangeInfo} from '../../types/types';
 import {ErrorCallback} from '../../api/rest';
 import {addDraftProp, DraftInfo} from '../../utils/comment-util';
-import {BaseScheduler} from '../scheduler/scheduler';
+import {BaseScheduler, Scheduler} from '../scheduler/scheduler';
 import {MaxInFlightScheduler} from '../scheduler/max-in-flight-scheduler';
 import {escapeAndWrapSearchOperatorValue} from '../../utils/string-util';
 
@@ -270,6 +270,11 @@
 function createWriteScheduler() {
   return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5);
 }
+
+function createSerializingScheduler() {
+  return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 1);
+}
+
 export class GrRestApiServiceImpl implements RestApiService, Finalizable {
   readonly _cache = siteBasedCache; // Shared across instances.
 
@@ -286,6 +291,9 @@
   // Private, but used in tests.
   readonly _restApiHelper: GrRestApiHelper;
 
+  // Used to serialize requests for certain RPCs
+  readonly _serialScheduler: Scheduler<Response>;
+
   constructor(private readonly authService: AuthService) {
     this._restApiHelper = new GrRestApiHelper(
       this._cache,
@@ -294,6 +302,7 @@
       createReadScheduler(),
       createWriteScheduler()
     );
+    this._serialScheduler = createSerializingScheduler();
   }
 
   finalize() {}
@@ -2232,11 +2241,13 @@
     return this.getFromProjectLookup(changeNum).then(project => {
       const encodedRepoName = project ? encodeURIComponent(project) + '~' : '';
       const url = `/accounts/self/starred.changes/${encodedRepoName}${changeNum}`;
-      return this._restApiHelper.send({
-        method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
-        url,
-        anonymizedUrl: '/accounts/self/starred.changes/*',
-      });
+      return this._serialScheduler.schedule(() =>
+        this._restApiHelper.send({
+          method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
+          url,
+          anonymizedUrl: '/accounts/self/starred.changes/*',
+        })
+      );
     });
   }
 
@@ -2686,7 +2697,7 @@
         encodeURIComponent(repo) +
         '/commits/' +
         encodeURIComponent(commit),
-      anonymizedUrl: '/projects/*/comments/*',
+      anonymizedUrl: '/projects/*/commits/*',
     }) as Promise<CommitInfo | undefined>;
   }
 
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index edde7a4..c3c1cb6 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -4,11 +4,6 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {Observable} from 'rxjs';
-import {
-  NumericChangeId,
-  RevisionPatchSetNum,
-  BasePatchSetNum,
-} from '../../types/common';
 import {Model} from '../../models/model';
 import {select} from '../../utils/observable-util';
 import {define} from '../../models/dependency';
@@ -18,9 +13,7 @@
   AGREEMENTS = 'agreements',
   CHANGE = 'change',
   DASHBOARD = 'dashboard',
-  DIFF = 'diff',
   DOCUMENTATION_SEARCH = 'documentation-search',
-  EDIT = 'edit',
   GROUP = 'group',
   PLUGIN_SCREEN = 'plugin-screen',
   REPO = 'repo',
@@ -31,9 +24,6 @@
 export interface RouterState {
   // Note that this router model view must be updated before view model state.
   view?: GerritView;
-  changeNum?: NumericChangeId;
-  patchNum?: RevisionPatchSetNum;
-  basePatchNum?: BasePatchSetNum;
 }
 
 export const routerModelToken = define<RouterModel>('router-model');
@@ -43,17 +33,6 @@
     state => state.view
   );
 
-  readonly routerChangeNum$: Observable<NumericChangeId | undefined> = select(
-    this.state$,
-    state => state.changeNum
-  );
-
-  readonly routerPatchNum$: Observable<RevisionPatchSetNum | undefined> =
-    select(this.state$, state => state.patchNum);
-
-  readonly routerBasePatchNum$: Observable<BasePatchSetNum | undefined> =
-    select(this.state$, state => state.basePatchNum);
-
   constructor() {
     super({});
   }
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
index da61c41..9ca2213 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -35,6 +35,8 @@
   GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
   GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
   GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
+  GO_TO_REPOS = 'GO_TO_REPOS',
+  GO_TO_GROUPS = 'GO_TO_GROUPS',
 
   CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
   CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
@@ -167,6 +169,16 @@
     {key: 'w', combo: ComboKey.G}
   );
   describe(
+    Shortcut.GO_TO_REPOS,
+    ShortcutSection.EVERYWHERE,
+    'Go to Repositories',
+    {key: 'r', combo: ComboKey.G}
+  );
+  describe(Shortcut.GO_TO_GROUPS, ShortcutSection.EVERYWHERE, 'Go to Groups', {
+    key: 'g',
+    combo: ComboKey.G,
+  });
+  describe(
     Shortcut.TOGGLE_CHECKBOX,
     ShortcutSection.ACTIONS,
     'Toggle checkbox',
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
index 7a44e79..17b7461 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.ts
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -32,7 +32,7 @@
     color: var(--deemphasized-text-color);
     padding: var(--spacing-l);
   }
-  @media only screen and (max-width: 67em) {
+  @media only screen and (max-width: 70em) {
     .main {
       margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
     }
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
index b6e8f60..963b2a2 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.ts
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -16,13 +16,16 @@
     border-top: 1px solid transparent;
     display: block;
     padding: 0 var(--spacing-xl);
-  }
-  .navStyles li a {
-    display: block;
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
   }
+  .navStyles li a {
+    display: block;
+    /* overflow and text-overflow are not inherited, must repeat them */
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
   .navStyles .subsectionItem {
     padding-left: var(--spacing-xxl);
   }
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 86c6ba2..6dbda3e 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -480,10 +480,7 @@
     --border-radius: 4px;
     --line-length-indicator-color: #681da8;
 
-    /* paper and iron component overrides */
-    --iron-overlay-backdrop-background-color: black;
-    --iron-overlay-backdrop-opacity: 0.32;
-
+    /* paper component overrides */
     --paper-tooltip-delay-in: 200ms;
     --paper-tooltip-delay-out: 0;
     --paper-tooltip-duration-in: 0;
@@ -520,9 +517,6 @@
     --paper-tooltip: {
       font-size: var(--font-size-small);
     };
-    --iron-overlay-backdrop: {
-      transition: none;
-    };
   }
 `;
 
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index ecae845..c6884a6 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -281,9 +281,6 @@
     /* misc */
     --line-length-indicator-color: #d7aefb;
 
-    /* paper and iron component overrides */
-    --iron-overlay-backdrop-background-color: white;
-
     /* rules applied to html */
     background-color: var(--view-background-color);
   }
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 33d2753..aed58d8 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -16,8 +16,6 @@
 import {
   cleanupTestUtils,
   getCleanupsCount,
-  addIronOverlayBackdropStyleEl,
-  removeIronOverlayBackdropStyleEl,
   removeThemeStyles,
 } from './test-utils';
 import {safeTypesBridge} from '../utils/safe-types-util';
@@ -99,7 +97,6 @@
 
 setup(() => {
   testSetupTimestampMs = new Date().getTime();
-  addIronOverlayBackdropStyleEl();
 
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
@@ -173,7 +170,6 @@
   fixtureCleanup();
   cleanupTestUtils();
   checkGlobalSpace();
-  removeIronOverlayBackdropStyleEl();
   removeThemeStyles();
   cancelAllTasks();
   cleanUpStorage();
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 23a5794..f0a4cbe 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -105,7 +105,6 @@
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
 import {
   DetailedLabelInfo,
-  FileInfo,
   QuickLabelInfo,
   SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
@@ -115,8 +114,8 @@
 import {Category, RunStatus} from '../api/checks';
 import {DiffInfo} from '../api/diff';
 import {SearchViewState} from '../models/views/search';
-import {ChangeViewState} from '../models/views/change';
-import {EditViewState} from '../models/views/edit';
+import {ChangeChildView, ChangeViewState} from '../models/views/change';
+import {NormalizedFileInfo} from '../models/change/files-model';
 
 const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN';
 export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
@@ -400,10 +399,15 @@
   return messages;
 }
 
-export function createFileInfo(): FileInfo {
+export function createFileInfo(
+  path = 'test-path/test-file.txt'
+): NormalizedFileInfo {
   return {
     size: 314,
     size_delta: 7,
+    lines_deleted: 0,
+    lines_inserted: 0,
+    __path: path,
   };
 }
 
@@ -701,6 +705,7 @@
 export function createChangeViewState(): ChangeViewState {
   return {
     view: GerritView.CHANGE,
+    childView: ChangeChildView.OVERVIEW,
     changeNum: TEST_NUMERIC_CHANGE_ID,
     repo: TEST_PROJECT_NAME,
   };
@@ -716,12 +721,22 @@
   };
 }
 
-export function createEditViewState(): EditViewState {
+export function createEditViewState(): ChangeViewState {
   return {
-    view: GerritView.EDIT,
+    view: GerritView.CHANGE,
+    childView: ChangeChildView.EDIT,
     changeNum: TEST_NUMERIC_CHANGE_ID,
     patchNum: EDIT,
-    path: 'foo/bar.baz',
+    repo: TEST_PROJECT_NAME,
+    editView: {path: 'foo/bar.baz'},
+  };
+}
+
+export function createDiffViewState(): ChangeViewState {
+  return {
+    view: GerritView.CHANGE,
+    childView: ChangeChildView.DIFF,
+    changeNum: TEST_NUMERIC_CHANGE_ID,
     repo: TEST_PROJECT_NAME,
   };
 }
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index ae6a6b4..c400d9c 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -122,24 +122,6 @@
   ReturnType<F>
 >;
 
-/**
- * Forcing an opacity of 0 onto the ironOverlayBackdrop is required, because
- * otherwise the backdrop stays around in the DOM for too long waiting for
- * an animation to finish.
- */
-export function addIronOverlayBackdropStyleEl() {
-  const el = document.createElement('style');
-  el.setAttribute('id', 'backdrop-style');
-  document.head.appendChild(el);
-  el.sheet!.insertRule('body { --iron-overlay-backdrop-opacity: 0; }');
-}
-
-export function removeIronOverlayBackdropStyleEl() {
-  const el = document.getElementById('backdrop-style');
-  if (!el?.parentNode) throw new Error('Backdrop style element not found.');
-  el.parentNode?.removeChild(el);
-}
-
 export function removeThemeStyles() {
   // Do not remove the light theme, because it is only added once statically,
   // not once per gr-app instantiation.
@@ -147,6 +129,30 @@
   document.head.querySelector('#dark-theme')?.remove();
 }
 
+function getActiveElement() {
+  return document.activeElement;
+}
+
+export function isFocusInsideElement(element: Element) {
+  // In Polymer 2 focused element either <paper-input> or nested
+  // native input <input> element depending on the current focus
+  // in browser window.
+  // For example, the focus is changed if the developer console
+  // get a focus.
+  let activeElement = getActiveElement();
+  while (activeElement) {
+    if (activeElement === element) {
+      return true;
+    }
+    if (activeElement.parentElement) {
+      activeElement = activeElement.parentElement;
+    } else {
+      activeElement = (activeElement.getRootNode() as ShadowRoot).host;
+    }
+  }
+  return false;
+}
+
 export async function waitQueryAndAssert<E extends Element = Element>(
   el: Element | null | undefined,
   selector: string
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 7370c96..22bee0c 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1511,3 +1511,8 @@
   conflicts?: string[];
   mergeable_into?: string[];
 }
+
+export interface ChangeActionDialog extends HTMLElement {
+  resetFocus?(): void;
+  init?(): void;
+}
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 8d55e36..557e3a0 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -10,7 +10,6 @@
   AccountInfo,
   BasePatchSetNum,
   ChangeViewChangeInfo,
-  CommitId,
   CommitInfo,
   EditPatchSet,
   PatchSetNum,
@@ -28,11 +27,6 @@
   requestAvailability(): void;
 }
 
-export interface CommitRange {
-  baseCommit: CommitId;
-  commit: CommitId;
-}
-
 export type {CoverageRange} from '../api/diff';
 export {CoverageType} from '../api/diff';
 
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
index 6f81d57..7916799 100644
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -12,7 +12,7 @@
 import {hasOwnProperty} from './common-util';
 import {GerritView} from '../services/router/router-model';
 import {MenuLink} from '../api/admin';
-import {AdminChildView} from '../models/views/admin';
+import {AdminChildView, createAdminUrl} from '../models/views/admin';
 import {createGroupUrl, GroupDetailView} from '../models/views/group';
 import {createRepoUrl, RepoDetailView} from '../models/views/repo';
 
@@ -20,7 +20,7 @@
   {
     name: 'Repositories',
     noBaseUrl: true,
-    url: '/admin/repos',
+    url: createAdminUrl({adminView: AdminChildView.REPOS}),
     view: 'gr-repo-list' as GerritView,
     viewableToAll: true,
   },
@@ -28,7 +28,7 @@
     name: 'Groups',
     section: 'Groups',
     noBaseUrl: true,
-    url: '/admin/groups',
+    url: createAdminUrl({adminView: AdminChildView.GROUPS}),
     view: 'gr-admin-group-list' as GerritView,
   },
   {
@@ -36,7 +36,7 @@
     capability: 'viewPlugins',
     section: 'Plugins',
     noBaseUrl: true,
-    url: '/admin/plugins',
+    url: createAdminUrl({adminView: AdminChildView.PLUGINS}),
     view: 'gr-plugin-list' as GerritView,
   },
 ];
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index 77834bd..4404e59 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -3,7 +3,12 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {AccountInfo, ChangeInfo, ServerInfo} from '../types/common';
+import {
+  AccountInfo,
+  ChangeInfo,
+  DetailedLabelInfo,
+  ServerInfo,
+} from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
 import {
   getAccountTemplate,
@@ -13,6 +18,7 @@
 } from './account-util';
 import {CommentThread, isMentionedThread, isUnresolved} from './comment-util';
 import {hasOwnProperty} from './common-util';
+import {getCodeReviewLabel} from './label-util';
 
 export function canHaveAttention(account?: AccountInfo): boolean {
   return !!account?._account_id && !isServiceUser(account);
@@ -101,9 +107,10 @@
 /**
  *  Sort order:
  * 1. The user themselves
- * 2. Human users in the attention set.
- * 3. Other human users.
- * 4. Service users.
+ * 2. Users in the attention set first.
+ * 3. Human users first.
+ * 4. Users that have voted first in this order of vote values:
+ *    -2, -1, +2, +1, 0 or no vote.
  */
 export function sortReviewers(
   r1: AccountInfo,
@@ -117,7 +124,22 @@
   }
   const a1 = hasAttention(r1, change) ? 1 : 0;
   const a2 = hasAttention(r2, change) ? 1 : 0;
-  const s1 = isServiceUser(r1) ? -2 : 0;
-  const s2 = isServiceUser(r2) ? -2 : 0;
-  return a2 - a1 + s2 - s1;
+  if (a2 - a1 !== 0) return a2 - a1;
+
+  const s1 = isServiceUser(r1) ? -1 : 0;
+  const s2 = isServiceUser(r2) ? -1 : 0;
+  if (s2 - s1 !== 0) return s2 - s1;
+
+  const crLabel = getCodeReviewLabel(change?.labels ?? {}) as DetailedLabelInfo;
+  let v1 =
+    crLabel?.all?.find(vote => vote._account_id === r1._account_id)?.value ?? 0;
+  let v2 =
+    crLabel?.all?.find(vote => vote._account_id === r2._account_id)?.value ?? 0;
+  // We want negative votes getting a higher score than positive votes, so
+  // we choose 10 as a random number that is higher than all positive votes that
+  // are in use, and then add the absolute value of the vote to that.
+  // So -2 becomes 12.
+  if (v1 < 0) v1 = 10 - v1;
+  if (v2 < 0) v2 = 10 - v2;
+  return v2 - v1;
 }
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.ts b/polygerrit-ui/app/utils/attention-set-util_test.ts
index 8092a6e..5bd1924 100644
--- a/polygerrit-ui/app/utils/attention-set-util_test.ts
+++ b/polygerrit-ui/app/utils/attention-set-util_test.ts
@@ -6,9 +6,11 @@
 import '../test/common-test-setup';
 import {
   createAccountDetailWithIdNameAndEmail,
+  createAccountWithId,
   createChange,
   createComment,
   createCommentThread,
+  createParsedChange,
   createServerInfo,
 } from '../test/test-data-generators';
 import {
@@ -22,9 +24,10 @@
   getMentionedReason,
   getReason,
   hasAttention,
+  sortReviewers,
 } from './attention-set-util';
 import {DefaultDisplayNameConfig} from '../api/rest-api';
-import {AccountsVisibility} from '../constants/constants';
+import {AccountsVisibility, AccountTag} from '../constants/constants';
 import {assert} from '@open-wc/testing';
 
 const KERMIT: AccountInfo = {
@@ -101,6 +104,45 @@
     assert.equal(getReason(config, OTHER_ACCOUNT, change), 'Added by kermit');
   });
 
+  test('sortReviewers', () => {
+    const a1 = createAccountWithId(1);
+    a1.tags = [AccountTag.SERVICE_USER];
+    const a2 = createAccountWithId(2);
+    a2.tags = [AccountTag.SERVICE_USER];
+    const a3 = createAccountWithId(3);
+    const a4 = createAccountWithId(4);
+    const a5 = createAccountWithId(5);
+    const a6 = createAccountWithId(6);
+    const a7 = createAccountWithId(7);
+
+    const reviewers = [a1, a2, a3, a4, a5, a6, a7];
+    const change = {
+      ...createParsedChange(),
+      attention_set: {'6': {account: a6}},
+      labels: {
+        'Code-Review': {
+          all: [
+            {...a2, value: 1},
+            {...a4, value: 1},
+            {...a5, value: -1},
+          ],
+        },
+      },
+    };
+    assert.sameOrderedMembers(
+      reviewers.sort((r1, r2) => sortReviewers(r1, r2, change, a7)),
+      [
+        a7, // self
+        a6, // is in the attention set
+        a5, // human user, has voted -1
+        a4, // human user, has voted +1
+        a3, // human user, has not voted
+        a2, // service user, has voted
+        a1, // service user, has not voted
+      ]
+    );
+  });
+
   test('getMentionReason', () => {
     let comment = {
       ...createComment(),
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 8af5beb..a92f0f8 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -598,3 +598,17 @@
       .includes(account.email)
   );
 }
+
+export function findComment(
+  comments: {
+    [path: string]: (CommentInfo | DraftInfo)[];
+  },
+  commentId: UrlEncodedCommentId
+) {
+  if (!commentId) return undefined;
+  let comment;
+  for (const path of Object.keys(comments)) {
+    comment = comment || comments[path].find(c => c.id === commentId);
+  }
+  return comment;
+}
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 23e3356..056238a 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -465,7 +465,7 @@
   const path: EventTarget[] = e.composedPath() ?? [];
   for (const el of path) {
     if (!isElementTarget(el)) continue;
-    if (el.tagName === 'GR-OVERLAY' || el.tagName === 'DIALOG') return true;
+    if (el.tagName === 'DIALOG') return true;
   }
   return false;
 }
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
index 3ad4438..fe185be 100644
--- a/polygerrit-ui/app/utils/dom-util_test.ts
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -329,15 +329,6 @@
       });
     });
 
-    test('suppress shortcut event from children of <gr-overlay>', async () => {
-      const overlay = document.createElement('gr-overlay');
-      const div = document.createElement('div');
-      overlay.appendChild(div);
-      await keyEventOn(div, e => {
-        assert.isTrue(shouldSuppress(e));
-      });
-    });
-
     test('suppress "enter" shortcut event from <gr-button>', async () => {
       await keyEventOn(
         document.createElement('gr-button'),
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index 1116123..b007d47 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {SpecialFilePath, FileInfoStatus} from '../constants/constants';
-import {FileInfo} from '../types/common';
+import {FileInfo, FileNameToFileInfoMap} from '../types/common';
 import {hasOwnProperty} from './common-util';
 
 export function specialFilePathCompare(a: string, b: string) {
@@ -55,7 +55,7 @@
 // In case there are files with comments on them but they are unchanged, then
 // we explicitly displays the file to render the comments with Unchanged status
 export function addUnmodifiedFiles(
-  files: {[filename: string]: FileInfo},
+  files: FileNameToFileInfoMap,
   commentedPaths: {[fileName: string]: boolean}
 ) {
   if (!commentedPaths) return;
diff --git a/polygerrit-ui/app/utils/path-list-util_test.ts b/polygerrit-ui/app/utils/path-list-util_test.ts
index 50f5c0e..3c9e0d3 100644
--- a/polygerrit-ui/app/utils/path-list-util_test.ts
+++ b/polygerrit-ui/app/utils/path-list-util_test.ts
@@ -12,9 +12,9 @@
   specialFilePathCompare,
   truncatePath,
 } from './path-list-util';
-import {FileInfo} from '../api/rest-api';
 import {hasOwnProperty} from './common-util';
 import {assert} from '@open-wc/testing';
+import {FileNameToFileInfoMap} from '../types/common';
 
 suite('path-list-utl tests', () => {
   test('special sort', () => {
@@ -117,7 +117,7 @@
       'file1.txt': true,
     };
 
-    const files: {[filename: string]: FileInfo} = {
+    const files: FileNameToFileInfoMap = {
       'file2.txt': {
         status: FileInfoStatus.REWRITTEN,
         size_delta: 10,
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 2f7cf3c..5a91fc0 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -161,7 +161,7 @@
   dependencies:
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/iron-overlay-behavior@^3.0.0-pre.27", "@polymer/iron-overlay-behavior@^3.0.3":
+"@polymer/iron-overlay-behavior@^3.0.0-pre.27":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/@polymer/iron-overlay-behavior/-/iron-overlay-behavior-3.0.3.tgz#29c198e19e05bb2bcf7d86d3c11848cb93301d00"
   integrity sha512-Q/Fp0+uOQQ145ebZ7T8Cxl4m1tUKYjyymkjcL2rXUm+aDQGb1wA1M1LYxUF5YBqd+9lipE0PTIiYwA2ZL/sznA==
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 98ab4b2..4b621b5 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -77,13 +77,8 @@
       {for $line, $index in $comment.lines}
         {if $index == 0}
           {if $comment.startLine != 0}
-            {$comment.link}
+            {$comment.link}{sp}:{\n}
           {/if}
-
-          // Insert a space before the newline so that Gmail does not mistakenly
-          // link the following line with the file link. See issue 9201.
-          {sp}{\n}
-
           {$comment.linePrefix}
         {else}
           {$comment.linePrefixEmpty}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 2f9561b..8e97ba7 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -39,6 +39,7 @@
 cpp = text/x-c++src
 cql = text/x-cassandra
 cxx = text/x-c++src
+cu = text/x-c++src
 cyp = application/x-cypher-query
 cypher = application/x-cypher-query
 c++ = text/x-c++src
diff --git a/tools/deps.bzl b/tools/deps.bzl
index d985a5c..ed8d65f5 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -114,8 +114,8 @@
 
     maven_jar(
         name = "commons-codec",
-        artifact = "commons-codec:commons-codec:1.10",
-        sha1 = "4b95f4897fa13f2cd904aee711aeafc0c5295cd8",
+        artifact = "commons-codec:commons-codec:1.15",
+        sha1 = "49d94806b6e3dc933dacbd8acb0fdbab8ebd1e5d",
     )
 
     # When upgrading commons-compress, also upgrade tukaani-xz
@@ -377,8 +377,8 @@
 
     maven_jar(
         name = "jsoup",
-        artifact = "org.jsoup:jsoup:1.9.2",
-        sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
+        artifact = "org.jsoup:jsoup:1.14.3",
+        sha1 = "c43a81e18e6d0eb71951aa031d55d5c293c531a6",
     )
 
     maven_jar(