Merge "Merge branch 'stable-3.8' into stable-3.9" into stable-3.9
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 7f25509..f795def 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -3420,6 +3420,15 @@
 +
 Defaults to true.
 
+[[index.excludeProjectFromChangeReindex]]index.excludeProjectFromChangeReindex::
++
+A list of projects that will be excluded from reindexing. This can be used
+to exclude projects which are expensive to reindex to prioritize the other
+projects.
++
+Excluded projects can later be reindexed by for example using the
+link:cmd-index-changes-in-project.html[index changes in project command].
+
 [[index.paginationType]]index.paginationType::
 +
 The pagination type to use when index queries are repeated to
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index fb12ff3..1bcda63 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -127,7 +127,7 @@
 link:#submit_requirement_submittable_if[submittableIf] expression evaluates to
 true or not.
 
-* `BYPASSED`
+* `FORCED`
 +
 The change was merged directly bypassing code review by supplying the
 link:user-upload.html#auto_merge[submit] push option while doing a git push.
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index 175a159..41543ab 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -53,15 +53,14 @@
 === Election of non-Google steering committee members
 
 The election of the non-Google steering committee members happens once
-a year in May. Non-Google link:dev-roles.html#maintainer[maintainers]
+a year in June. Non-Google link:dev-roles.html#maintainer[maintainers]
 can nominate themselves by posting an informal application on the
 non-public mailto:gerritcodereview-community-managers@googlegroups.com[
-community manager mailing list] by end of April (deadline for 2020
-is Thu 30th of April EOD).
+community manager mailing list] when the call for nomiations is sent to
+the maintainers list by a community manager.
 
 The list with all candidates will be published at the beginning of the
-voting period (for 2020 the start of the voting is planned for Mon 4th
-of May).
+voting period.
 
 Keeping the candidates private during the nomination phase and
 publishing all candidates at once only at the start of the voting
@@ -83,7 +82,7 @@
 happens by posting on the
 mailto:gerritcodereview-maintainers@googlegroups.com[maintainer mailing
 list]. The voting period is 14 calendar days from the start of the
-voting (for 2020 the voting period ends on Mon 18th May EOD).
+voting.
 
 Google maintainers do not take part in this vote, because Google
 already has dedicated seats in the steering committee (see section
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index f3a81e7..36fd46e 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -329,10 +329,10 @@
 This is a group that remains private between the individual community
 member and community managers.
 
-The community managers should be a pair or trio that shares the work:
+The community managers should be at least a pair that shares the work:
 
 * One Googler that is appointed by Google.
-* One or two non-Googlers, elected by the community if there are more
+* One or more non-Googlers, elected by the community if there are more
   than two candidates. If there is no candidate, we only have the one
   community manager from Google.
 
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 310d141..f3881f2 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index c6457a4..edbb1ee 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountDelta;
 import com.google.gerrit.server.account.AccountState;
@@ -28,7 +29,6 @@
 import com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index 5efcfc6..dbcfceb 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeInserter;
@@ -41,7 +42,6 @@
 import com.google.gerrit.server.edit.tree.TreeModification;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
index dcf1158..0a22688 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -21,13 +21,13 @@
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupUuid;
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index 699acc0..52ad0a9 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -162,7 +162,7 @@
   /**
    * Create a new account.
    *
-   * @param newId unique id, see {@link com.google.gerrit.server.notedb.Sequences#nextAccountId()}.
+   * @param newId unique id, see Sequences#nextAccountId().
    * @param registeredOn when the account was registered.
    */
   public static Account.Builder builder(Account.Id newId, Instant registeredOn) {
diff --git a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
index 77c5381..d9f1c09 100644
--- a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
+++ b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
@@ -29,7 +29,17 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-/** Redirects {@code domain.tld/123} to {@code domain.tld/c/project/+/123}. */
+/**
+ * Redirects:
+ *
+ * <ul>
+ *   <li>{@code domain.tld/123} to {@code domain.tld/c/project/+/123}
+ *   <li/>
+ *   <li>{@code domain.tld/123/comment/bc630c55_3e265b44} to {@code
+ *       domain.tld/c/project/+/123/comment/bc630c55_3e265b44/}
+ *   <li/>
+ * </ul>
+ */
 @Singleton
 public class NumericChangeIdRedirectServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
@@ -43,7 +53,11 @@
 
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    String idString = req.getPathInfo();
+    String uriPath = req.getPathInfo();
+    // Check if we are processing a comment url, like "/c/1/comment/ff3303fd_8341647b/".
+    int commentIdx = uriPath.indexOf("/comment");
+    String idString = commentIdx == -1 ? uriPath : uriPath.substring(0, commentIdx);
+
     if (idString.endsWith("/")) {
       idString = idString.substring(0, idString.length() - 1);
     }
@@ -64,6 +78,10 @@
     }
     String path =
         PageLinks.toChange(changeResource.getProject(), changeResource.getChange().getId());
+    if (commentIdx > -1) {
+      // path already contain a trailing /, hence we start from "commentIdx + 1"
+      path = path + uriPath.substring(commentIdx + 1);
+    }
     UrlModule.toGerrit(path, req, rsp);
   }
 }
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index 5a898a1..aad6b57 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -73,6 +73,7 @@
     serveRegex("^/register$").with(registerScreen(false));
     serveRegex("^/register/(.+)$").with(registerScreen(true));
     serveRegex("^(?:/c)?/([1-9][0-9]*)/?$").with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^(?:/c)?/([1-9][0-9]*)/comment/\\w+/?$").with(NumericChangeIdRedirectServlet.class);
     serveRegex("^/p/(.*)$").with(queryProjectNew());
     serveRegex("^/r/(.+)/?$").with(DirectChangeByCommit.class);
 
diff --git a/java/com/google/gerrit/pgm/init/InitModule.java b/java/com/google/gerrit/pgm/init/InitModule.java
index a40a704..f36ec3d 100644
--- a/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/java/com/google/gerrit/pgm/init/InitModule.java
@@ -20,13 +20,13 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.pgm.init.api.SequencesOnInit.DisabledGitRefUpdatedRepoAccountsSequenceProvider;
 import com.google.gerrit.server.Sequence;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.notedb.RepoSequence.DisabledGitRefUpdatedRepoAccountsSequenceProvider;
 import com.google.inject.Singleton;
 import com.google.inject.binder.LinkedBindingBuilder;
 import com.google.inject.internal.UniqueAnnotations;
diff --git a/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
index f6e2b9c..68b1de7 100644
--- a/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
@@ -17,8 +17,17 @@
 import static com.google.gerrit.server.Sequence.LightweightAccounts;
 
 import com.google.gerrit.server.Sequence;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.RepoSequence;
+import com.google.gerrit.server.notedb.RepoSequence.RepoSequenceModule;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class SequencesOnInit {
@@ -32,4 +41,39 @@
   public int nextAccountId() {
     return accountsSequence.next();
   }
+
+  /** A accounts sequence provider that does not fire git reference updates. */
+  public static class DisabledGitRefUpdatedRepoAccountsSequenceProvider
+      implements Provider<Sequence> {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsers;
+    private final Config cfg;
+
+    @Inject
+    DisabledGitRefUpdatedRepoAccountsSequenceProvider(
+        @GerritServerConfig Config cfg,
+        GitRepositoryManagerOnInit repoManager,
+        AllUsersName allUsersName) {
+      this.repoManager = repoManager;
+      this.allUsers = allUsersName;
+      this.cfg = cfg;
+    }
+
+    @Override
+    public Sequence get() {
+      int accountBatchSize =
+          cfg.getInt(
+              RepoSequenceModule.SECTION_NOTE_DB,
+              Sequence.NAME_ACCOUNTS,
+              RepoSequenceModule.KEY_SEQUENCE_BATCH_SIZE,
+              RepoSequenceModule.DEFAULT_ACCOUNTS_SEQUENCE_BATCH_SIZE);
+      return new RepoSequence(
+          repoManager,
+          GitReferenceUpdated.DISABLED,
+          allUsers,
+          Sequence.NAME_ACCOUNTS,
+          () -> Sequences.FIRST_ACCOUNT_ID,
+          accountBatchSize);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/DeleteZombieComments.java b/java/com/google/gerrit/server/DeleteZombieComments.java
index 8deee6f..4532b04 100644
--- a/java/com/google/gerrit/server/DeleteZombieComments.java
+++ b/java/com/google/gerrit/server/DeleteZombieComments.java
@@ -109,9 +109,15 @@
   @CanIgnoreReturnValue
   public int execute() throws IOException {
     setup();
-    List<KeyT> emptyDrafts = filterByCleanupPercentage(listEmptyDrafts(), "empty");
     ListMultimap<KeyT, HumanComment> alreadyPublished = listDraftCommentsThatAreAlsoPublished();
-    if (dryRun) {
+    if (!dryRun) {
+      deleteZombieDrafts(alreadyPublished);
+    }
+
+    List<KeyT> emptyDrafts = filterByCleanupPercentage(listEmptyDrafts(), "empty");
+    if (!dryRun) {
+      deleteEmptyDraftsByKey(emptyDrafts);
+    } else {
       logInfo(
           String.format(
               "Running in dry run mode. Skipping deletion."
@@ -119,9 +125,6 @@
                   + "\nEmpty drafts = %d"
                   + "\nAlready published drafts (zombies) = %d",
               cleanupPercentage, emptyDrafts.size(), alreadyPublished.size()));
-    } else {
-      deleteEmptyDraftsByKey(emptyDrafts);
-      deleteZombieDrafts(alreadyPublished);
     }
     return emptyDrafts.size() + alreadyPublished.size();
   }
diff --git a/java/com/google/gerrit/server/notedb/Sequences.java b/java/com/google/gerrit/server/Sequences.java
similarity index 97%
rename from java/com/google/gerrit/server/notedb/Sequences.java
rename to java/com/google/gerrit/server/Sequences.java
index 780998b..431a1b2 100644
--- a/java/com/google/gerrit/server/notedb/Sequences.java
+++ b/java/com/google/gerrit/server/Sequences.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.notedb;
+package com.google.gerrit.server;
 
 import static com.google.gerrit.server.Sequence.NAME_ACCOUNTS;
 import static com.google.gerrit.server.Sequence.NAME_CHANGES;
@@ -24,7 +24,6 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer2;
-import com.google.gerrit.server.Sequence;
 import com.google.gerrit.server.Sequence.SequenceType;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index edec52c..5023413 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -45,7 +46,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 09fbb89..24e8ba5 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.Sequences;
 import com.google.inject.BindingAnnotation;
 import java.io.IOException;
 import java.lang.annotation.Retention;
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 74f1355..8a7840b 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
@@ -51,7 +52,6 @@
 import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index b0e1e38..d27b13ad 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -119,6 +119,7 @@
 import com.google.gerrit.server.PublishCommentsOp;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.cancellation.RequestCancelledException;
@@ -159,7 +160,6 @@
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.ChangePermission;
diff --git a/java/com/google/gerrit/server/index/OnlineReindexer.java b/java/com/google/gerrit/server/index/OnlineReindexer.java
index eef394d..e3b8e7c 100644
--- a/java/com/google/gerrit/server/index/OnlineReindexer.java
+++ b/java/com/google/gerrit/server/index/OnlineReindexer.java
@@ -106,9 +106,6 @@
         "Starting online reindex of %s from schema version %s to %s",
         name, version(indexes.getSearchIndex()), version(index));
 
-    if (oldVersion != newVersion) {
-      index.deleteAll();
-    }
     SiteIndexer.Result result = batchIndexer.indexAll(index);
     if (!result.success()) {
       logger.atSevere().log(
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 4f411a2..b98bae0 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -22,7 +22,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Stopwatch;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
@@ -31,6 +31,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.SiteIndexer;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
@@ -46,10 +47,14 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.Repository;
@@ -82,6 +87,7 @@
   private final ChangeIndexer.Factory indexerFactory;
   private final ChangeNotes.Factory notesFactory;
   private final ProjectCache projectCache;
+  private final Set<Project.NameKey> projectsToSkip;
 
   @Inject
   AllChangesIndexer(
@@ -91,7 +97,8 @@
       @IndexExecutor(BATCH) ListeningExecutorService executor,
       ChangeIndexer.Factory indexerFactory,
       ChangeNotes.Factory notesFactory,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      @GerritServerConfig Config config) {
     this.multiProgressMonitorFactory = multiProgressMonitorFactory;
     this.changeDataFactory = changeDataFactory;
     this.repoManager = repoManager;
@@ -99,6 +106,11 @@
     this.indexerFactory = indexerFactory;
     this.notesFactory = notesFactory;
     this.projectCache = projectCache;
+    this.projectsToSkip =
+        Sets.newHashSet(config.getStringList("index", null, "excludeProjectFromChangeReindex"))
+            .stream()
+            .map(p -> Project.NameKey.parse(p))
+            .collect(Collectors.toSet());
   }
 
   @AutoValue
@@ -186,15 +198,20 @@
     return Result.create(sw, ok.get(), nDone, nFailed);
   }
 
+  /**
+   * Reindexes all changes in a given project, even if they already exist in the index. Changes will
+   * not be sliced to allow multithreaded reindexing.
+   */
   @Nullable
   public Callable<Void> reindexProject(
       ChangeIndexer indexer, Project.NameKey project, Task done, Task failed) {
     try (Repository repo = repoManager.openRepository(project)) {
-      return reindexProjectSlice(
+      return new ProjectSliceIndexer(
           indexer,
           ProjectSlice.oneSlice(project, ChangeNotes.Factory.scanChangeIds(repo)),
           done,
-          failed);
+          failed,
+          true);
     } catch (IOException e) {
       logger.atSevere().log("%s", e.getMessage());
       return null;
@@ -203,7 +220,7 @@
 
   public Callable<Void> reindexProjectSlice(
       ChangeIndexer indexer, ProjectSlice projectSlice, Task done, Task failed) {
-    return new ProjectSliceIndexer(indexer, projectSlice, done, failed);
+    return new ProjectSliceIndexer(indexer, projectSlice, done, failed, false);
   }
 
   private class ProjectSliceIndexer implements Callable<Void> {
@@ -211,46 +228,73 @@
     private final ProjectSlice projectSlice;
     private final ProgressMonitor done;
     private final ProgressMonitor failed;
+    private final boolean forceReindex;
 
     private ProjectSliceIndexer(
         ChangeIndexer indexer,
         ProjectSlice projectSlice,
         ProgressMonitor done,
-        ProgressMonitor failed) {
+        ProgressMonitor failed,
+        boolean forceReindex) {
       this.indexer = indexer;
       this.projectSlice = projectSlice;
       this.done = done;
       this.failed = failed;
+      this.forceReindex = forceReindex;
     }
 
     @Override
     public Void call() throws Exception {
-      OnlineReindexMode.begin();
-      // Order of scanning changes is undefined. This is ok if we assume that packfile locality is
-      // not important for indexing, since sites should have a fully populated DiffSummary cache.
-      // It does mean that reindexing after invalidating the DiffSummary cache will be expensive,
-      // but the goal is to invalidate that cache as infrequently as we possibly can. And besides,
-      // we don't have concrete proof that improving packfile locality would help.
-      notesFactory
-          .scan(
-              projectSlice.metaIdByChange(),
-              projectSlice.name(),
-              id -> (id.get() % projectSlice.slices()) == projectSlice.slice())
-          .forEach(r -> index(r));
-      OnlineReindexMode.end();
+      String oldThreadName = Thread.currentThread().getName();
+      try {
+        Thread.currentThread()
+            .setName(
+                oldThreadName
+                    + "["
+                    + projectSlice.name().toString()
+                    + "-"
+                    + projectSlice.slice()
+                    + "]");
+        OnlineReindexMode.begin();
+        Optional<ChangeIndex> newestIndex = indexer.getNewestIndex();
+        if (newestIndex.isEmpty()) {
+          logger.atWarning().log("No change index available yet");
+        }
+        // Order of scanning changes is undefined. This is ok if we assume that packfile locality is
+        // not important for indexing, since sites should have a fully populated DiffSummary cache.
+        // It does mean that reindexing after invalidating the DiffSummary cache will be expensive,
+        // but the goal is to invalidate that cache as infrequently as we possibly can. And besides,
+        // we don't have concrete proof that improving packfile locality would help.
+        notesFactory
+            .scan(
+                projectSlice.metaIdByChange(),
+                projectSlice.name(),
+                id -> (id.get() % projectSlice.slices()) == projectSlice.slice())
+            .forEach(r -> index(r, newestIndex));
+        OnlineReindexMode.end();
+      } finally {
+        Thread.currentThread().setName(oldThreadName);
+      }
       return null;
     }
 
-    private void index(ChangeNotesResult r) {
+    private void index(ChangeNotesResult r, Optional<ChangeIndex> newestIndex) {
       if (r.error().isPresent()) {
         fail("Failed to read change " + r.id() + " for indexing", true, r.error().get());
         return;
       }
       try {
-        indexer.index(changeDataFactory.create(r.notes()));
+        if (forceReindex || !indexer.isChangeAlreadyIndexed(r.id(), newestIndex)) {
+          indexer.index(changeDataFactory.create(r.notes()));
+          verboseWriter.format(
+              "Reindexed change %d (project: %s)\n",
+              r.id().get(), r.notes().getProjectName().get());
+
+        } else {
+          verboseWriter.format(
+              "Skipped change %d (project: %s)\n", r.id().get(), r.notes().getProjectName().get());
+        }
         done.update(1);
-        verboseWriter.format(
-            "Reindexed change %d (project: %s)\n", r.id().get(), r.notes().getProjectName().get());
       } catch (RejectedExecutionException e) {
         // Server shutdown, don't spam the logs.
         failSilently();
@@ -302,7 +346,7 @@
     }
 
     private List<ListenableFuture<?>> schedule() throws ProjectsCollectionFailure {
-      ImmutableSortedSet<Project.NameKey> projects = projectCache.all();
+      Set<Project.NameKey> projects = Sets.difference(projectCache.all(), projectsToSkip);
       int projectCount = projects.size();
       slicingProjects = mpm.beginSubTask("Slicing projects", projectCount);
       for (Project.NameKey name : projects) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 517809a..bf83a1b 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -27,6 +27,8 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.index.Index;
+import com.google.gerrit.metrics.proc.ThreadMXBeanFactory;
+import com.google.gerrit.metrics.proc.ThreadMXBeanInterface;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.StalenessCheckResult;
@@ -45,6 +47,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -62,6 +65,7 @@
  */
 public class ChangeIndexer {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final ThreadMXBeanInterface threadMxBean = ThreadMXBeanFactory.create();
 
   public interface Factory {
     ChangeIndexer create(ListeningExecutorService executor, ChangeIndex index);
@@ -218,32 +222,56 @@
   }
 
   private void indexImpl(ChangeData cd) {
-    logger.atFine().log("Reindex change %d in index.", cd.getId().get());
-    for (Index<?, ChangeData> i : getWriteIndexes()) {
-      try (TraceTimer traceTimer =
-          TraceContext.newTimer(
-              "Reindexing change in index",
-              Metadata.builder()
-                  .changeId(cd.getId().get())
-                  .patchSetId(cd.currentPatchSet().number())
-                  .indexVersion(i.getSchema().getVersion())
-                  .build())) {
-        if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
-          i.insert(cd);
-        } else {
-          i.replace(cd);
+    long memoryAtStart = 0;
+    if (logger.atFine().isEnabled()) {
+      memoryAtStart = threadMxBean.getCurrentThreadAllocatedBytes();
+      logger.atFine().log("Reindex change %d in index.", cd.getId().get());
+    }
+    try {
+      for (Index<?, ChangeData> i : getWriteIndexes()) {
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Reindexing change in index",
+                Metadata.builder()
+                    .changeId(cd.getId().get())
+                    .patchSetId(cd.currentPatchSet().number())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
+          if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
+            i.insert(cd);
+          } else {
+            i.replace(cd);
+          }
+        } catch (RuntimeException e) {
+          throw new StorageException(
+              String.format(
+                  "Failed to reindex change %d in index version %d (current patch set = %d)",
+                  cd.getId().get(), i.getSchema().getVersion(), cd.currentPatchSet().number()),
+              e);
         }
-      } catch (RuntimeException e) {
-        throw new StorageException(
-            String.format(
-                "Failed to reindex change %d in index version %d (current patch set = %d)",
-                cd.getId().get(), i.getSchema().getVersion(), cd.currentPatchSet().number()),
-            e);
+      }
+    } finally {
+      if (logger.atFine().isEnabled()) {
+        long memAllocated = threadMxBean.getCurrentThreadAllocatedBytes() - memoryAtStart;
+        logger.atFine().log(
+            "Reindexing of change %d allocated %d bytes of memory.",
+            cd.getId().get(), memAllocated);
       }
     }
     fireChangeIndexedEvent(cd.project().get(), cd.getId().get());
   }
 
+  public boolean isChangeAlreadyIndexed(Change.Id id, Optional<ChangeIndex> newestIndex) {
+    if (newestIndex.isEmpty()) {
+      return false;
+    }
+    return newestIndex.get().get(id, IndexedChangeQuery.oneResult()).isPresent();
+  }
+
+  public Optional<ChangeIndex> getNewestIndex() {
+    return getWriteIndexes().stream().max(Comparator.comparingInt(i -> i.getSchema().getVersion()));
+  }
+
   private void fireChangeScheduledForIndexingEvent(String projectName, int id) {
     indexedListeners.runEach(l -> l.onChangeScheduledForIndexing(projectName, id));
   }
diff --git a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index 00642a9..f7ff13c 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -21,6 +21,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.IndexConfig;
@@ -51,6 +52,10 @@
  */
 public class IndexedChangeQuery extends IndexedQuery<Change.Id, ChangeData>
     implements ChangeDataSource, Matchable<ChangeData> {
+  public static QueryOptions oneResult() {
+    IndexConfig config = IndexConfig.createDefault();
+    return createOptions(config, 0, 1, config.pageSizeMultiplier(), 1, ImmutableSet.of());
+  }
 
   public static QueryOptions createOptions(
       IndexConfig config, int start, int limit, Set<String> fields) {
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index 38ce3a0..bf2795d 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.server.Sequence;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -81,11 +82,11 @@
   }
 
   public static class RepoSequenceModule extends FactoryModule {
-    private static final String SECTION_NOTE_DB = "noteDb";
-    private static final String KEY_SEQUENCE_BATCH_SIZE = "sequenceBatchSize";
-    private static final int DEFAULT_ACCOUNTS_SEQUENCE_BATCH_SIZE = 1;
-    private static final int DEFAULT_GROUPS_SEQUENCE_BATCH_SIZE = 1;
-    private static final int DEFAULT_CHANGES_SEQUENCE_BATCH_SIZE = 20;
+    public static final String SECTION_NOTE_DB = "noteDb";
+    public static final String KEY_SEQUENCE_BATCH_SIZE = "sequenceBatchSize";
+    public static final int DEFAULT_ACCOUNTS_SEQUENCE_BATCH_SIZE = 1;
+    public static final int DEFAULT_GROUPS_SEQUENCE_BATCH_SIZE = 1;
+    public static final int DEFAULT_CHANGES_SEQUENCE_BATCH_SIZE = 20;
 
     @Provides
     @Named(NAME_ACCOUNTS)
@@ -173,41 +174,6 @@
     }
   }
 
-  /** A accounts sequence provider that does not fire git reference updates. */
-  public static class DisabledGitRefUpdatedRepoAccountsSequenceProvider
-      implements Provider<Sequence> {
-    private final GitRepositoryManager repoManager;
-    private final AllUsersName allUsers;
-    private final Config cfg;
-
-    @Inject
-    DisabledGitRefUpdatedRepoAccountsSequenceProvider(
-        @GerritServerConfig Config cfg,
-        GitRepositoryManager repoManager,
-        AllUsersName allUsersName) {
-      this.repoManager = repoManager;
-      this.allUsers = allUsersName;
-      this.cfg = cfg;
-    }
-
-    @Override
-    public Sequence get() {
-      int accountBatchSize =
-          cfg.getInt(
-              RepoSequenceModule.SECTION_NOTE_DB,
-              NAME_ACCOUNTS,
-              RepoSequenceModule.KEY_SEQUENCE_BATCH_SIZE,
-              RepoSequenceModule.DEFAULT_ACCOUNTS_SEQUENCE_BATCH_SIZE);
-      return new RepoSequence(
-          repoManager,
-          GitReferenceUpdated.DISABLED,
-          allUsers,
-          NAME_ACCOUNTS,
-          () -> Sequences.FIRST_ACCOUNT_ID,
-          accountBatchSize);
-    }
-  }
-
   @VisibleForTesting
   static RetryerBuilder<ImmutableList<Integer>> retryerBuilder() {
     return RetryerBuilder.<ImmutableList<Integer>>newBuilder()
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
index d812eef..9df01f4 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
@@ -25,13 +25,13 @@
 import com.google.gerrit.index.query.QueryProcessor;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexRewriter;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 2979170..305316d 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
@@ -40,7 +41,6 @@
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
index 74c8d39..e08ff1c 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
@@ -26,12 +26,12 @@
 import com.google.gerrit.index.query.QueryProcessor;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndexRewriter;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index b4946c4..c9ae7d3 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountExternalIdCreator;
 import com.google.gerrit.server.account.AccountLoader;
@@ -50,7 +51,6 @@
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.ssh.SshKeyCache;
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 8c9b7a6..4da8410 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -52,7 +53,6 @@
 import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 86bd35f..5146a97 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -56,6 +56,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
@@ -70,7 +71,6 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 3851e82..5bf0e8b 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
@@ -56,7 +57,6 @@
 import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index b1f1da5..be2fae3 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -22,7 +22,6 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
@@ -83,6 +82,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
@@ -278,7 +278,7 @@
         }
       }
 
-      Collection<ChangeData> unmergeable = unmergeableChanges(cs);
+      Collection<ChangeData> unmergeable = getUnmergeableChanges(cs);
       if (unmergeable == null) {
         return CLICK_FAILURE_TOOLTIP;
       } else if (!unmergeable.isEmpty()) {
@@ -375,36 +375,27 @@
   }
 
   @Nullable
-  public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
-    Set<ChangeData> mergeabilityMap = new HashSet<>();
-    Set<ObjectId> outDatedPatchsets = new HashSet<>();
+  public Collection<ChangeData> getUnmergeableChanges(ChangeSet cs) throws IOException {
+    Set<ChangeData> unmergeableChanges = new HashSet<>();
+    Set<ObjectId> outDatedPatchSets = new HashSet<>();
     for (ChangeData change : cs.changes()) {
-      mergeabilityMap.add(change);
-      // Add all the patchsets commit ids except the current patchset.
-      outDatedPatchsets.addAll(
-          change.notes().getPatchSets().values().stream()
-              .map(p -> p.commitId())
-              .collect(Collectors.toSet()));
-      outDatedPatchsets.remove(change.currentPatchSet().commitId());
+      unmergeableChanges.add(change);
+      addAllOutdatedPatchSets(outDatedPatchSets, change);
     }
-
     ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
     for (BranchNameKey branch : cbb.keySet()) {
       Collection<ChangeData> targetBranch = cbb.get(branch);
-      HashMap<Change.Id, RevCommit> commits = findCommits(targetBranch, branch.project());
-
-      Set<ObjectId> allParents = Sets.newHashSetWithExpectedSize(cs.size());
-      for (RevCommit commit : commits.values()) {
-        for (RevCommit parent : commit.getParents()) {
-          allParents.add(parent.getId());
-        }
-      }
+      HashMap<Change.Id, RevCommit> commits = mapToCommits(targetBranch, branch.project());
+      Set<ObjectId> allParents =
+          commits.values().stream()
+              .flatMap(c -> Arrays.stream(c.getParents()))
+              .map(RevObject::getId)
+              .collect(Collectors.toSet());
       for (ChangeData change : targetBranch) {
-
         RevCommit commit = commits.get(change.getId());
         boolean isMergeCommit = commit.getParentCount() > 1;
         boolean isLastInChain = !allParents.contains(commit.getId());
-        if (Arrays.stream(commit.getParents()).anyMatch(c -> outDatedPatchsets.contains(c.getId()))
+        if (Arrays.stream(commit.getParents()).anyMatch(c -> outDatedPatchSets.contains(c.getId()))
             && !isCherryPickSubmit(change)) {
           // Found a parent that depends on an outdated patchset and the submit strategy is not
           // cherry-pick.
@@ -421,18 +412,26 @@
           return null;
         }
         if (mergeable) {
-          mergeabilityMap.remove(change);
+          unmergeableChanges.remove(change);
         }
-
         if (isLastInChain && isMergeCommit && mergeable) {
-          for (ChangeData c : targetBranch) {
-            mergeabilityMap.remove(c);
-          }
+          targetBranch.stream().forEach(unmergeableChanges::remove);
           break;
         }
       }
     }
-    return mergeabilityMap;
+    return unmergeableChanges;
+  }
+
+  /**
+   * Add all outdated patch-sets (non-last patch-sets) to the output set {@code outdatedPatchSets}.
+   */
+  private static void addAllOutdatedPatchSets(Set<ObjectId> outdatedPatchSets, ChangeData cd) {
+    outdatedPatchSets.addAll(
+        cd.notes().getPatchSets().values().stream()
+            .map(p -> p.commitId())
+            .collect(Collectors.toSet()));
+    outdatedPatchSets.remove(cd.currentPatchSet().commitId());
   }
 
   private boolean isCherryPickSubmit(ChangeData changeData) {
@@ -440,7 +439,8 @@
     return submitTypeRecord.isOk() && submitTypeRecord.type == SubmitType.CHERRY_PICK;
   }
 
-  private HashMap<Change.Id, RevCommit> findCommits(
+  /** Map input {@code changes} to the commit SHA-1 of their latest patch-set. */
+  private HashMap<Change.Id, RevCommit> mapToCommits(
       Collection<ChangeData> changes, Project.NameKey project) throws IOException {
     HashMap<Change.Id, RevCommit> commits = new HashMap<>();
     try (Repository repo = repoManager.openRepository(project);
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index 9d36aaa..4110eff 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.CreateGroupArgs;
 import com.google.gerrit.server.account.GroupCache;
@@ -52,7 +53,6 @@
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.util.time.TimeUtil;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 458ae4d..338ff0d 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -33,11 +33,11 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index a079050..cfb9754 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.InheritableBoolean;
-import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.Sequences;
 import java.util.Optional;
 
 @AutoValue
diff --git a/java/com/google/gerrit/sshd/commands/SequenceSetCommand.java b/java/com/google/gerrit/sshd/commands/SequenceSetCommand.java
index 197d61c..3ec34bc 100644
--- a/java/com/google/gerrit/sshd/commands/SequenceSetCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SequenceSetCommand.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/sshd/commands/SequenceShowCommand.java b/java/com/google/gerrit/sshd/commands/SequenceShowCommand.java
index 490c7ca..e9058b4 100644
--- a/java/com/google/gerrit/sshd/commands/SequenceShowCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SequenceShowCommand.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 5af87e8..a1c7a00 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -133,6 +133,7 @@
 import com.google.gerrit.httpd.CacheBasedWebSession;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.Sequence;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountProperties;
@@ -155,7 +156,6 @@
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.account.StalenessChecker;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index 091d444..ee2db28 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
@@ -45,7 +46,6 @@
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 6dbbe9a..db12e85 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -84,6 +84,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupIncludeCache;
@@ -98,7 +99,6 @@
 import com.google.gerrit.server.group.db.InternalGroupCreation;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.index.group.StalenessChecker;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.GerritJUnit.ThrowingRunnable;
diff --git a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
index 27962da..d48f41d 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
@@ -40,11 +40,11 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.ProjectWatches;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
index 1143e89..0393f2b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
@@ -436,6 +436,32 @@
   }
 
   @Test
+  public void testCommentLinkWithPrefixRedirects() throws Exception {
+    int changeNumber = createChange().getChange().getId().get();
+    String commentId = "ff3303fd_8341647b";
+
+    String redirectUri =
+        String.format("/c/%s/+/%d/comment/%s", project.get(), changeNumber, commentId);
+
+    anonymousRestSession
+        .get(String.format("/c/%s/comment/%s", changeNumber, commentId))
+        .assertTemporaryRedirect(redirectUri);
+  }
+
+  @Test
+  public void testCommentLinkWithoutPrefixRedirects() throws Exception {
+    int changeNumber = createChange().getChange().getId().get();
+    String commentId = "ff3303fd_8341647b";
+
+    String redirectUri =
+        String.format("/c/%s/+/%d/comment/%s", project.get(), changeNumber, commentId);
+
+    anonymousRestSession
+        .get(String.format("/%s/comment/%s", changeNumber, commentId))
+        .assertTemporaryRedirect(redirectUri);
+  }
+
+  @Test
   public void testNumericChangeIdRedirectWithoutPrefix() throws Exception {
     int changeNumber = createChange().getChange().getId().get();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index 0a9a098..fe220f2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -310,7 +310,7 @@
         mergeSuperSet
             .get()
             .completeChangeSet(change.change(), user(admin), /* includingTopicClosure= */ false);
-    assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
+    assertThat(submit.getUnmergeableChanges(cs).isEmpty()).isEqualTo(expected);
   }
 
   private void assertMergeable(ChangeData change) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index 35ecceb..b150491 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountResolver;
@@ -39,7 +40,6 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 55f102f..07e4866 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -38,13 +38,13 @@
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ConsistencyChecker;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java
index f4dc798..4529f72 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -30,7 +31,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
index 48fd38c..4241511 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.concurrent.atomic.AtomicInteger;
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index c90f5d4..c7b1299 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -107,6 +107,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
@@ -132,7 +133,6 @@
 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;
 import com.google.gerrit.server.schema.SchemaCreator;
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index e6a6497..6c79c43 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -30,11 +30,11 @@
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.GroupUuid;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.Config;
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index ff1f6a3..f7a2afa 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.change.AbandonOp;
@@ -50,7 +51,6 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.update.context.RefUpdateContext;
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 7a4caa7..3b1a824 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
@@ -546,7 +546,9 @@
       allowConflicts: !!this.rebaseAllowConflicts?.checked,
       rebaseChain: !!this.rebaseChain?.checked,
       onBehalfOfUploader: this.rebaseOnBehalfOfUploader(),
-      committerEmail: this.selectedEmailForRebase || null,
+      committerEmail: this.rebaseChain?.checked
+        ? null
+        : this.selectedEmailForRebase || null,
     };
     fireNoBubbleNoCompose(this, 'confirm-rebase', 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 e6326b3..038fcd5 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
@@ -389,6 +389,49 @@
     );
   });
 
+  test('committer email is sent when chain is not rebased', async () => {
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+    element.text = '123';
+    element.selectedEmailForRebase = 'abc@def.com';
+    await element.updateComplete;
+    queryAndAssert(element, '#confirmDialog').dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      allowConflicts: false,
+      base: '123',
+      rebaseChain: false,
+      onBehalfOfUploader: true,
+      committerEmail: 'abc@def.com',
+    });
+  });
+
+  test('committer email is not sent when chain is rebased', async () => {
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+    element.text = '123';
+    element.selectedEmailForRebase = 'abc@def.com';
+    element.hasParent = true;
+    element.shouldRebaseChain = true;
+    await element.updateComplete;
+    queryAndAssert<HTMLInputElement>(element, '#rebaseChain').checked = true;
+    queryAndAssert(element, '#confirmDialog').dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      allowConflicts: false,
+      base: '123',
+      rebaseChain: true,
+      onBehalfOfUploader: true,
+      committerEmail: null,
+    });
+  });
+
   test('input cleared on cancel or submit', async () => {
     element.text = '123';
     await element.updateComplete;
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 ceccdda..5131083 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
@@ -209,7 +209,16 @@
   ) {
     // Figure out what the revert title should be.
     const originalTitle = (commitMessage || '').split('\n')[0];
-    const revertTitle = `Revert "${originalTitle}"`;
+    let revertTitle = `Revert "${originalTitle}"`;
+    const match = originalTitle.match(/^Revert(?:\^([0-9]+))? "(.*)"$/);
+    if (match) {
+      let revertNum = 2;
+      if (match[1]) {
+        revertNum = Number(match[1]) + 1;
+      }
+      revertTitle = `Revert^${revertNum} "${match[2]}"`;
+    }
+
     if (!commitHash) {
       fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index 8d71e15..920ff00 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -105,7 +105,21 @@
       'abcd123' as CommitId
     );
     const expected =
-      'Revert "Revert "one line commit""\n\n' +
+      'Revert^2 "one line commit"\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('revert a revert of a revert', () => {
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
+      createParsedChange(),
+      'Revert^2 "one line commit"\n\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert^3 "one line commit"\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
     assert.equal(element.message, expected);
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 b58f2fa..a0ad2f0 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
@@ -130,6 +130,7 @@
 import {GrReviewerUpdatesParser} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {formStyles} from '../../../styles/form-styles';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {getDocUrl} from '../../../utils/url-util';
 
 export enum FocusTarget {
   ANY = 'any',
@@ -208,6 +209,8 @@
 
   @state() serverConfig?: ServerInfo;
 
+  @state() private docsBaseUrl = '';
+
   @state()
   patchsetLevelDraftMessage = '';
 
@@ -618,6 +621,11 @@
     );
     subscribe(
       this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().change$,
       x => (this.change = x)
     );
@@ -1009,7 +1017,7 @@
           <div>
             ${this.renderModifyAttentionSetButton()}
             <a
-              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              href=${getDocUrl(this.docsBaseUrl, 'user-attention-set.html')}
               target="_blank"
               rel="noopener noreferrer"
             >
@@ -1057,7 +1065,7 @@
           <div>
             ${this.renderModifyAttentionSetButton()}
             <a
-              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              href=${getDocUrl(this.docsBaseUrl, 'user-attention-set.html')}
               target="_blank"
               rel="noopener noreferrer"
             >
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 f7e267c..500aa63 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
@@ -276,7 +276,7 @@
                       </div>
                     </gr-button>
                     <a
-                      href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+                      href="/Documentation/user-attention-set.html"
                       target="_blank"
                       rel="noopener noreferrer"
                     >
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 a55173c..34950b0 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
@@ -8,7 +8,7 @@
 import '../../shared/gr-icon/gr-icon';
 import '../gr-account-dropdown/gr-account-dropdown';
 import '../gr-smart-search/gr-smart-search';
-import {getBaseUrl} from '../../../utils/url-util';
+import {getBaseUrl, getDocUrl} from '../../../utils/url-util';
 import {getAdminLinks, NavLink} from '../../../models/views/admin';
 import {
   AccountDetailInfo,
@@ -85,6 +85,18 @@
   },
 ];
 
+// visible for testing
+export function getDocLinks(docBaseUrl: string, docLinks: MainHeaderLink[]) {
+  if (!docBaseUrl) return [];
+  return docLinks.map(link => {
+    return {
+      url: getDocUrl(docBaseUrl, link.url),
+      name: link.name,
+      target: '_blank',
+    };
+  });
+}
+
 // Set of authentication methods that can provide custom registration page.
 const AUTH_TYPES_WITH_REGISTER_URL: Set<AuthType> = new Set([
   AuthType.LDAP,
@@ -121,7 +133,7 @@
 
   @state() private adminLinks: NavLink[] = [];
 
-  @state() private docBaseUrl: string | null = null;
+  @state() private docsBaseUrl = '';
 
   @state() private userLinks: MainHeaderLink[] = [];
 
@@ -165,7 +177,7 @@
     subscribe(
       this,
       () => this.getConfigModel().docsBaseUrl$,
-      docsBaseUrl => (this.docBaseUrl = docsBaseUrl)
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
     );
     subscribe(
       this,
@@ -359,12 +371,9 @@
       </gr-endpoint-decorator>
     </a>
     <ul class="links">
-      ${this.computeLinks(
-        this.userLinks,
-        this.adminLinks,
-        this.topMenus,
-        this.docBaseUrl
-      ).map(linkGroup => this.renderLinkGroup(linkGroup))}
+      ${this.computeLinks(this.userLinks, this.adminLinks, this.topMenus).map(
+        linkGroup => this.renderLinkGroup(linkGroup)
+      )}
     </ul>
     <div class="rightItems">
       <gr-endpoint-decorator
@@ -488,15 +497,13 @@
     userLinks?: MainHeaderLink[],
     adminLinks?: NavLink[],
     topMenus?: TopMenuEntryInfo[],
-    docBaseUrl?: string | null,
     // defaultLinks parameter is used in tests only
     defaultLinks = DEFAULT_LINKS
   ) {
     if (
       userLinks === undefined ||
       adminLinks === undefined ||
-      topMenus === undefined ||
-      docBaseUrl === undefined
+      topMenus === undefined
     ) {
       return [];
     }
@@ -513,7 +520,7 @@
         links: userLinks.slice(),
       });
     }
-    const docLinks = this.getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+    const docLinks = getDocLinks(this.docsBaseUrl, DOCUMENTATION_LINKS);
     if (docLinks.length) {
       links.push({
         title: 'Documentation',
@@ -550,24 +557,6 @@
   }
 
   // private but used in test
-  getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
-    if (!docBaseUrl) {
-      return [];
-    }
-    return docLinks.map(link => {
-      let url = docBaseUrl;
-      if (url && url[url.length - 1] === '/') {
-        url = url.substring(0, url.length - 1);
-      }
-      return {
-        url: url + link.url,
-        name: link.name,
-        target: '_blank',
-      };
-    });
-  }
-
-  // private but used in test
   loadAccount() {
     this.loading = true;
 
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 4b9c313..dfb44b70 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
@@ -11,7 +11,7 @@
   stubRestApi,
 } from '../../../test/test-utils';
 import './gr-main-header';
-import {GrMainHeader} from './gr-main-header';
+import {GrMainHeader, getDocLinks} from './gr-main-header';
 import {
   createAccountDetailWithId,
   createGerritInfo,
@@ -51,6 +51,11 @@
                 <span class="linksTitle" id="Changes"> Changes </span>
               </gr-dropdown>
             </li>
+            <li class="hideOnMobile">
+              <gr-dropdown down-arrow="" horizontal-align="left" link="">
+                <span class="linksTitle" id="Documentation">Documentation</span>
+              </gr-dropdown>
+            </li>
             <li>
               <gr-dropdown down-arrow="" horizontal-align="left" link="">
                 <span class="linksTitle" id="Browse"> Browse </span>
@@ -168,36 +173,24 @@
 
     // When no admin links are passed, it should use the default.
     assert.deepEqual(
-      element.computeLinks(
-        /* userLinks= */ [],
-        adminLinks,
-        /* topMenus= */ [],
-        /* docBaseUrl= */ '',
-        defaultLinks
-      ),
-      defaultLinks.concat({
-        title: 'Browse',
-        links: adminLinks,
-      })
+      element
+        .computeLinks(
+          /* userLinks= */ [],
+          adminLinks,
+          /* topMenus= */ [],
+          defaultLinks
+        )
+        .find(i => i.title === 'Faves'),
+      defaultLinks[0]
     );
     assert.deepEqual(
-      element.computeLinks(
-        userLinks,
-        adminLinks,
-        /* topMenus= */ [],
-        /* docBaseUrl= */ '',
-        defaultLinks
-      ),
-      defaultLinks.concat([
-        {
-          title: 'Your',
-          links: userLinks,
-        },
-        {
-          title: 'Browse',
-          links: adminLinks,
-        },
-      ])
+      element
+        .computeLinks(userLinks, adminLinks, /* topMenus= */ [], defaultLinks)
+        .find(i => i.title === 'Your'),
+      {
+        title: 'Your',
+        links: userLinks,
+      }
     );
   });
 
@@ -209,11 +202,10 @@
       },
     ];
 
-    assert.deepEqual(element.getDocLinks(null, docLinks), []);
-    assert.deepEqual(element.getDocLinks('', docLinks), []);
-    assert.deepEqual(element.getDocLinks('base', []), []);
+    assert.deepEqual(getDocLinks('', docLinks), []);
+    assert.deepEqual(getDocLinks('base', []), []);
 
-    assert.deepEqual(element.getDocLinks('base', docLinks), [
+    assert.deepEqual(getDocLinks('base', docLinks), [
       {
         name: 'Table of Contents',
         target: '_blank',
@@ -221,7 +213,7 @@
       },
     ]);
 
-    assert.deepEqual(element.getDocLinks('base/', docLinks), [
+    assert.deepEqual(getDocLinks('base/', docLinks), [
       {
         name: 'Table of Contents',
         target: '_blank',
@@ -255,24 +247,17 @@
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ '',
         /* defaultLinks= */ []
-      ),
-      [
-        {
-          title: 'Browse',
-          links: adminLinks,
-        },
-        {
-          title: 'Plugins',
-          links: [
-            {
-              name: 'Manage',
-              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-            },
-          ],
-        },
-      ]
+      )[2],
+      {
+        title: 'Plugins',
+        links: [
+          {
+            name: 'Manage',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      }
     );
   });
 
@@ -306,24 +291,17 @@
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ '',
         /* defaultLinks= */ []
-      ),
-      [
-        {
-          title: 'Browse',
-          links: adminLinks,
-        },
-        {
-          title: 'Projects',
-          links: [
-            {
-              name: 'Project List',
-              url: '/plugins/myplugin/index.html',
-            },
-          ],
-        },
-      ]
+      )[2],
+      {
+        title: 'Projects',
+        links: [
+          {
+            name: 'Project List',
+            url: '/plugins/myplugin/index.html',
+          },
+        ],
+      }
     );
   });
 
@@ -362,28 +340,21 @@
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ '',
         /* defaultLinks= */ []
-      ),
-      [
-        {
-          title: 'Browse',
-          links: adminLinks,
-        },
-        {
-          title: 'Plugins',
-          links: [
-            {
-              name: 'Manage',
-              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-            },
-            {
-              name: 'Create',
-              url: 'https://gerrit/plugins/plugin-manager/static/create.html',
-            },
-          ],
-        },
-      ]
+      )[2],
+      {
+        title: 'Plugins',
+        links: [
+          {
+            name: 'Manage',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+          {
+            name: 'Create',
+            url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+          },
+        ],
+      }
     );
   });
 
@@ -416,24 +387,17 @@
         /* userLinks= */ [],
         /* adminLinks= */ [],
         topMenus,
-        /* baseDocUrl= */ '',
         defaultLinks
-      ),
-      [
-        {
-          title: 'Faves',
-          links: defaultLinks[0].links.concat([
-            {
-              name: 'Manage',
-              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-            },
-          ]),
-        },
-        {
-          title: 'Browse',
-          links: [],
-        },
-      ]
+      )[0],
+      {
+        title: 'Faves',
+        links: defaultLinks[0].links.concat([
+          {
+            name: 'Manage',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ]),
+      }
     );
   });
 
@@ -462,29 +426,22 @@
         userLinks,
         /* adminLinks= */ [],
         topMenus,
-        /* baseDocUrl= */ '',
         /* defaultLinks= */ []
-      ),
-      [
-        {
-          title: 'Your',
-          links: [
-            {
-              name: 'Facebook',
-              url: 'https://facebook.com',
-              target: '',
-            },
-            {
-              name: 'Manage',
-              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-            },
-          ],
-        },
-        {
-          title: 'Browse',
-          links: [],
-        },
-      ]
+      )[0],
+      {
+        title: 'Your',
+        links: [
+          {
+            name: 'Facebook',
+            url: 'https://facebook.com',
+            target: '',
+          },
+          {
+            name: 'Manage',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      }
     );
   });
 
@@ -513,21 +470,18 @@
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ '',
         /* defaultLinks= */ []
-      ),
-      [
-        {
-          title: 'Browse',
-          links: [
-            adminLinks[0],
-            {
-              name: 'Manage',
-              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-            },
-          ],
-        },
-      ]
+      )[1],
+      {
+        title: 'Browse',
+        links: [
+          adminLinks[0],
+          {
+            name: 'Manage',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      }
     );
   });
 
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index a0187f9..98e9eba 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -30,6 +30,7 @@
   ValueChangedEvent,
 } from '../../../types/events';
 import {fireNoBubbleNoCompose} from '../../../utils/event-util';
+import {getDocUrl} from '../../../utils/url-util';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -168,7 +169,7 @@
   @state() inputVal = '';
 
   // private but used in test
-  @state() docsBaseUrl: string | null = null;
+  @state() docsBaseUrl = '';
 
   @state() private query: AutocompleteQuery;
 
@@ -240,7 +241,7 @@
           <a
             class="help"
             slot="suffix"
-            href=${this.computeHelpDocLink()}
+            href=${getDocUrl(this.docsBaseUrl, 'user-search.html')}
             target="_blank"
             rel="noopener noreferrer"
             tabindex="-1"
@@ -276,18 +277,6 @@
     return set;
   }
 
-  // private but used in test
-  computeHelpDocLink() {
-    // fallback to gerrit's official doc
-    let baseUrl =
-      this.docsBaseUrl ||
-      'https://gerrit-review.googlesource.com/Documentation/';
-    if (baseUrl.endsWith('/')) {
-      baseUrl = baseUrl.substring(0, baseUrl.length - 1);
-    }
-    return `${baseUrl}/user-search.html`;
-  }
-
   private handleInputCommit(e: AutocompleteCommitEvent) {
     this.preventDefaultAndNavigateToInputVal(e);
   }
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
index 2f955de..f67024f 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
@@ -91,23 +91,6 @@
     );
   });
 
-  test('falls back to gerrit docs url', async () => {
-    const configWithoutDocsUrl = createServerInfo();
-    configWithoutDocsUrl.gerrit.doc_url = undefined;
-
-    configModel.updateServerConfig(configWithoutDocsUrl);
-    await waitUntilObserved(
-      configModel.docsBaseUrl$,
-      docsBaseUrl => docsBaseUrl === 'https://mydocumentationurl.google.com/'
-    );
-    await element.updateComplete;
-
-    assert.equal(
-      queryAndAssert<HTMLAnchorElement>(element, 'a')!.href,
-      'https://mydocumentationurl.google.com/user-search.html'
-    );
-  });
-
   test('value is propagated to inputVal', async () => {
     element.value = 'foo';
     await element.updateComplete;
@@ -303,29 +286,4 @@
       });
     });
   });
-
-  suite('doc url', () => {
-    setup(async () => {
-      element = await fixture(html`<gr-search-bar></gr-search-bar>`);
-    });
-
-    test('compute help doc url with correct path', async () => {
-      element.docsBaseUrl = 'https://doc.com/';
-      await element.updateComplete;
-      assert.equal(
-        element.computeHelpDocLink(),
-        'https://doc.com/user-search.html'
-      );
-    });
-
-    test('compute help doc url fallback to gerrit url', async () => {
-      element.docsBaseUrl = null;
-      await element.updateComplete;
-      assert.equal(
-        element.computeHelpDocLink(),
-        'https://gerrit-review.googlesource.com/Documentation/' +
-          'user-search.html'
-      );
-    });
-  });
 });
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 da6918b..e25b738 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
@@ -23,6 +23,10 @@
 import {when} from 'lit/directives/when.js';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {formStyles} from '../../../styles/form-styles';
+import {getDocUrl} from '../../../utils/url-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
 
 @customElement('gr-account-info')
 export class GrAccountInfo extends LitElement {
@@ -59,8 +63,12 @@
 
   @state() private avatarChangeUrl = '';
 
+  @state() private docsBaseUrl = '';
+
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   static override get styles() {
     return [
       sharedStyles,
@@ -100,6 +108,15 @@
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
+  }
+
   override render() {
     if (!this.account || this.loading) return nothing;
     return html`<div class="gr-form-styles">
@@ -107,8 +124,7 @@
         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"
+        <a href=${getDocUrl(this.docsBaseUrl, 'user-privacy.html')}
           >Learn more</a
         >
       </p>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 7f1595d..4d4834e 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -69,15 +69,9 @@
 } from '../../../models/user/user-model';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {rootUrl} from '../../../utils/url-util';
+import {getDocUrl, rootUrl} from '../../../utils/url-util';
 import {configModelToken} from '../../../models/config/config-model';
 
-const GERRIT_DOCS_BASE_URL =
-  'https://gerrit-review.googlesource.com/' + 'Documentation';
-const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
-const ABSOLUTE_URL_PATTERN = /^https?:/;
-const TRAILING_SLASH_PATTERN = /\/$/;
-
 const HTTP_AUTH = ['HTTP', 'HTTP_LDAP'];
 
 enum CopyPrefsDirection {
@@ -188,9 +182,6 @@
   // private but used in test
   @state() serverConfig?: ServerInfo;
 
-  // private but used in test
-  @state() docsBaseUrl?: string | null;
-
   @state() private emailsChanged = false;
 
   // private but used in test
@@ -203,6 +194,8 @@
 
   @state() isDeletingAccount = false;
 
+  @state() private docsBaseUrl = '';
+
   // private but used in test
   public _testOnly_loadingPromise?: Promise<void>;
 
@@ -210,8 +203,6 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly getConfigModel = resolve(this, configModelToken);
-
   // private but used in test
   readonly flagsService = getAppContext().flagsService;
 
@@ -219,6 +210,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   constructor() {
     super();
     subscribe(
@@ -238,11 +231,6 @@
     );
     subscribe(
       this,
-      () => this.getConfigModel().docsBaseUrl$,
-      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
-    );
-    subscribe(
-      this,
       () => this.getUserModel().preferences$,
       prefs => {
         if (!prefs) {
@@ -255,6 +243,11 @@
         this.localChangeTableColumns = changeTablePrefs(prefs);
       }
     );
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
   }
 
   // private, but used in tests
@@ -848,7 +841,10 @@
             >Allow browser notifications</label
           >
           <a
-            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html#_browser_notifications"
+            href=${getDocUrl(
+              this.docsBaseUrl,
+              'user-attention-set.html#_browser_notifications'
+            )}
             target="_blank"
             rel="noopener noreferrer"
           >
@@ -1171,19 +1167,6 @@
   }
 
   // private but used in test
-  getFilterDocsLink(docsBaseUrl?: string | null) {
-    let base = docsBaseUrl;
-    if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
-      base = GERRIT_DOCS_BASE_URL;
-    }
-
-    // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
-    base = base.replace(TRAILING_SLASH_PATTERN, '');
-
-    return base + GERRIT_DOCS_FILTER_PATH;
-  }
-
-  // private but used in test
   showHttpAuth() {
     if (this.serverConfig?.auth?.git_basic_auth_policy) {
       return HTTP_AUTH.includes(
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index 30a2922..f9b1738 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -121,10 +121,6 @@
   });
 
   test('renders', async () => {
-    sinon
-      .stub(element, 'getFilterDocsLink')
-      .returns('https://test.com/user-notify.html');
-    element.docsBaseUrl = 'https://test.com';
     await element.updateComplete;
     // this cannot be formatted with /* HTML */, because it breaks test
     assert.shadowDom.equal(
@@ -473,7 +469,7 @@
             Allow browser notifications
           </label>
           <a
-            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html#_browser_notifications"
+            href="/Documentation/user-attention-set.html#_browser_notifications"
             target="_blank"
             rel="noopener noreferrer"
           >
@@ -777,45 +773,6 @@
     assert.isFalse(element.showHttpAuth());
   });
 
-  suite('getFilterDocsLink', () => {
-    test('with http: docs base URL', () => {
-      const base = 'http://example.com/';
-      const result = element.getFilterDocsLink(base);
-      assert.equal(result, 'http://example.com/user-notify.html');
-    });
-
-    test('with http: docs base URL without slash', () => {
-      const base = 'http://example.com';
-      const result = element.getFilterDocsLink(base);
-      assert.equal(result, 'http://example.com/user-notify.html');
-    });
-
-    test('with https: docs base URL', () => {
-      const base = 'https://example.com/';
-      const result = element.getFilterDocsLink(base);
-      assert.equal(result, 'https://example.com/user-notify.html');
-    });
-
-    test('without docs base URL', () => {
-      const result = element.getFilterDocsLink(null);
-      assert.equal(
-        result,
-        'https://gerrit-review.googlesource.com/' +
-          'Documentation/user-notify.html'
-      );
-    });
-
-    test('ignores non HTTP links', () => {
-      const base = 'javascript://alert("evil");';
-      const result = element.getFilterDocsLink(base);
-      assert.equal(
-        result,
-        'https://gerrit-review.googlesource.com/' +
-          'Documentation/user-notify.html'
-      );
-    });
-  });
-
   suite('when email verification token is provided', () => {
     let resolveConfirm: (
       value: string | PromiseLike<string | null> | null
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
index c65a1fe..01e8a87 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
@@ -48,6 +48,7 @@
 } from '../../../models/views/dashboard';
 import {fire, fireReload} from '../../../utils/event-util';
 import {userModelToken} from '../../../models/user/user-model';
+import {getDocUrl} from '../../../utils/url-util';
 
 @customElement('gr-hovercard-account-contents')
 export class GrHovercardAccountContents extends LitElement {
@@ -76,6 +77,8 @@
   @state()
   serverConfig?: ServerInfo;
 
+  @state() private docsBaseUrl = '';
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
@@ -98,6 +101,11 @@
         this.serverConfig = config;
       }
     );
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
   }
 
   static override get styles() {
@@ -310,7 +318,7 @@
           ></gr-icon>
           <span> ${this.computePronoun()} turn to take action. </span>
           <a
-            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+            href=${getDocUrl(this.docsBaseUrl, 'user-attention-set.html')}
             target="_blank"
             rel="noopener noreferrer"
           >
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
index f0e41b2..6322123 100644
--- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
@@ -8,8 +8,12 @@
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
 import {css, html, LitElement, nothing} from 'lit';
-import {customElement} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
 import {fire} from '../../../utils/event-util';
+import {getDocUrl} from '../../../utils/url-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
 
 declare global {
   interface HTMLElementEventMap {
@@ -25,8 +29,17 @@
 
 @customElement('gr-user-suggestion-fix')
 export class GrUserSuggestionsFix extends LitElement {
+  @state() private docsBaseUrl = '';
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   constructor() {
     super();
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
   }
 
   static override get styles() {
@@ -58,7 +71,7 @@
         <div class="title">
           <span>Suggested edit</span>
           <a
-            href="https://gerrit-review.googlesource.com/Documentation/user-suggest-edits.html"
+            href=${getDocUrl(this.docsBaseUrl, 'user-suggest-edits.html')}
             target="_blank"
             rel="noopener noreferrer"
             ><gr-icon icon="help" title="read documentation"></gr-icon
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
index 91e9162..b7d73b3 100644
--- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
@@ -44,7 +44,7 @@
           <div class="title">
             <span>Suggested edit</span>
             <a
-              href="https://gerrit-review.googlesource.com/Documentation/user-suggest-edits.html"
+              href="/Documentation/user-suggest-edits.html"
               rel="noopener noreferrer"
               target="_blank"
               ><gr-icon icon="help" title="read documentation"></gr-icon
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
index f2f85af..00a1d65 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -13,9 +13,6 @@
   :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
     display: none;
   }
-  :host(.disable-context-control-buttons) {
-    --context-control-display: none;
-  }
   :host(.disable-context-control-buttons) .section {
     border-right: none;
   }
@@ -376,7 +373,7 @@
 
   /* Context controls */
   .contextControl {
-    display: var(--context-control-display, table-row-group);
+    display: table-row-group;
     background-color: transparent;
     border: none;
     --divider-height: var(--spacing-s);
@@ -405,6 +402,16 @@
     height: calc(var(--line-height-normal) + var(--spacing-s));
   }
 
+  /* Hide the actual context control buttons */
+  :host(.disable-context-control-buttons) .contextControl gr-context-controls {
+    display: none;
+  }
+  /* Maintain a small amount of padding at the edges of diff chunks */
+  :host(.disable-context-control-buttons) .contextControl .contextBackground {
+    height: var(--spacing-s);
+    border-right: none;
+  }
+
   .dividerCell {
     vertical-align: top;
   }
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
index 66ee2e8..dd7828b6 100644
--- a/polygerrit-ui/app/models/config/config-model.ts
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -11,7 +11,10 @@
 import {select} from '../../utils/observable-util';
 import {Model} from '../base/model';
 import {define} from '../dependency';
-import {loginUrl} from '../../utils/url-util';
+import {getBaseUrl, loginUrl} from '../../utils/url-util';
+
+export const PROBE_PATH = '/Documentation/index.html';
+export const DOCS_BASE_PATH = '/Documentation';
 
 export interface ConfigState {
   repoConfig?: ConfigInfo;
@@ -56,9 +59,7 @@
 
   public docsBaseUrl$ = select(
     this.serverConfig$.pipe(
-      switchMap(serverConfig =>
-        from(this.restApiService.getDocsBaseUrl(serverConfig))
-      )
+      switchMap(serverConfig => from(this.getDocsBaseUrl(serverConfig)))
     ),
     url => url
   );
@@ -86,6 +87,16 @@
   }
 
   // visible for testing
+  async getDocsBaseUrl(config: ServerInfo | undefined): Promise<string> {
+    if (config?.gerrit?.doc_url) return config.gerrit.doc_url;
+
+    const ok = await this.restApiService.probePath(getBaseUrl() + PROBE_PATH);
+    if (ok) return getBaseUrl() + DOCS_BASE_PATH;
+
+    return 'https://gerrit-review.googlesource.com/Documentation';
+  }
+
+  // visible for testing
   updateRepoConfig(repoConfig?: ConfigInfo) {
     this.updateState({repoConfig});
   }
diff --git a/polygerrit-ui/app/models/config/config-model_test.ts b/polygerrit-ui/app/models/config/config-model_test.ts
new file mode 100644
index 0000000..b78a933
--- /dev/null
+++ b/polygerrit-ui/app/models/config/config-model_test.ts
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {assert} from '@open-wc/testing';
+import {getBaseUrl} from '../../utils/url-util';
+import {
+  createGerritInfo,
+  createServerInfo,
+} from '../../test/test-data-generators';
+import {ConfigModel} from './config-model';
+import {testResolver} from '../../test/common-test-setup';
+import {getAppContext} from '../../services/app-context';
+import {changeModelToken} from '../change/change-model';
+import {ServerInfo} from '../../api/rest-api';
+
+suite('getDocsBaseUrl tests', () => {
+  let model: ConfigModel;
+
+  setup(async () => {
+    model = new ConfigModel(
+      testResolver(changeModelToken),
+      getAppContext().restApiService
+    );
+  });
+
+  test('null config', async () => {
+    const probePathMock = sinon
+      .stub(model.restApiService, 'probePath')
+      .resolves(true);
+    const docsBaseUrl = await model.getDocsBaseUrl(undefined);
+    assert.equal(
+      probePathMock.lastCall.args[0],
+      `${getBaseUrl()}/Documentation/index.html`
+    );
+    assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
+  });
+
+  test('no doc config', async () => {
+    const probePathMock = sinon
+      .stub(model.restApiService, 'probePath')
+      .resolves(true);
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      gerrit: createGerritInfo(),
+    };
+    const docsBaseUrl = await model.getDocsBaseUrl(config);
+    assert.equal(
+      probePathMock.lastCall.args[0],
+      `${getBaseUrl()}/Documentation/index.html`
+    );
+    assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
+  });
+
+  test('has doc config', async () => {
+    const probePathMock = sinon
+      .stub(model.restApiService, 'probePath')
+      .resolves(true);
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      gerrit: {...createGerritInfo(), doc_url: 'foobar'},
+    };
+    const docsBaseUrl = await model.getDocsBaseUrl(config);
+    assert.isFalse(probePathMock.called);
+    assert.equal(docsBaseUrl, 'foobar');
+  });
+
+  test('no probe', async () => {
+    const probePathMock = sinon
+      .stub(model.restApiService, 'probePath')
+      .resolves(false);
+    const docsBaseUrl = await model.getDocsBaseUrl(undefined);
+    assert.equal(
+      probePathMock.lastCall.args[0],
+      `${getBaseUrl()}/Documentation/index.html`
+    );
+    assert.equal(
+      docsBaseUrl,
+      'https://gerrit-review.googlesource.com/Documentation'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/models/views/documentation.ts b/polygerrit-ui/app/models/views/documentation.ts
index ac844fc..118fdf9 100644
--- a/polygerrit-ui/app/models/views/documentation.ts
+++ b/polygerrit-ui/app/models/views/documentation.ts
@@ -14,6 +14,10 @@
   filter: string;
 }
 
+/**
+ * This is just for documentation *searches*, not for static documentation
+ * URLs. See `getDocUrl()` in url-util.ts.
+ */
 export function createDocumentationUrl() {
   return `${getBaseUrl()}/Documentation`;
 }
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 acc7cdd..2350594 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
@@ -151,8 +151,6 @@
 import {FlagsService, KnownExperimentId} from '../flags/flags';
 
 const MAX_PROJECT_RESULTS = 25;
-export const PROBE_PATH = '/Documentation/index.html';
-export const DOCS_BASE_PATH = '/Documentation';
 
 const Requests = {
   SEND_DIFF_DRAFT: 'sendDiffDraft',
@@ -290,8 +288,6 @@
 
   readonly _etags = grEtagDecorator; // Shared across instances.
 
-  getDocsBaseUrlCachedPromise: Promise<string | null> | undefined;
-
   // readonly, but set in tests.
   _projectLookup = projectLookup; // Shared across instances.
 
@@ -3399,26 +3395,6 @@
     }) as Promise<DashboardInfo | undefined>;
   }
 
-  /**
-   * Get the docs base URL from either the server config or by probing.
-   *
-   * @return A promise that resolves with the docs base URL.
-   */
-  getDocsBaseUrl(config: ServerInfo | undefined): Promise<string | null> {
-    if (!this.getDocsBaseUrlCachedPromise) {
-      this.getDocsBaseUrlCachedPromise = new Promise(resolve => {
-        if (config?.gerrit?.doc_url) {
-          resolve(config.gerrit.doc_url);
-        } else {
-          this.probePath(getBaseUrl() + PROBE_PATH).then(ok => {
-            resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null);
-          });
-        }
-      });
-    }
-    return this.getDocsBaseUrlCachedPromise;
-  }
-
   getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> {
     filter = filter.trim();
     const encodedFilter = encodeURIComponent(filter);
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
index 624bcb5..1e1439d 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -17,7 +17,6 @@
   createAccountDetailWithId,
   createChange,
   createComment,
-  createGerritInfo,
   createParsedChange,
   createServerInfo,
 } from '../../test/test-data-generators';
@@ -54,7 +53,6 @@
   RevisionId,
   RevisionPatchSetNum,
   RobotCommentInfo,
-  ServerInfo,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../types/common';
@@ -1629,51 +1627,4 @@
       anonymizedUrl: '/accounts/self/starred.changes/*',
     });
   });
-
-  suite('getDocsBaseUrl tests', () => {
-    test('null config', async () => {
-      const probePathMock = sinon.stub(element, 'probePath').resolves(true);
-      const docsBaseUrl = await element.getDocsBaseUrl(undefined);
-      assert.equal(
-        probePathMock.lastCall.args[0],
-        `${getBaseUrl()}/Documentation/index.html`
-      );
-      assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
-    });
-
-    test('no doc config', async () => {
-      const probePathMock = sinon.stub(element, 'probePath').resolves(true);
-      const config: ServerInfo = {
-        ...createServerInfo(),
-        gerrit: createGerritInfo(),
-      };
-      const docsBaseUrl = await element.getDocsBaseUrl(config);
-      assert.equal(
-        probePathMock.lastCall.args[0],
-        `${getBaseUrl()}/Documentation/index.html`
-      );
-      assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
-    });
-
-    test('has doc config', async () => {
-      const probePathMock = sinon.stub(element, 'probePath').resolves(true);
-      const config: ServerInfo = {
-        ...createServerInfo(),
-        gerrit: {...createGerritInfo(), doc_url: 'foobar'},
-      };
-      const docsBaseUrl = await element.getDocsBaseUrl(config);
-      assert.isFalse(probePathMock.called);
-      assert.equal(docsBaseUrl, 'foobar');
-    });
-
-    test('no probe', async () => {
-      const probePathMock = sinon.stub(element, 'probePath').resolves(false);
-      const docsBaseUrl = await element.getDocsBaseUrl(undefined);
-      assert.equal(
-        probePathMock.lastCall.args[0],
-        `${getBaseUrl()}/Documentation/index.html`
-      );
-      assert.isNotOk(docsBaseUrl);
-    });
-  });
 });
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index c70f780..c96e978 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -371,8 +371,6 @@
     endpoint: string
   ): Promise<string>;
 
-  getDocsBaseUrl(config?: ServerInfo): Promise<string | null>;
-
   createChange(
     repo: RepoName,
     branch: BranchName,
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index e0a1682..7969264 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -77,11 +77,6 @@
   createDefaultPreferences,
 } from '../../constants/constants';
 import {ParsedChangeInfo} from '../../types/types';
-import {getBaseUrl} from '../../utils/url-util';
-import {
-  DOCS_BASE_PATH,
-  PROBE_PATH,
-} from '../../services/gr-rest-api/gr-rest-api-impl';
 
 export const grRestApiMock: RestApiService = {
   addAccountEmail(): Promise<Response> {
@@ -317,16 +312,6 @@
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     return Promise.resolve({}) as any;
   },
-  getDocsBaseUrl(config?: ServerInfo): Promise<string | null> {
-    if (config?.gerrit?.doc_url) {
-      return Promise.resolve(config.gerrit.doc_url);
-    } else {
-      return this.probePath(getBaseUrl() + PROBE_PATH).then(ok =>
-        Promise.resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null)
-      );
-    }
-    return Promise.resolve('');
-  },
   getDocumentationSearches(): Promise<DocResult[] | undefined> {
     return Promise.resolve([]);
   },
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 3d1bf7e..96edc7e 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -17,6 +17,16 @@
   return self.CANONICAL_PATH || '';
 }
 
+export function getDocUrl(docsBaseUrl: string, relativeUrl: string): string {
+  if (docsBaseUrl.endsWith('/')) {
+    docsBaseUrl = docsBaseUrl.slice(0, -1);
+  }
+  if (relativeUrl.startsWith('/')) {
+    relativeUrl = relativeUrl.slice(1);
+  }
+  return `${docsBaseUrl}/${relativeUrl}`;
+}
+
 /**
  * Return the url to use for login. If the server configuration
  * contains the `loginUrl` in the `auth` section then that custom url
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index f8be92a..a92d8b1 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -16,6 +16,7 @@
   toPathname,
   toSearchParams,
   sameOrigin,
+  getDocUrl,
 } from './url-util';
 import {assert} from '@open-wc/testing';
 import {createAuth} from '../test/test-data-generators';
@@ -38,6 +39,15 @@
     });
   });
 
+  suite('getDocUrl tests', () => {
+    test('getDocUrl', () => {
+      assert.deepEqual(getDocUrl('a', 'b'), 'a/b');
+      assert.deepEqual(getDocUrl('a/', 'b'), 'a/b');
+      assert.deepEqual(getDocUrl('a', '/b'), 'a/b');
+      assert.deepEqual(getDocUrl('a/', '/b'), 'a/b');
+    });
+  });
+
   suite('loginUrl tests', () => {
     const authConfig = createAuth();