Merge "Delete unused VersionedAccountPreferences class"
diff --git a/.zuul.yaml b/.zuul.yaml
index d6dbc34..e0e92fa 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -7,6 +7,7 @@
       This adds required projects needed for all Gerrit-related builds
       (i.e., builds of Gerrit itself or plugins) on this branch.
     required-projects:
+      - java-prettify
       - jgit
 
 - job:
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index fff9d0b..fa30c87 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2467,6 +2467,53 @@
   ]
 ----
 
+DescendingOrder(d)::
+Sort the returned tags in descending order.
++
+.Request
+----
+  GET /projects/work%2Fmy-project/tags?d HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "ref": "refs/tags/v3.0",
+      "revision": "c628685b3c5a3614571ecb5c8fceb85db9112313",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Signed tag\n-----BEGIN PGP SIGNATURE-----\nVersion: GnuPG v1.4.11 (GNU/Linux)\n\niQEcBAABAgAGBQJUMlqYAAoJEPI2qVPgglptp7MH/j+KFcittFbxfSnZjUl8n5IZ\nveZo7wE+syjD9sUbMH4EGv0WYeVjphNTyViBof+stGTNkB0VQzLWI8+uWmOeiJ4a\nzj0LsbDOxodOEMI5tifo02L7r4Lzj++EbqtKv8IUq2kzYoQ2xjhKfFiGjeYOn008\n9PGnhNblEHZgCHguGR6GsfN8bfA2XNl9B5Ysl5ybX1kAVs/TuLZR4oDMZ/pW2S75\nhuyNnSgcgq7vl2gLGefuPs9lxkg5Fj3GZr7XPZk4pt/x1oiH7yXxV4UzrUwg2CC1\nfHrBlNbQ4uJNY8TFcwof52Z0cagM5Qb/ZSLglHbqEDGA68HPqbwf5z2hQyU2/U4\u003d\n\u003dZtUX\n-----END PGP SIGNATURE-----",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 09:02:16.000000000",
+        "tz": 540
+      }
+    },
+    {
+      "ref": "refs/tags/v2.0",
+      "revision": "1624f5af8ae89148d1a3730df8c290413e3dcf30"
+    },
+    {
+      "ref": "refs/tags/v1.0",
+      "revision": "49ce77fdcfd3398dc0dedbe016d1a425fd52d666",
+      "object": "1624f5af8ae89148d1a3730df8c290413e3dcf30",
+      "message": "Annotated tag",
+      "tagger": {
+        "name": "David Pursehouse",
+        "email": "david.pursehouse@sonymobile.com",
+        "date": "2014-10-06 07:35:03.000000000",
+        "tz": 540
+      }
+    }
+  ]
+----
+
 Substring(m)::
 Limit the results to those tags that match the specified substring.
 +
diff --git a/java/com/google/gerrit/acceptance/AccountIndexedCounter.java b/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
index 88b97c7..17e0559 100644
--- a/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
+++ b/java/com/google/gerrit/acceptance/AccountIndexedCounter.java
@@ -52,6 +52,11 @@
     countsByAccount.remove(accountId.get());
   }
 
+  public void assertReindexAtLeastOnceOf(Account.Id accountId) {
+    assertThat(countsByAccount.asMap().getOrDefault(accountId.get(), 0L)).isAtLeast(1);
+    countsByAccount.remove(accountId.get());
+  }
+
   public void assertNoReindex() {
     assertThat(countsByAccount.asMap()).isEmpty();
   }
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index ba1dbbc..2d53533 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -176,7 +176,7 @@
   private final TestRepository<?>.CommitBuilder commitBuilder;
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo)
@@ -185,7 +185,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -202,7 +202,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -212,7 +212,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -224,7 +224,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -236,7 +236,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -256,7 +256,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -275,7 +275,7 @@
   }
 
   @AssistedInject
-  PushOneCommit(
+  public PushOneCommit(
       Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
@@ -303,6 +303,11 @@
     commitBuilder.message(subject).author(i).committer(new PersonIdent(i, testRepo.getDate()));
   }
 
+  @UsedAt(Project.GOOGLE)
+  protected TestRepository<?> testRepository() {
+    return testRepo;
+  }
+
   @CanIgnoreReturnValue
   public PushOneCommit setParents(List<RevCommit> parents) throws Exception {
     commitBuilder.noParents();
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index 577cda8..5b5895f 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -71,7 +71,7 @@
         this::createAccount, externalIdFactory.arePasswordsAllowed());
   }
 
-  private Account.Id createAccount(TestAccountCreation testAccountCreation) throws Exception {
+  protected Account.Id createAccount(TestAccountCreation testAccountCreation) throws Exception {
     Account.Id accountId = Account.id(seq.nextAccountId());
     Consumer<AccountDelta.Builder> accountCreation =
         deltaBuilder -> initAccountDelta(deltaBuilder, testAccountCreation, accountId);
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 1169364..fa94e0d 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -72,6 +72,7 @@
   abstract class ListRefsRequest<T extends RefInfo> {
     protected int limit;
     protected int start;
+    protected boolean descendingOrder;
     protected String substring;
     protected String regex;
     protected String nextPageToken;
@@ -88,6 +89,11 @@
       return this;
     }
 
+    public ListRefsRequest<T> withDescendingOrder(boolean descendingOrder) {
+      this.descendingOrder = descendingOrder;
+      return this;
+    }
+
     public ListRefsRequest<T> withNextPageToken(String token) {
       this.nextPageToken = token;
       return this;
@@ -111,6 +117,10 @@
       return start;
     }
 
+    public boolean getDescendingOrder() {
+      return descendingOrder;
+    }
+
     public String getNextPageToken() {
       return nextPageToken;
     }
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 29f5a7c..d08aeb7 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -365,7 +365,8 @@
               + e.getDuplicateKey().get()
               + "\" to account "
               + newId
-              + "; external ID already in use.");
+              + "; external ID already in use.",
+          e);
     } finally {
       // If adding the account failed, it may be that it actually was the
       // first account. So we reset the 'check for first account'-guard, as
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 74f8ccc..3b07829 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -107,6 +107,9 @@
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -477,22 +480,27 @@
     }
   }
 
-  private void ensureLoaded(Iterable<ChangeData> all) {
+  private void ensureLoaded(Collection<ChangeData> all) {
     if (lazyLoad) {
-      for (ChangeData cd : all) {
-        // Mark all ChangeDatas as coming from the index, but allow backfilling data from NoteDb
-        cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Load change data for lazyLoad options",
+              Metadata.builder().resourceCount(all.size()).build())) {
+        for (ChangeData cd : all) {
+          // Mark all ChangeDatas as coming from the index, but allow backfilling data from NoteDb
+          cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
+        }
+        ChangeData.ensureChangeLoaded(all);
+        if (has(ALL_REVISIONS)) {
+          ChangeData.ensureAllPatchSetsLoaded(all);
+        } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
+          ChangeData.ensureCurrentPatchSetLoaded(all);
+        }
+        if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
+          ChangeData.ensureReviewedByLoadedForOpenChanges(all);
+        }
+        ChangeData.ensureCurrentApprovalsLoaded(all);
       }
-      ChangeData.ensureChangeLoaded(all);
-      if (has(ALL_REVISIONS)) {
-        ChangeData.ensureAllPatchSetsLoaded(all);
-      } else if (has(CURRENT_REVISION) || has(MESSAGES)) {
-        ChangeData.ensureCurrentPatchSetLoaded(all);
-      }
-      if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
-        ChangeData.ensureReviewedByLoadedForOpenChanges(all);
-      }
-      ChangeData.ensureCurrentApprovalsLoaded(all);
     } else {
       for (ChangeData cd : all) {
         // Mark all ChangeDatas as coming from the index. Disallow using NoteDb
@@ -618,10 +626,12 @@
     if (has(CHECK)) {
       out.problems = checkerProvider.get().check(cd.notes(), fix).problems();
       // If any problems were fixed, the ChangeData needs to be reloaded.
-      for (ProblemInfo p : out.problems) {
-        if (p.status == ProblemInfo.Status.FIXED) {
+      if (out.problems.stream().anyMatch(p -> p.status == ProblemInfo.Status.FIXED)) {
+        try (TraceTimer timer =
+            TraceContext.newTimer(
+                "Reload change data after fixing a problem",
+                Metadata.builder().changeId(cd.change().getChangeId()).build())) {
           cd = changeDataFactory.create(cd.project(), cd.getId());
-          break;
         }
       }
     }
diff --git a/java/com/google/gerrit/server/config/GerritInstanceNameProvider.java b/java/com/google/gerrit/server/config/GerritInstanceNameProvider.java
index 740bb01..b2e80d7 100644
--- a/java/com/google/gerrit/server/config/GerritInstanceNameProvider.java
+++ b/java/com/google/gerrit/server/config/GerritInstanceNameProvider.java
@@ -30,9 +30,8 @@
 
   @Inject
   public GerritInstanceNameProvider(
-      @GerritServerConfig Config config,
-      @CanonicalWebUrl @Nullable Provider<String> canonicalUrlProvider) {
-    this.instanceName = getInstanceName(config, canonicalUrlProvider);
+      @GerritServerConfig Config config, @CanonicalWebUrl @Nullable String canonicalUrl) {
+    this.instanceName = getInstanceName(config, canonicalUrl);
   }
 
   @Override
@@ -40,14 +39,13 @@
     return instanceName;
   }
 
-  private static String getInstanceName(
-      Config config, @Nullable Provider<String> canonicalUrlProvider) {
+  private static String getInstanceName(Config config, String canonicalUrl) {
     String instanceName = config.getString("gerrit", null, "instanceName");
-    if (instanceName != null || canonicalUrlProvider == null) {
+    if (instanceName != null) {
       return instanceName;
     }
 
-    return extractInstanceName(canonicalUrlProvider.get());
+    return extractInstanceName(canonicalUrl);
   }
 
   private static String extractInstanceName(String canonicalUrl) {
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index e11d6aa..1ff0a8b 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -44,7 +44,7 @@
   private final String displayName;
   private final String description;
   private final boolean inheritable;
-  private final String defaultValue;
+  @Nullable private final String defaultValue;
   private final ProjectConfigEntryType type;
   private final List<String> permittedValues;
 
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 69dae04..7caa988 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -369,6 +369,7 @@
   private final BatchUpdate.Factory batchUpdateFactory;
   private final CancellationMetrics cancellationMetrics;
   private final ChangeEditUtil editUtil;
+  private final ChangeData.Factory changeDataFactory;
   private final ChangeIndexer indexer;
   private final ChangeInserter.Factory changeInserterFactory;
   private final ChangeNotes.Factory notesFactory;
@@ -459,6 +460,7 @@
       ProjectConfig.Factory projectConfigFactory,
       @GerritServerConfig Config config,
       ChangeEditUtil editUtil,
+      ChangeData.Factory changeDataFactory,
       ChangeIndexer indexer,
       ChangeInserter.Factory changeInserterFactory,
       ChangeNotes.Factory notesFactory,
@@ -525,6 +527,7 @@
     this.deadlineCheckerFactory = deadlineCheckerFactory;
     this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory;
     this.editUtil = editUtil;
+    this.changeDataFactory = changeDataFactory;
     this.hashtagsFactory = hashtagsFactory;
     this.setTopicFactory = setTopicFactory;
     this.indexer = indexer;
@@ -884,7 +887,7 @@
         logger.atFine().log("Added %d additional ref updates", added);
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(false, superprojectUpdateSubmissionListeners);
+            new SubmissionExecutor(changeDataFactory, false, superprojectUpdateSubmissionListeners);
 
         submissionExecutor.execute(ImmutableList.of(bu));
 
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 5a439f8..59d1b9b 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -142,7 +142,7 @@
   @Override
   public boolean canEmail(String address) {
     if (!isEnabled()) {
-      logger.atWarning().log("Not emailing %s (email is disabled)", address);
+      logger.atFine().log("Not emailing %s (email is disabled)", address);
       return false;
     }
 
@@ -163,7 +163,7 @@
     if (denyrcpt.contains(address)
         || denyrcpt.contains(domain)
         || denyrcpt.contains("@" + domain)) {
-      logger.atInfo().log("Not emailing %s (prohibited by sendemail.denyrcpt)", address);
+      logger.atFine().log("Not emailing %s (prohibited by sendemail.denyrcpt)", address);
       return true;
     }
 
@@ -182,7 +182,7 @@
       return true;
     }
 
-    logger.atWarning().log("Not emailing %s (prohibited by sendemail.allowrcpt)", address);
+    logger.atFine().log("Not emailing %s (prohibited by sendemail.allowrcpt)", address);
     return false;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
index a49952c..6fe464d 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
@@ -122,7 +122,7 @@
       // were,
       // all updates from this operation only happen in All-Users and thus are fully atomic, so
       // allowing partial failure would have little value.
-      BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
+      BatchUpdate.execute(changeDataFactory, updates.values(), ImmutableList.of(), false);
     }
     return ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList());
   }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index f54df5b..709d6a7 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -146,7 +146,6 @@
   private final BatchUpdate.Factory updateFactory;
   private final PostReviewOp.Factory postReviewOpFactory;
   private final ChangeResource.Factory changeResourceFactory;
-  private final ChangeData.Factory changeDataFactory;
   private final AccountCache accountCache;
   private final ApprovalsUtil approvalsUtil;
   private final DraftCommentsReader draftCommentsReader;
@@ -171,7 +170,6 @@
       BatchUpdate.Factory updateFactory,
       PostReviewOp.Factory postReviewOpFactory,
       ChangeResource.Factory changeResourceFactory,
-      ChangeData.Factory changeDataFactory,
       AccountCache accountCache,
       ApprovalsUtil approvalsUtil,
       DraftCommentsReader draftCommentsReader,
@@ -191,7 +189,6 @@
     this.updateFactory = updateFactory;
     this.postReviewOpFactory = postReviewOpFactory;
     this.changeResourceFactory = changeResourceFactory;
-    this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
     this.draftCommentsReader = draftCommentsReader;
     this.approvalsUtil = approvalsUtil;
@@ -293,6 +290,8 @@
     }
     output.labels = input.labels;
 
+    BatchUpdate.Result batchUpdateResult;
+
     // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
     NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
@@ -380,12 +379,13 @@
         // Adjust the attention set based on the input
         replyAttentionSetUpdates.updateAttentionSetOnPostReview(
             bu, postReviewOp, revision.getNotes(), input, revision.getUser());
-        bu.execute();
+
+        batchUpdateResult = bu.execute();
       }
     }
 
-    // Re-read change to take into account results of the update.
-    ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
+    ChangeData cd =
+        batchUpdateResult.getChangeData(revision.getProject(), revision.getChange().getId());
     for (ReviewerModification reviewerResult : reviewerResults) {
       reviewerResult.gatherResults(cd);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 338ff0d..3e8002b 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -14,17 +14,11 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
-
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -33,89 +27,35 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.approval.ApprovalsUtil;
-import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
 public class CreateAccessChange implements RestModifyView<ProjectResource, ProjectAccessInput> {
-  private final PermissionBackend permissionBackend;
-  private final Sequences seq;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final BatchUpdate.Factory updateFactory;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final SetAccessUtil setAccess;
   private final ChangeJson.Factory jsonFactory;
-  private final ProjectCache projectCache;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   CreateAccessChange(
-      PermissionBackend permissionBackend,
-      ChangeInserter.Factory changeInserterFactory,
-      BatchUpdate.Factory updateFactory,
-      Sequences seq,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       SetAccessUtil accessUtil,
       ChangeJson.Factory jsonFactory,
-      ProjectCache projectCache,
-      ProjectConfig.Factory projectConfigFactory) {
-    this.permissionBackend = permissionBackend;
-    this.seq = seq;
-    this.changeInserterFactory = changeInserterFactory;
-    this.updateFactory = updateFactory;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.setAccess = accessUtil;
     this.jsonFactory = jsonFactory;
-    this.projectCache = projectCache;
-    this.projectConfigFactory = projectConfigFactory;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<ChangeInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
-      throws PermissionBackendException, AuthException, IOException, ConfigInvalidException,
-          InvalidNameException, UpdateException, RestApiException {
-    PermissionBackend.ForProject forProject =
-        permissionBackend.user(rsrc.getUser()).project(rsrc.getNameKey());
-    if (!check(forProject, ProjectPermission.READ_CONFIG)) {
-      throw new AuthException(RefNames.REFS_CONFIG + " not visible");
-    }
-    if (!check(forProject, ProjectPermission.WRITE_CONFIG)) {
-      try {
-        forProject.ref(RefNames.REFS_CONFIG).check(RefPermission.CREATE_CHANGE);
-      } catch (AuthException denied) {
-        throw new AuthException("cannot create change for " + RefNames.REFS_CONFIG, denied);
-      }
-    }
-    projectCache
-        .get(rsrc.getNameKey())
-        .orElseThrow(illegalState(rsrc.getNameKey()))
-        .checkStatePermitsWrite();
-
-    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
+      throws PermissionBackendException, IOException, ConfigInvalidException, InvalidNameException,
+          UpdateException, RestApiException {
     ImmutableList<AccessSection> removals =
         setAccess.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
     ImmutableList<AccessSection> additions =
@@ -123,81 +63,30 @@
 
     Project.NameKey newParentProjectName =
         input.parent == null ? null : Project.nameKey(input.parent);
-
-    try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
-      ObjectId oldCommit = config.getRevision();
-      String oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
-
-      setAccess.validateChanges(config, removals, additions);
-      setAccess.applyChanges(config, removals, additions);
-      try {
-        setAccess.setParentName(
-            rsrc.getUser().asIdentifiedUser(),
-            config,
-            rsrc.getNameKey(),
-            newParentProjectName,
-            false);
-      } catch (AuthException e) {
-        throw new IllegalStateException(e);
-      }
-
-      if (!Strings.isNullOrEmpty(input.message)) {
-        if (!input.message.endsWith("\n")) {
-          input.message += "\n";
-        }
-        md.setMessage(input.message);
-      } else {
-        md.setMessage("Review access change\n");
-      }
-
-      md.setInsertChangeId(true);
-      Change.Id changeId = Change.id(seq.nextChangeId());
-      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-        RevCommit commit =
-            config.commitToNewRef(
-                md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
-
-        if (commit.name().equals(oldCommitSha1)) {
-          throw new BadRequestException("no change");
-        }
-
-        try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
-            ObjectReader objReader = objInserter.newReader();
-            RevWalk rw = new RevWalk(objReader);
-            BatchUpdate bu =
-                updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.now())) {
-          bu.setRepository(md.getRepository(), rw, objInserter);
-          ChangeInserter ins = newInserter(changeId, commit);
-          bu.insertChange(ins);
-          bu.execute();
-          return Response.created(jsonFactory.noOptions().format(ins.getChange()));
-        }
-      }
+    String message = !Strings.isNullOrEmpty(input.message) ? input.message : "Review access change";
+    try {
+      Change change =
+          repoMetaDataUpdater.updateAndCreateChangeForReview(
+              rsrc.getNameKey(),
+              rsrc.getUser(),
+              message,
+              config -> {
+                setAccess.validateChanges(config, removals, additions);
+                setAccess.applyChanges(config, removals, additions);
+                try {
+                  setAccess.setParentName(
+                      rsrc.getUser().asIdentifiedUser(),
+                      config,
+                      rsrc.getNameKey(),
+                      newParentProjectName,
+                      false);
+                } catch (AuthException e) {
+                  throw new IllegalStateException(e);
+                }
+              });
+      return Response.created(jsonFactory.noOptions().format(change));
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
     }
   }
-
-  // ProjectConfig doesn't currently support fusing into a BatchUpdate.
-  @SuppressWarnings("deprecation")
-  private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
-    return changeInserterFactory
-        .create(changeId, commit, RefNames.REFS_CONFIG)
-        .setMessage(
-            // Same message as in ReceiveCommits.CreateRequest.
-            ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
-        .setValidate(false)
-        .setUpdateRef(false);
-  }
-
-  private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
-      throws PermissionBackendException {
-    try {
-      perm.check(p);
-      return true;
-    } catch (AuthException denied) {
-      return false;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index 83d29de..5eca1fc 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -42,6 +42,7 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Constants;
@@ -78,6 +79,14 @@
   }
 
   @Option(
+      name = "--descending",
+      aliases = {"-d"},
+      usage = "sort the returned tags in descending order")
+  public void setDescendingOrder(boolean descendingOrder) {
+    this.descendingOrder = descendingOrder;
+  }
+
+  @Option(
       name = "--match",
       aliases = {"-m"},
       metaVar = "MATCH",
@@ -97,6 +106,7 @@
 
   private int limit;
   private int start;
+  private boolean descendingOrder;
   private String matchSubstring;
   private String matchRegex;
 
@@ -111,6 +121,7 @@
   public ListTags request(ListRefsRequest<TagInfo> request) {
     this.setLimit(request.getLimit());
     this.setStart(request.getStart());
+    this.setDescendingOrder(request.getDescendingOrder());
     this.setMatchSubstring(request.getSubstring());
     this.setMatchRegex(request.getRegex());
     return this;
@@ -137,6 +148,9 @@
     }
 
     tags.sort(comparing(t -> t.ref));
+    if (descendingOrder) {
+      Collections.reverse(tags);
+    }
 
     return Response.ok(
         new RefFilter<>(Constants.R_TAGS, (TagInfo tag) -> tag.ref)
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 7bca46d..d5f61ce 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -222,7 +222,8 @@
             value = Joiner.on("\n").join(v.getValue().values);
           }
 
-          if (projectConfigEntry.getDefaultValue().equals(value)) {
+          String defaultValue = projectConfigEntry.getDefaultValue();
+          if (defaultValue != null && defaultValue.equals(value)) {
             // If the value is equal to the default, unset in case it existed.
             if (oldValue != null) {
               validateProjectConfigEntryIsEditable(
diff --git a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
new file mode 100644
index 0000000..c45a009
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
@@ -0,0 +1,215 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.InvalidNameException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.CreateGroupPermissionSyncer;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.MetaDataUpdate.User;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.io.IOException;
+import javax.inject.Inject;
+import javax.inject.Provider;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Updates repo refs/meta/config content. */
+@Singleton
+public class RepoMetaDataUpdater {
+  private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
+  private final Provider<User> metaDataUpdateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final Sequences seq;
+
+  private final BatchUpdate.Factory updateFactory;
+
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  RepoMetaDataUpdater(
+      CreateGroupPermissionSyncer createGroupPermissionSyncer,
+      Provider<User> metaDataUpdateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache,
+      ChangeInserter.Factory changeInserterFactory,
+      Sequences seq,
+      BatchUpdate.Factory updateFactory,
+      PermissionBackend permissionBackend) {
+    this.createGroupPermissionSyncer = createGroupPermissionSyncer;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+    this.changeInserterFactory = changeInserterFactory;
+    this.seq = seq;
+    this.updateFactory = updateFactory;
+    this.permissionBackend = permissionBackend;
+  }
+
+  public Change updateAndCreateChangeForReview(
+      Project.NameKey projectName,
+      CurrentUser user,
+      String message,
+      ProjectConfigUpdater projectConfigUpdater)
+      throws ConfigInvalidException, IOException, RestApiException, UpdateException,
+          InvalidNameException, PermissionBackendException {
+    checkArgument(!message.isBlank(), "The message must not be empty");
+    message = validateMessage(message);
+
+    PermissionBackend.ForProject forProject = permissionBackend.user(user).project(projectName);
+    if (!check(forProject, ProjectPermission.READ_CONFIG)) {
+      throw new AuthException(RefNames.REFS_CONFIG + " not visible");
+    }
+    if (!check(forProject, ProjectPermission.WRITE_CONFIG)) {
+      try {
+        forProject.ref(RefNames.REFS_CONFIG).check(RefPermission.CREATE_CHANGE);
+      } catch (AuthException denied) {
+        throw new AuthException("cannot create change for " + RefNames.REFS_CONFIG, denied);
+      }
+    }
+    projectCache.get(projectName).orElseThrow(illegalState(projectName)).checkStatePermitsWrite();
+
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
+      ProjectConfig config = projectConfigFactory.read(md);
+      ObjectId oldCommit = config.getRevision();
+      String oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
+
+      projectConfigUpdater.update(config);
+      md.setMessage(message);
+      md.setInsertChangeId(true);
+
+      Change.Id changeId = Change.id(seq.nextChangeId());
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        RevCommit commit =
+            config.commitToNewRef(
+                md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
+
+        if (commit.name().equals(oldCommitSha1)) {
+          throw new BadRequestException("no change");
+        }
+
+        try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
+            ObjectReader objReader = objInserter.newReader();
+            RevWalk rw = new RevWalk(objReader);
+            BatchUpdate bu = updateFactory.create(projectName, user, TimeUtil.now())) {
+          bu.setRepository(md.getRepository(), rw, objInserter);
+          ChangeInserter ins = newInserter(changeId, commit);
+          bu.insertChange(ins);
+          bu.execute();
+          return ins.getChange();
+        }
+      }
+    }
+  }
+
+  public void updateWithoutReview(
+      Project.NameKey projectName, String message, ProjectConfigUpdater projectConfigUpdater)
+      throws ConfigInvalidException, IOException, PermissionBackendException, AuthException,
+          ResourceConflictException, InvalidNameException, BadRequestException {
+    updateWithoutReview(
+        projectName, message, /*skipPermissionsCheck=*/ false, projectConfigUpdater);
+  }
+
+  public void updateWithoutReview(
+      Project.NameKey projectName,
+      String message,
+      boolean skipPermissionsCheck,
+      ProjectConfigUpdater projectConfigUpdater)
+      throws ConfigInvalidException, IOException, PermissionBackendException, AuthException,
+          ResourceConflictException, InvalidNameException, BadRequestException {
+    message = validateMessage(message);
+    if (!skipPermissionsCheck) {
+      permissionBackend.currentUser().project(projectName).check(ProjectPermission.WRITE_CONFIG);
+    }
+
+    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      projectConfigUpdater.update(config);
+      md.setMessage(message);
+      config.commit(md);
+      projectCache.evictAndReindex(config.getProject());
+      createGroupPermissionSyncer.syncIfNeeded();
+    }
+  }
+
+  private String validateMessage(String message) {
+    if (!message.endsWith("\n")) {
+      return message + "\n";
+    }
+    return message;
+  }
+
+  // ProjectConfig doesn't currently support fusing into a BatchUpdate.
+  @SuppressWarnings("deprecation")
+  private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
+    return changeInserterFactory
+        .create(changeId, commit, RefNames.REFS_CONFIG)
+        .setMessage(
+            // Same message as in ReceiveCommits.CreateRequest.
+            ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
+        .setValidate(false)
+        .setUpdateRef(false);
+  }
+
+  private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
+      throws PermissionBackendException {
+    try {
+      perm.check(p);
+      return true;
+    } catch (AuthException denied) {
+      return false;
+    }
+  }
+
+  @FunctionalInterface
+  public interface ProjectConfigUpdater {
+    void update(ProjectConfig config)
+        throws BadRequestException, InvalidNameException, PermissionBackendException,
+            ResourceConflictException, AuthException;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index e4e4373..75fe280 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -29,15 +29,11 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.RefPermission;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -49,92 +45,72 @@
 public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessInput> {
   protected final GroupBackend groupBackend;
   private final PermissionBackend permissionBackend;
-  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final GetAccess getAccess;
-  private final ProjectCache projectCache;
   private final Provider<IdentifiedUser> identifiedUser;
   private final SetAccessUtil accessUtil;
-  private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   private SetAccess(
       GroupBackend groupBackend,
       PermissionBackend permissionBackend,
-      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      ProjectCache projectCache,
       GetAccess getAccess,
       Provider<IdentifiedUser> identifiedUser,
       SetAccessUtil accessUtil,
-      CreateGroupPermissionSyncer createGroupPermissionSyncer,
-      ProjectConfig.Factory projectConfigFactory) {
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.groupBackend = groupBackend;
     this.permissionBackend = permissionBackend;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.getAccess = getAccess;
-    this.projectCache = projectCache;
     this.identifiedUser = identifiedUser;
     this.accessUtil = accessUtil;
-    this.createGroupPermissionSyncer = createGroupPermissionSyncer;
-    this.projectConfigFactory = projectConfigFactory;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<ProjectAccessInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
       throws Exception {
-    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
-
     validateInput(input);
 
-    ProjectConfig config;
-
     ImmutableList<AccessSection> removals =
         accessUtil.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
     ImmutableList<AccessSection> additions =
         accessUtil.getAccessSections(input.add, /* rejectNonResolvableGroups= */ true);
-    try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
-      config = projectConfigFactory.read(md);
-
-      // Check that the user has the right permissions.
-      boolean checkedAdmin = false;
-      for (AccessSection section : Iterables.concat(additions, removals)) {
-        boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
-        if (isGlobalCapabilities) {
-          if (!checkedAdmin) {
-            permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
-            checkedAdmin = true;
-          }
-        } else {
-          permissionBackend
-              .currentUser()
-              .project(rsrc.getNameKey())
-              .ref(section.getName())
-              .check(RefPermission.WRITE_CONFIG);
-        }
-      }
-
-      accessUtil.validateChanges(config, removals, additions);
-      accessUtil.applyChanges(config, removals, additions);
-
-      accessUtil.setParentName(
-          identifiedUser.get(),
-          config,
+    String message = !Strings.isNullOrEmpty(input.message) ? input.message : "Modify access rules";
+    try {
+      this.repoMetaDataUpdater.updateWithoutReview(
           rsrc.getNameKey(),
-          input.parent == null ? null : Project.nameKey(input.parent),
-          !checkedAdmin);
+          message,
+          /*skipPermissionsCheck=*/ true,
+          config -> {
+            // Check that the user has the right permissions.
+            boolean checkedAdmin = false;
+            for (AccessSection section : Iterables.concat(additions, removals)) {
+              boolean isGlobalCapabilities =
+                  AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+              if (isGlobalCapabilities) {
+                if (!checkedAdmin) {
+                  permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+                  checkedAdmin = true;
+                }
+              } else {
+                permissionBackend
+                    .currentUser()
+                    .project(rsrc.getNameKey())
+                    .ref(section.getName())
+                    .check(RefPermission.WRITE_CONFIG);
+              }
+            }
 
-      if (!Strings.isNullOrEmpty(input.message)) {
-        if (!input.message.endsWith("\n")) {
-          input.message += "\n";
-        }
-        md.setMessage(input.message);
-      } else {
-        md.setMessage("Modify access rules\n");
-      }
+            accessUtil.validateChanges(config, removals, additions);
+            accessUtil.applyChanges(config, removals, additions);
 
-      config.commit(md);
-      projectCache.evictAndReindex(config.getProject());
-      createGroupPermissionSyncer.syncIfNeeded();
+            accessUtil.setParentName(
+                identifiedUser.get(),
+                config,
+                rsrc.getNameKey(),
+                input.parent == null ? null : Project.nameKey(input.parent),
+                !checkedAdmin);
+          });
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
     } catch (ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index bc14f34..bb1f25f 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -558,7 +558,8 @@
         }
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListeners);
+            new SubmissionExecutor(
+                changeDataFactory, dryrun, superprojectUpdateSubmissionListeners);
         RetryTracker retryTracker = new RetryTracker();
         @SuppressWarnings("unused")
         var unused =
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index cebb5e3..02afedb 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
@@ -39,13 +40,16 @@
 
   @Singleton
   public static class Factory {
+    private final ChangeData.Factory changeDataFactory;
     private final SubscriptionGraph.Factory subscriptionGraphFactory;
     private final SubmoduleCommits.Factory submoduleCommitsFactory;
 
     @Inject
     Factory(
+        ChangeData.Factory changeDataFactory,
         SubscriptionGraph.Factory subscriptionGraphFactory,
         SubmoduleCommits.Factory submoduleCommitsFactory) {
+      this.changeDataFactory = changeDataFactory;
       this.subscriptionGraphFactory = subscriptionGraphFactory;
       this.submoduleCommitsFactory = submoduleCommitsFactory;
     }
@@ -54,6 +58,7 @@
         Map<BranchNameKey, ReceiveCommand> updatedBranches, MergeOpRepoManager orm)
         throws SubmoduleConflictException {
       return new SubmoduleOp(
+          changeDataFactory,
           updatedBranches,
           orm,
           subscriptionGraphFactory.compute(updatedBranches.keySet(), orm),
@@ -61,6 +66,7 @@
     }
   }
 
+  private final ChangeData.Factory changeDataFactory;
   private final Map<BranchNameKey, ReceiveCommand> updatedBranches;
   private final MergeOpRepoManager orm;
   private final SubscriptionGraph subscriptionGraph;
@@ -68,10 +74,12 @@
   private final UpdateOrderCalculator updateOrderCalculator;
 
   private SubmoduleOp(
+      ChangeData.Factory changeDataFactory,
       Map<BranchNameKey, ReceiveCommand> updatedBranches,
       MergeOpRepoManager orm,
       SubscriptionGraph subscriptionGraph,
       SubmoduleCommits submoduleCommits) {
+    this.changeDataFactory = changeDataFactory;
     this.updatedBranches = updatedBranches;
     this.orm = orm;
     this.subscriptionGraph = subscriptionGraph;
@@ -108,6 +116,7 @@
       }
       try (RefUpdateContext ctx = RefUpdateContext.open(UPDATE_SUPERPROJECT)) {
         BatchUpdate.execute(
+            changeDataFactory,
             orm.batchUpdates(superProjects, /* refLogMessage= */ "merged"),
             ImmutableList.of(),
             dryrun);
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index b28ab27..0e29a4e 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -103,7 +103,6 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushCertificate;
 import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.transport.ReceiveCommand.Result;
 
 /**
  * Helper for a set of change updates that should be applied to the NoteDb database.
@@ -142,12 +141,42 @@
     BatchUpdate create(Project.NameKey project, CurrentUser user, Instant when);
   }
 
-  public static void execute(
-      Collection<BatchUpdate> updates, ImmutableList<BatchUpdateListener> listeners, boolean dryrun)
+  public static class Result {
+    private final ChangeData.Factory changeDataFactory;
+    private final Map<Change.Id, ChangeData> changeDatas;
+
+    private Result(ChangeData.Factory changeDataFactory) {
+      this(changeDataFactory, new HashMap<>());
+    }
+
+    private Result(ChangeData.Factory changeDataFactory, Map<Change.Id, ChangeData> changeDatas) {
+      this.changeDataFactory = changeDataFactory;
+      this.changeDatas = changeDatas;
+    }
+
+    /**
+     * Returns the updated {@link ChangeData} for the given project and change ID.
+     *
+     * <p>If the requested {@link ChangeData} was already loaded after the {@link BatchUpdate} has
+     * been executed the cached {@link ChangeData} instance is returned, otherwise the requested
+     * {@link ChangeData} is loaded and put into the cache.
+     */
+    public ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId) {
+      return changeDatas.computeIfAbsent(
+          changeId, id -> changeDataFactory.create(projectName, changeId));
+    }
+  }
+
+  @CanIgnoreReturnValue
+  public static Result execute(
+      ChangeData.Factory changeDataFactory,
+      Collection<BatchUpdate> updates,
+      ImmutableList<BatchUpdateListener> listeners,
+      boolean dryrun)
       throws UpdateException, RestApiException {
     requireNonNull(listeners);
     if (updates.isEmpty()) {
-      return;
+      return new Result(changeDataFactory);
     }
 
     checkDifferentProject(updates);
@@ -193,8 +222,11 @@
           u.executePostOps(changeDatas);
         }
       }
+
+      return new Result(changeDataFactory, changeDatas);
     } catch (Exception e) {
       wrapAndThrowException(e);
+      return new Result(changeDataFactory);
     }
   }
 
@@ -484,12 +516,14 @@
     }
   }
 
-  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), ImmutableList.of(listener), false);
+  @CanIgnoreReturnValue
+  public Result execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
+    return execute(changeDataFactory, ImmutableList.of(this), ImmutableList.of(listener), false);
   }
 
-  public void execute() throws UpdateException, RestApiException {
-    execute(ImmutableList.of(this), ImmutableList.of(), false);
+  @CanIgnoreReturnValue
+  public Result execute() throws UpdateException, RestApiException {
+    return execute(changeDataFactory, ImmutableList.of(this), ImmutableList.of(), false);
   }
 
   public boolean isExecuted() {
@@ -584,7 +618,7 @@
    */
   public Map<BranchNameKey, ReceiveCommand> getSuccessfullyUpdatedBranches(boolean dryrun) {
     return getRefUpdates().entrySet().stream()
-        .filter(entry -> dryrun || entry.getValue().getResult() == Result.OK)
+        .filter(entry -> dryrun || entry.getValue().getResult() == ReceiveCommand.Result.OK)
         .collect(
             toMap(entry -> BranchNameKey.create(project, entry.getKey()), Map.Entry::getValue));
   }
diff --git a/java/com/google/gerrit/server/update/SubmissionExecutor.java b/java/com/google/gerrit/server/update/SubmissionExecutor.java
index 39eda58..bab6ddd 100644
--- a/java/com/google/gerrit/server/update/SubmissionExecutor.java
+++ b/java/com/google/gerrit/server/update/SubmissionExecutor.java
@@ -16,18 +16,23 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import java.util.Collection;
 import java.util.Optional;
 import java.util.stream.Collectors;
 
 public class SubmissionExecutor {
-
+  private final ChangeData.Factory changeDataFactory;
   private final ImmutableList<SubmissionListener> submissionListeners;
   private final boolean dryrun;
   private ImmutableList<BatchUpdateListener> additionalListeners = ImmutableList.of();
 
-  public SubmissionExecutor(boolean dryrun, ImmutableList<SubmissionListener> submissionListeners) {
+  public SubmissionExecutor(
+      ChangeData.Factory changeDataFactory,
+      boolean dryrun,
+      ImmutableList<SubmissionListener> submissionListeners) {
+    this.changeDataFactory = changeDataFactory;
     this.dryrun = dryrun;
     this.submissionListeners = submissionListeners;
     if (dryrun) {
@@ -58,7 +63,7 @@
                     .map(Optional::get)
                     .collect(Collectors.toList()))
             .build();
-    BatchUpdate.execute(updates, listeners, dryrun);
+    BatchUpdate.execute(changeDataFactory, updates, listeners, dryrun);
   }
 
   /**
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 01c5710..65954e4 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -144,7 +144,9 @@
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdsNoteDbImpl;
@@ -244,7 +246,7 @@
   @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
   @Inject private AccountIndexer accountIndexer;
   @Inject private ExternalIdNotes.Factory extIdNotesFactory;
-  @Inject private ExternalIdsNoteDbImpl externalIds;
+  @Inject private ExternalIdsNoteDbImpl externalIdsNoteDbImpl;
   @Inject private GitReferenceUpdated gitReferenceUpdated;
   @Inject private Provider<InternalAccountQuery> accountQueryProvider;
   @Inject private Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
@@ -255,7 +257,7 @@
   @Inject private VersionedAuthorizedKeys.Accessor authorizedKeys;
   @Inject private PluginSetContext<ExceptionHook> exceptionHooks;
   @Inject private ExternalIdKeyFactory externalIdKeyFactory;
-  @Inject private ExternalIdFactoryNoteDbImpl externalIdFactory;
+  @Inject private ExternalIdFactoryNoteDbImpl externalIdFactoryNoteDbImpl;
   @Inject private AuthConfig authConfig;
   @Inject private AccountControl.Factory accountControlFactory;
   @Inject private AccountOperations accountOperations;
@@ -406,10 +408,10 @@
 
       Account.Id accountId = Account.id(accountInfo._accountId);
       accountIndexedCounter.assertReindexOf(accountId, 1);
-      assertThat(externalIds.byAccount(accountId))
+      assertThat(getExternalIdsReader().byAccount(accountId))
           .containsExactly(
-              externalIdFactory.createUsername(input.username, accountId, null),
-              externalIdFactory.createEmail(accountId, input.email));
+              getExternalIdFactory().createUsername(input.username, accountId, null),
+              getExternalIdFactory().createEmail(accountId, input.email));
     }
   }
 
@@ -462,7 +464,7 @@
   public void createAtomically() throws Exception {
     Account.Id accountId = Account.id(seq.nextAccountId());
     String fullName = "Foo";
-    ExternalId extId = externalIdFactory.createEmail(accountId, "foo@example.com");
+    ExternalId extId = getExternalIdFactory().createEmail(accountId, "foo@example.com");
     AccountState accountState =
         accountsUpdateProvider
             .get()
@@ -630,7 +632,9 @@
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       Account.Id activatableAccountId =
           accountOperations.newAccount().inactive().preferredEmail("foo@activatable.com").create();
-      accountIndexedCounter.assertReindexOf(activatableAccountId, 1);
+      // Implementation of the accountOperations can create an account in several steps,
+      // with more than one reindexing.
+      accountIndexedCounter.assertReindexAtLeastOnceOf(activatableAccountId);
     }
 
     @SuppressWarnings("unused")
@@ -644,7 +648,9 @@
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
       Account.Id activatableAccountId =
           accountOperations.newAccount().inactive().username("foo").create();
-      accountIndexedCounter.assertReindexOf(activatableAccountId, 1);
+      // Implementation of the accountOperations can create an account in several steps,
+      // with more than one reindexing.
+      accountIndexedCounter.assertReindexAtLeastOnceOf(activatableAccountId);
     }
 
     @SuppressWarnings("unused")
@@ -1274,11 +1280,13 @@
               admin.id(),
               u ->
                   u.addExternalId(
-                          externalIdFactory.createWithEmail(
-                              externalIdKeyFactory.parse(extId1), admin.id(), email))
+                          getExternalIdFactory()
+                              .createWithEmail(
+                                  externalIdKeyFactory.parse(extId1), admin.id(), email))
                       .addExternalId(
-                          externalIdFactory.createWithEmail(
-                              externalIdKeyFactory.parse(extId2), admin.id(), email)));
+                          getExternalIdFactory()
+                              .createWithEmail(
+                                  externalIdKeyFactory.parse(extId2), admin.id(), email)));
       accountIndexedCounter.assertReindexOf(admin);
       assertThat(
               gApi.accounts().self().getExternalIds().stream()
@@ -1314,8 +1322,9 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                    externalIdFactory.createWithEmail(
-                        externalIdKeyFactory.parse(ldapExternalId), admin.id(), ldapEmail)));
+                    getExternalIdFactory()
+                        .createWithEmail(
+                            externalIdKeyFactory.parse(ldapExternalId), admin.id(), ldapEmail)));
     assertThat(
             gApi.accounts().self().getExternalIds().stream()
                 .map(e -> e.identity)
@@ -1353,13 +1362,17 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                        externalIdFactory.createWithEmail(
-                            externalIdKeyFactory.parse(nonLdapExternalId),
-                            admin.id(),
-                            nonLdapEMail))
+                        getExternalIdFactory()
+                            .createWithEmail(
+                                externalIdKeyFactory.parse(nonLdapExternalId),
+                                admin.id(),
+                                nonLdapEMail))
                     .addExternalId(
-                        externalIdFactory.createWithEmail(
-                            externalIdKeyFactory.parse(ldapExternalId), admin.id(), ldapEmail)));
+                        getExternalIdFactory()
+                            .createWithEmail(
+                                externalIdKeyFactory.parse(ldapExternalId),
+                                admin.id(),
+                                ldapEmail)));
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
         .containsAtLeast(ldapExternalId, nonLdapExternalId);
@@ -1422,8 +1435,9 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                    externalIdFactory.createWithEmail(
-                        externalIdKeyFactory.parse("foo:bar"), admin.id(), email)));
+                    getExternalIdFactory()
+                        .createWithEmail(
+                            externalIdKeyFactory.parse("foo:bar"), admin.id(), email)));
     assertEmail(emails.getAccountFor(email), admin);
 
     // wrong case doesn't match
@@ -1739,7 +1753,7 @@
           .update(
               "Add External ID",
               user.id(),
-              u -> u.addExternalId(externalIdFactory.create("foo", "myId", user.id())));
+              u -> u.addExternalId(getExternalIdFactory().create("foo", "myId", user.id())));
       accountIndexedCounter.assertReindexOf(user);
 
       TestKey key = validKeyWithSecondUserId();
@@ -2042,7 +2056,7 @@
         .update(
             "Delete External ID",
             account.id(),
-            u -> u.deleteExternalId(externalIdFactory.createEmail(account.id(), email)));
+            u -> u.deleteExternalId(getExternalIdFactory().createEmail(account.id(), email)));
     expectedProblems.add(
         new ConsistencyProblemInfo(
             ConsistencyProblemInfo.Status.ERROR,
@@ -2199,7 +2213,7 @@
     String fullName = "Foo";
     AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
     AccountsUpdate update =
-        getAccountsUpdate(
+        getAccountsUpdateWithRunnables(
             () -> {
               if (!doneBgUpdate.getAndSet(true)) {
                 try {
@@ -2237,7 +2251,7 @@
     String fullName = "Foo";
     AtomicInteger bgCounter = new AtomicInteger(0);
     AccountsUpdate update =
-        getAccountsUpdate(
+        getAccountsUpdateWithRunnables(
             () -> {
               try {
                 accountsUpdateProvider
@@ -2282,15 +2296,19 @@
   }
 
   @Test
-  public void atomicReadMofifyWrite() throws Exception {
+  public void atomicReadModifyWrite() throws Exception {
     gApi.accounts().id(admin.id().get()).setStatus("A-1");
 
-    AtomicInteger bgCounterA1 = new AtomicInteger(0);
-    AtomicInteger bgCounterA2 = new AtomicInteger(0);
+    AtomicBoolean bgIndicatorA1ToA2 = new AtomicBoolean(false);
     AccountsUpdate update =
-        getAccountsUpdate(
+        getAccountsUpdateWithRunnables(
             Runnables.doNothing(),
             () -> {
+              if (bgIndicatorA1ToA2.get()) {
+                // In the Google architecture, this runnable might be called multiple times. Only
+                // do the replacement once.
+                return;
+              }
               try {
                 accountsUpdateProvider
                     .get()
@@ -2298,29 +2316,31 @@
               } catch (IOException | ConfigInvalidException | StorageException e) {
                 // Ignore, the expected exception is asserted later
               }
+              bgIndicatorA1ToA2.set(true);
             });
-    assertThat(bgCounterA1.get()).isEqualTo(0);
-    assertThat(bgCounterA2.get()).isEqualTo(0);
+
     assertThat(gApi.accounts().id(admin.id().get()).get().status).isEqualTo("A-1");
 
+    AtomicBoolean bgIndicatorA1ToB1 = new AtomicBoolean(false);
+    AtomicBoolean bgIndicatorA2ToB2 = new AtomicBoolean(false);
     Optional<AccountState> updatedAccountState =
         update.update(
             "Set Status",
             admin.id(),
             (a, u) -> {
               if ("A-1".equals(a.account().status())) {
-                bgCounterA1.getAndIncrement();
+                bgIndicatorA1ToB1.set(true);
                 u.setStatus("B-1");
               }
 
               if ("A-2".equals(a.account().status())) {
-                bgCounterA2.getAndIncrement();
+                bgIndicatorA2ToB2.set(true);
                 u.setStatus("B-2");
               }
             });
 
-    assertThat(bgCounterA1.get()).isEqualTo(1);
-    assertThat(bgCounterA2.get()).isEqualTo(1);
+    assertThat(bgIndicatorA1ToB1.get()).isTrue();
+    assertThat(bgIndicatorA2ToB2.get()).isTrue();
 
     assertThat(updatedAccountState).isPresent();
     assertThat(updatedAccountState.get().account().status()).isEqualTo("B-2");
@@ -2329,64 +2349,70 @@
   }
 
   @Test
-  public void atomicReadMofifyWriteExternalIds() throws Exception {
+  public void atomicReadModifyWriteExternalIds() throws Exception {
     projectOperations
         .allProjectsForUpdate()
         .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
         .update();
 
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId extIdA1 = externalIdFactory.create("foo", "A-1", accountId);
+    ExternalId extIdA1 = getExternalIdFactory().create("foo", "A-1", accountId);
     accountsUpdateProvider
         .get()
         .insert("Create Test Account", accountId, u -> u.addExternalId(extIdA1));
 
-    AtomicInteger bgCounterA1 = new AtomicInteger(0);
-    AtomicInteger bgCounterA2 = new AtomicInteger(0);
-    ExternalId extIdA2 = externalIdFactory.create("foo", "A-2", accountId);
+    ExternalId extIdA2 = getExternalIdFactory().create("foo", "A-2", accountId);
+    AtomicBoolean bgIndicatorA1ToA2 = new AtomicBoolean(false);
     AccountsUpdate update =
-        getAccountsUpdate(
+        getAccountsUpdateWithRunnables(
             Runnables.doNothing(),
             () -> {
+              if (bgIndicatorA1ToA2.get()) {
+                // In the Google architecture, this runnable might be called multiple times. Only
+                // do the replacement once.
+                return;
+              }
               try {
                 accountsUpdateProvider
                     .get()
                     .update(
-                        "Update External ID",
+                        "Update External ID A1->A2",
                         accountId,
                         u -> u.replaceExternalId(extIdA1, extIdA2));
               } catch (IOException | ConfigInvalidException | StorageException e) {
                 // Ignore, the expected exception is asserted later
               }
+              bgIndicatorA1ToA2.set(true);
             });
-    assertThat(bgCounterA1.get()).isEqualTo(0);
-    assertThat(bgCounterA2.get()).isEqualTo(0);
+
     assertThat(
             gApi.accounts().id(accountId.get()).getExternalIds().stream()
                 .map(i -> i.identity)
                 .collect(toSet()))
         .containsExactly(extIdA1.key().get());
 
-    ExternalId extIdB1 = externalIdFactory.create("foo", "B-1", accountId);
-    ExternalId extIdB2 = externalIdFactory.create("foo", "B-2", accountId);
+    ExternalId extIdB1 = getExternalIdFactory().create("foo", "B-1", accountId);
+    ExternalId extIdB2 = getExternalIdFactory().create("foo", "B-2", accountId);
+    AtomicBoolean bgIndicatorA1ToB1 = new AtomicBoolean(false);
+    AtomicBoolean bgIndicatorA2ToB2 = new AtomicBoolean(false);
     Optional<AccountState> updatedAccount =
         update.update(
-            "Update External ID",
+            "Conditionally update External IDs: A1->B1, A2->B2",
             accountId,
             (a, u) -> {
               if (a.externalIds().contains(extIdA1)) {
-                bgCounterA1.getAndIncrement();
+                bgIndicatorA1ToB1.set(true);
                 u.replaceExternalId(extIdA1, extIdB1);
               }
 
               if (a.externalIds().contains(extIdA2)) {
-                bgCounterA2.getAndIncrement();
+                bgIndicatorA2ToB2.set(true);
                 u.replaceExternalId(extIdA2, extIdB2);
               }
             });
 
-    assertThat(bgCounterA1.get()).isEqualTo(1);
-    assertThat(bgCounterA2.get()).isEqualTo(1);
+    assertThat(bgIndicatorA1ToB1.get()).isTrue();
+    assertThat(bgIndicatorA2ToB2.get()).isTrue();
 
     assertThat(updatedAccount).isPresent();
     assertThat(updatedAccount.get().externalIds()).containsExactly(extIdB2);
@@ -2436,38 +2462,24 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       testRefAction(
           () -> {
-            ExternalIdNotes extIdNotes =
-                ExternalIdNotes.load(
-                    allUsers,
-                    repo,
-                    externalIdFactory,
-                    authConfig.isUserNameCaseInsensitiveMigrationMode());
+            ExternalIdNotes extIdNotes = getExternalIdNotes(repo);
 
             ExternalId.Key key = externalIdKeyFactory.create("foo", "foo");
-            extIdNotes.insert(externalIdFactory.create(key, accountId));
+            extIdNotes.insert(getExternalIdFactory().create(key, accountId));
             try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
               extIdNotes.commit(update);
             }
             assertStaleAccountAndReindex(accountId);
 
-            extIdNotes =
-                ExternalIdNotes.load(
-                    allUsers,
-                    repo,
-                    externalIdFactory,
-                    authConfig.isUserNameCaseInsensitiveMigrationMode());
-            extIdNotes.upsert(externalIdFactory.createWithEmail(key, accountId, "foo@example.com"));
+            extIdNotes = getExternalIdNotes(repo);
+            extIdNotes.upsert(
+                getExternalIdFactory().createWithEmail(key, accountId, "foo@example.com"));
             try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
               extIdNotes.commit(update);
             }
             assertStaleAccountAndReindex(accountId);
 
-            extIdNotes =
-                ExternalIdNotes.load(
-                    allUsers,
-                    repo,
-                    externalIdFactory,
-                    authConfig.isUserNameCaseInsensitiveMigrationMode());
+            extIdNotes = getExternalIdNotes(repo);
             extIdNotes.delete(accountId, key);
             try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
               extIdNotes.commit(update);
@@ -2732,11 +2744,11 @@
     String extId1String = "foo:bar";
     String extId2String = "foo:baz";
     ExternalId extId1 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse(extId1String), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse(extId1String), admin.id(), "1@foo.com");
     ExternalId extId2 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse(extId2String), user.id(), "2@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse(extId2String), user.id(), "2@foo.com");
 
     ObjectId revBefore;
     try (Repository repo = repoManager.openRepository(allUsers)) {
@@ -2776,11 +2788,11 @@
   @Test
   public void externalIdBatchUpdates_fail_sameAccount() {
     ExternalId extId1 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
     ExternalId extId2 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
@@ -2799,11 +2811,11 @@
   @Test
   public void externalIdBatchUpdates_fail_duplicateKey() {
     ExternalId extIdAdmin =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
     ExternalId extIdUser =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), user.id(), "2@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), user.id(), "2@foo.com");
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
@@ -2821,11 +2833,11 @@
   @Test
   public void externalIdBatchUpdates_commitMsg_multipleAccounts() throws Exception {
     ExternalId extId1 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
     ExternalId extId2 =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
@@ -2847,8 +2859,8 @@
   @Test
   public void externalIdBatchUpdates_commitMsg_singleAccount() throws Exception {
     ExternalId extId =
-        externalIdFactory.createWithEmail(
-            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+        getExternalIdFactory()
+            .createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
 
     accountsUpdateProvider.get().update("foobar", admin.id(), (a, u) -> u.addExternalId(extId));
 
@@ -2967,7 +2979,7 @@
     AccountState preUpdateState = accountCache.get(admin.id()).get();
     requestScopeOperations.setApiUser(admin.id());
 
-    ExternalId externalId = externalIdFactory.create("custom", "value", admin.id());
+    ExternalId externalId = getExternalIdFactory().create("custom", "value", admin.id());
     accountsUpdateProvider
         .get()
         .update("Add External ID", admin.id(), (a, u) -> u.addExternalId(externalId));
@@ -2999,8 +3011,9 @@
     requestScopeOperations.setApiUser(admin.id());
 
     ExternalId externalId =
-        externalIdFactory.createWithEmail(
-            SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
+        getExternalIdFactory()
+            .createWithEmail(
+                SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
     accountsUpdateProvider
         .get()
         .update("Update External ID", admin.id(), (a, u) -> u.updateExternalId(externalId));
@@ -3019,17 +3032,27 @@
     requestScopeOperations.setApiUser(admin.id());
 
     ExternalId externalId =
-        externalIdFactory.createWithEmail(
-            SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
+        getExternalIdFactory()
+            .createWithEmail(
+                SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
+    AtomicBoolean bgIndicator = new AtomicBoolean(false);
     accountsUpdateProvider
         .get()
         .update(
             "Replace External ID",
             admin.id(),
-            (a, u) ->
-                u.replaceExternalId(
-                    externalIds.get(createEmailExternalId(admin.id(), admin.email()).key()).get(),
-                    externalId));
+            (a, u) -> {
+              if (bgIndicator.getAndSet(true)) {
+                // In the Google architecture, this runnable might be called multiple times. Only
+                // do the replacement once.
+                return;
+              }
+              u.replaceExternalId(
+                  getExternalIdsReader()
+                      .get(createEmailExternalId(admin.id(), admin.email()).key())
+                      .get(),
+                  externalId);
+            });
     assertExternalIds(admin.id(), ImmutableSet.of("mailto:secondary@non.google", "username:admin"));
 
     AccountState updatedState = accountCache.get(admin.id()).get();
@@ -3044,10 +3067,11 @@
 
     requestScopeOperations.setApiUser(admin.id());
     ExternalId extId1 =
-        externalIdFactory.createWithEmail("custom", "admin-id", admin.id(), "admin-id@test.com");
+        getExternalIdFactory()
+            .createWithEmail("custom", "admin-id", admin.id(), "admin-id@test.com");
 
     ExternalId extId2 =
-        externalIdFactory.createWithEmail("custom", "user-id", user.id(), "user-id@test.com");
+        getExternalIdFactory().createWithEmail("custom", "user-id", user.id(), "user-id@test.com");
 
     AccountsUpdate.UpdateArguments ua1 =
         new AccountsUpdate.UpdateArguments(
@@ -3115,7 +3139,7 @@
   }
 
   protected ExternalId createEmailExternalId(Account.Id accountId, String email) {
-    return externalIdFactory.createWithEmail(SCHEME_MAILTO, email, accountId, email);
+    return getExternalIdFactory().createWithEmail(SCHEME_MAILTO, email, accountId, email);
   }
 
   @Test
@@ -3240,7 +3264,8 @@
     gApi.accounts().self().delete();
 
     requestScopeOperations.setApiUser(admin.id());
-    assertThat(externalIds.byEmails("deleted@internal.com", "deleted@external.com")).isEmpty();
+    assertThat(getExternalIdsReader().byEmails("deleted@internal.com", "deleted@external.com"))
+        .isEmpty();
 
     // Clean up the test framework
     accountCreator.evict(deleted.id());
@@ -3556,7 +3581,7 @@
     Iterable<String> expectedFps =
         expected.transform(k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
     Set<String> actualFps =
-        externalIds.byAccount(currAccountId, SCHEME_GPGKEY).stream()
+        getExternalIdsReader().byAccount(currAccountId, SCHEME_GPGKEY).stream()
             .map(e -> e.key().id())
             .collect(toSet());
     assertWithMessage("external IDs in database")
@@ -3598,7 +3623,8 @@
               account.id(),
               u ->
                   u.addExternalId(
-                      externalIdFactory.createWithEmail(name("test"), email, account.id(), email)));
+                      getExternalIdFactory()
+                          .createWithEmail(name("test"), email, account.id(), email)));
       accountIndexedCounter.assertReindexOf(account);
       requestScopeOperations.setApiUser(account.id());
     }
@@ -3677,8 +3703,9 @@
         "login?account_id=" + accountId, HttpServletResponse.SC_MOVED_TEMPORARILY);
   }
 
-  private AccountsUpdate getAccountsUpdate(Runnable afterReadRevision, Runnable beforeCommit) {
-    return getAccountsUpdate(
+  private AccountsUpdate getAccountsUpdateWithRunnables(
+      Runnable afterReadRevision, Runnable beforeCommit) {
+    return getAccountsUpdateWithRunnables(
         afterReadRevision,
         beforeCommit,
         new RetryHelper(
@@ -3692,14 +3719,40 @@
             r -> r.withBlockStrategy(noSleepBlockStrategy)));
   }
 
-  private AccountsUpdate getAccountsUpdate(
+  private ExternalIdNotes getExternalIdNotes(Repository allUsersRepo)
+      throws ConfigInvalidException, IOException {
+    return ExternalIdNotes.load(
+        allUsers,
+        allUsersRepo,
+        externalIdFactoryNoteDbImpl,
+        authConfig.isUserNameCaseInsensitiveMigrationMode());
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected ExternalIdFactory getExternalIdFactory() {
+    return externalIdFactoryNoteDbImpl;
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected ExternalIds getExternalIdsReader() {
+    return externalIdsNoteDbImpl;
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected AccountsUpdate getAccountsUpdateWithRunnables(
+      Runnable afterReadRevision, Runnable beforeCommit, RetryHelper retryHelper) {
+    return getAccountsUpdateNoteDbImplWithRunnables(afterReadRevision, beforeCommit, retryHelper);
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected final AccountsUpdateNoteDbImpl getAccountsUpdateNoteDbImplWithRunnables(
       Runnable afterReadRevision, Runnable beforeCommit, RetryHelper retryHelper) {
     return new AccountsUpdateNoteDbImpl(
         repoManager,
         gitReferenceUpdated,
         Optional.empty(),
         allUsers,
-        externalIds,
+        externalIdsNoteDbImpl,
         extIdNotesFactory,
         metaDataUpdateInternalFactory,
         retryHelper,
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index 7449a5c..07ce95b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.SetInactiveFlag;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
@@ -593,7 +594,7 @@
 
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
-    assertThat(thrown).hasMessageThat().contains("Cannot assign external ID \"username:foo\" to");
+    assertThat(thrown).hasCauseThat().isInstanceOf(DuplicateExternalIdKeyException.class);
   }
 
   @Test
@@ -647,9 +648,7 @@
 
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("Cannot assign external ID \"username:foo\" to account");
+    assertThat(thrown).hasCauseThat().isInstanceOf(DuplicateExternalIdKeyException.class);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 12d3ced..8d30d45 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -1152,11 +1152,13 @@
         .modifyFile(FILE_NAME, RawInputUtil.create("foo".getBytes(UTF_8)));
 
     requestScopeOperations.setApiUser(admin.id());
+    String expected = RefNames.refsUsers(user.id()) + "/edit-" + result.getChange().getId() + "/1";
     try (Repository repo = repoManager.openRepository(project)) {
-      String expected =
-          RefNames.refsUsers(user.id()) + "/edit-" + result.getChange().getId() + "/1";
       assertThat(repo.getRefDatabase().getRefsByPrefix(expected)).isNotEmpty();
       gApi.changes().id(changeId).delete();
+    }
+    // On google infra, repo should be reopened for getting updated refs.
+    try (Repository repo = repoManager.openRepository(project)) {
       assertThat(repo.getRefDatabase().getRefsByPrefix(expected)).isEmpty();
     }
   }
@@ -1457,7 +1459,9 @@
     assertThat(r.reviewers).hasSize(1);
     ReviewerInfo reviewer = r.reviewers.get(0);
     assertThat(reviewer._accountId).isEqualTo(id.get());
-    assertThat(reviewer.username).isEqualTo(username);
+    if (server.isUsernameSupported()) {
+      assertThat(reviewer.username).isEqualTo(username);
+    }
   }
 
   @Test
@@ -1476,7 +1480,9 @@
     assertThat(r.reviewers).hasSize(1);
     ReviewerInfo reviewer = r.reviewers.get(0);
     assertThat(reviewer._accountId).isEqualTo(id.get());
-    assertThat(reviewer.username).isEqualTo(username);
+    if (server.isUsernameSupported()) {
+      assertThat(reviewer.username).isEqualTo(username);
+    }
   }
 
   @Test
@@ -1485,20 +1491,20 @@
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
     PushOneCommit.Result result = createChange();
-    String username = "user@domain.com";
-    Account.Id id = accountOperations.newAccount().username(username).inactive().create();
+    String email = "user@domain.com";
+    Account.Id id = accountOperations.newAccount().preferredEmail(email).inactive().create();
 
     ReviewerInput in = new ReviewerInput();
-    in.reviewer = username;
+    in.reviewer = email;
     in.state = ReviewerState.CC;
     ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
-    assertThat(r.input).isEqualTo(username);
+    assertThat(r.input).isEqualTo(email);
     assertThat(r.error).isNull();
     assertThat(r.ccs).hasSize(1);
     AccountInfo reviewer = r.ccs.get(0);
     assertThat(reviewer._accountId).isEqualTo(id.get());
-    assertThat(reviewer.username).isEqualTo(username);
+    assertThat(reviewer.email).isEqualTo(email);
   }
 
   @Test
@@ -1587,17 +1593,22 @@
 
     String username1 = name("user1");
     String email1 = username1 + "@example.com";
-    accountOperations
-        .newAccount()
-        .username(username1)
-        .preferredEmail(email1)
-        .fullname("User1")
-        .create();
+    Account.Id user1Id =
+        accountOperations
+            .newAccount()
+            .username(username1)
+            .preferredEmail(email1)
+            .fullname("User1")
+            .create();
     in.reviewer = email1;
     in.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(in);
-    assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a.username))
-        .containsExactly(user.username(), username1);
+    if (server.isUsernameSupported()) {
+      assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a.username))
+          .containsExactly(user.username(), username1);
+    }
+    assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a._accountId))
+        .containsExactly(user.id().get(), user1Id.get());
   }
 
   @Test
@@ -1821,7 +1832,9 @@
   @Test
   public void implicitlyCcOnNonVotingReviewForUserWithoutUserNamePgStyle() throws Exception {
     com.google.gerrit.acceptance.TestAccount accountWithoutUsername = accountCreator.create();
-    assertThat(accountWithoutUsername.username()).isNull();
+    if (server.isUsernameSupported()) {
+      assertThat(accountWithoutUsername.username()).isNull();
+    }
     testImplicitlyCcOnNonVotingReviewPgStyle(accountWithoutUsername);
   }
 
@@ -3470,7 +3483,9 @@
     // permittedVotingRange is not served if DETAILED_LABELS is not requested.
     assertThat(codeReviewApproval.permittedVotingRange).isNull();
     assertThat(codeReviewApproval.value).isEqualTo(1);
-    assertThat(codeReviewApproval.username).isEqualTo(admin.username());
+    if (server.isUsernameSupported()) {
+      assertThat(codeReviewApproval.username).isEqualTo(admin.username());
+    }
 
     // Add another +1 vote as user
     requestScopeOperations.setApiUser(user.id());
@@ -3486,8 +3501,12 @@
         .containsExactly(null, null);
     assertThat(codeReviewApprovals.stream().map(a -> a.value).collect(toList()))
         .containsExactly(1, 1);
-    assertThat(codeReviewApprovals.stream().map(a -> a.username).collect(toList()))
-        .containsExactly(admin.username(), user.username());
+    if (server.isUsernameSupported()) {
+      assertThat(codeReviewApprovals.stream().map(a -> a.username).collect(toList()))
+          .containsExactly(admin.username(), user.username());
+    }
+    assertThat(codeReviewApprovals.stream().map(a -> a._accountId).collect(toList()))
+        .containsExactly(admin.id().get(), user.id().get());
   }
 
   @Test
@@ -3602,12 +3621,16 @@
 
     assertThat(codeReviewApprovals).hasSize(1);
     assertThat(codeReviewApprovals.get(0).value).isEqualTo(2);
-    assertThat(codeReviewApprovals.get(0).username).isEqualTo(admin.username());
+    if (server.isUsernameSupported()) {
+      assertThat(codeReviewApprovals.get(0).username).isEqualTo(admin.username());
+    }
     assertThat(codeReviewApprovals.get(0).permittedVotingRange).isNull();
 
     assertThat(verifiedApprovals).hasSize(1);
     assertThat(verifiedApprovals.get(0).value).isEqualTo(1);
-    assertThat(verifiedApprovals.get(0).username).isEqualTo(admin.username());
+    if (server.isUsernameSupported()) {
+      assertThat(verifiedApprovals.get(0).username).isEqualTo(admin.username());
+    }
     assertThat(codeReviewApprovals.get(0).permittedVotingRange).isNull();
   }
 
@@ -4154,6 +4177,8 @@
     PushOneCommit push = pushFactory.create(user.newIdent(), userTestRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertOkStatus();
+    assertThat(gApi.changes().id(r.getChangeId()).info().owner._accountId)
+        .isEqualTo(user.id().get());
     // Try to change the commit message
     AuthException thrown =
         assertThrows(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 3771bb9..314dc44 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -93,6 +93,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -771,6 +772,20 @@
   }
 
   @Test
+  public void submitRulesAreInvokedOnlyOnce_allOptionsSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestSubmitRule testSubmitRule = new TestSubmitRule();
+    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      input.responseFormatOptions = ImmutableList.copyOf(EnumSet.allOf(ListChangesOption.class));
+      gApi.changes().id(r.getChangeId()).current().review(input);
+    }
+
+    assertThat(testSubmitRule.count).isEqualTo(1);
+  }
+
+  @Test
   public void addingReviewers() throws Exception {
     PushOneCommit.Result r = createChange();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index cd687f3..b69ca1c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -23,6 +23,7 @@
 
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -155,6 +156,10 @@
 
     // With conflicting options
     assertBadRequest(getTags().withSubstring("ag-B").withRegex("^tag-[c|d]$"));
+
+    // with descending order
+    result = getTags().withDescendingOrder(true).get();
+    assertTagList(FluentIterable.from(Lists.reverse(testTags)), result);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
index 35082ec..6bfd988 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
@@ -33,7 +33,7 @@
 import com.google.common.truth.StandardSubjectBuilder;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.common.truth.Truth8;
+import com.google.common.truth.Truth;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -489,7 +489,7 @@
                       approvalData.patchSetApproval().label().equals(labelId)
                           && approvalData.patchSetApproval().accountId().equals(accountId))
               .findAny();
-      Truth8.assertThat(approvalDataForLabelAndAccount).isPresent();
+      Truth.assertThat(approvalDataForLabelAndAccount).isPresent();
       return assertAbout(approvalDatas()).that(approvalDataForLabelAndAccount.get());
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
index d0610b3..24767cb 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
@@ -22,7 +22,6 @@
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.truth.Truth8;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -124,7 +123,7 @@
 
     OptionalLong tokens =
         quotaBackend.user(identifiedAdmin).availableTokens("testGroup").availableTokens();
-    Truth8.assertThat(tokens).isPresent();
+    assertThat(tokens).isPresent();
     assertThat(tokens.getAsLong()).isEqualTo(10L);
 
     verify(quotaEnforcerA).availableTokens("testGroup", ctx);
@@ -139,7 +138,7 @@
 
     OptionalLong tokens =
         quotaBackend.user(identifiedAdmin).availableTokens("testGroup").availableTokens();
-    Truth8.assertThat(tokens).isPresent();
+    assertThat(tokens).isPresent();
     assertThat(tokens.getAsLong()).isEqualTo(20L);
 
     verify(quotaEnforcerA).availableTokens("testGroup", ctx);
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 44fc4bb..6204115 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.extensions.restapi.testing.BinaryResultSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 1adfc98..fd8ddd2 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -67,6 +67,7 @@
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/schema/testing",
+        "//java/com/google/gerrit/server/util/git",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/sshd",
         "//java/com/google/gerrit/testing:assertable-executor",
diff --git a/javatests/com/google/gerrit/server/config/GerritInstanceNameProviderTest.java b/javatests/com/google/gerrit/server/config/GerritInstanceNameProviderTest.java
new file mode 100644
index 0000000..edf9b8b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/GerritInstanceNameProviderTest.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.util.git.DelegateSystemReader;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.util.SystemReader;
+import org.junit.Test;
+
+public class GerritInstanceNameProviderTest {
+
+  @Test
+  public void instanceNameSet_canonicalWebUrlUnset_useInstanceName() {
+    Config cfg = new Config();
+    cfg.setString("gerrit", null, "instanceName", "myName");
+
+    GerritInstanceNameProvider provider = new GerritInstanceNameProvider(cfg, null);
+    assertThat(provider.get()).isEqualTo("myName");
+  }
+
+  @Test
+  public void instanceNameSet_canonicalWebUrlSet_useInstanceName() {
+    Config cfg = new Config();
+    cfg.setString("gerrit", null, "instanceName", "myName");
+
+    GerritInstanceNameProvider provider = new GerritInstanceNameProvider(cfg, "http://myhost");
+    assertThat(provider.get()).isEqualTo("myName");
+  }
+
+  @Test
+  public void instanceNameNotSet_canonicalWebUrlSet_useHostFromCanonicalWebUrl() {
+    Config cfg = new Config();
+    GerritInstanceNameProvider provider = new GerritInstanceNameProvider(cfg, "http://myhost");
+    assertThat(provider.get()).isEqualTo("myhost");
+  }
+
+  @Test
+  public void instanceNameNotSet_canonicalWebUrlNotSet_useSystemHostName() {
+    SystemReader oldSystemReader = SystemReader.getInstance();
+    SystemReader.setInstance(
+        new DelegateSystemReader(oldSystemReader) {
+          @Override
+          public String getHostname() {
+            return "systemHostName";
+          }
+        });
+    Config cfg = new Config();
+    GerritInstanceNameProvider provider = new GerritInstanceNameProvider(cfg, null);
+    assertThat(provider.get()).isEqualTo("systemHostName");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
index e40ccfc..8d93f66 100644
--- a/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/RepositoryConfigTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.truth.Truth8;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.SubmitType;
 import java.nio.file.Path;
@@ -148,7 +147,7 @@
 
   @Test
   public void basePathWhenNotConfigured() {
-    Truth8.assertThat(repoCfg.getBasePath(Project.nameKey("someProject"))).isNull();
+    assertThat(repoCfg.getBasePath(Project.nameKey("someProject"))).isNull();
   }
 
   @Test
@@ -162,7 +161,7 @@
   public void basePathForSpecificFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("someProject", basePath);
-    Truth8.assertThat(repoCfg.getBasePath(Project.nameKey("someOtherProject"))).isNull();
+    assertThat(repoCfg.getBasePath(Project.nameKey("someOtherProject"))).isNull();
     assertThat(repoCfg.getBasePath(Project.nameKey("someProject")).toString()).isEqualTo(basePath);
   }
 
diff --git a/javatests/com/google/gerrit/server/config/SitePathsTest.java b/javatests/com/google/gerrit/server/config/SitePathsTest.java
index 6ecf549..1de6c30 100644
--- a/javatests/com/google/gerrit/server/config/SitePathsTest.java
+++ b/javatests/com/google/gerrit/server/config/SitePathsTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
-import com.google.common.truth.Truth8;
 import com.google.gerrit.server.ioutil.HostPlatform;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -31,8 +30,8 @@
     final Path root = random();
     final SitePaths site = new SitePaths(root);
     assertThat(site.isNew).isTrue();
-    Truth8.assertThat(site.site_path).isEqualTo(root);
-    Truth8.assertThat(site.etc_dir).isEqualTo(root.resolve("etc"));
+    assertThat(site.site_path).isEqualTo(root);
+    assertThat(site.etc_dir).isEqualTo(root.resolve("etc"));
   }
 
   @Test
@@ -43,7 +42,7 @@
 
       final SitePaths site = new SitePaths(root);
       assertThat(site.isNew).isTrue();
-      Truth8.assertThat(site.site_path).isEqualTo(root);
+      assertThat(site.site_path).isEqualTo(root);
     } finally {
       Files.delete(root);
     }
@@ -59,7 +58,7 @@
 
       final SitePaths site = new SitePaths(root);
       assertThat(site.isNew).isFalse();
-      Truth8.assertThat(site.site_path).isEqualTo(root);
+      assertThat(site.site_path).isEqualTo(root);
     } finally {
       Files.delete(txt);
       Files.delete(root);
@@ -83,15 +82,15 @@
     final Path root = random();
     final SitePaths site = new SitePaths(root);
 
-    Truth8.assertThat(site.resolve(null)).isNull();
-    Truth8.assertThat(site.resolve("")).isNull();
+    assertThat(site.resolve(null)).isNull();
+    assertThat(site.resolve("")).isNull();
 
-    Truth8.assertThat(site.resolve("a")).isNotNull();
-    Truth8.assertThat(site.resolve("a")).isEqualTo(root.resolve("a").toAbsolutePath().normalize());
+    assertThat(site.resolve("a")).isNotNull();
+    assertThat(site.resolve("a")).isEqualTo(root.resolve("a").toAbsolutePath().normalize());
 
     final String pfx = HostPlatform.isWin32() ? "C:/" : "/";
-    Truth8.assertThat(site.resolve(pfx + "a")).isNotNull();
-    Truth8.assertThat(site.resolve(pfx + "a")).isEqualTo(Path.of(pfx + "a"));
+    assertThat(site.resolve(pfx + "a")).isNotNull();
+    assertThat(site.resolve(pfx + "a")).isEqualTo(Path.of(pfx + "a"));
   }
 
   private static Path random() throws IOException {
diff --git a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
index f335201..7ed2391 100644
--- a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
+++ b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 6919982..f406511 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -153,6 +153,7 @@
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 import {KnownExperimentId} from '../../../services/flags/flags';
+import {assign} from '../../../utils/location-util';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -1810,7 +1811,10 @@
     if (this.loggedIn) {
       this.openReplyDialog(FocusTarget.ANY);
     } else {
-      this.getNavigation().setUrl(this.loginUrl);
+      // We are not using `this.getNavigation().setUrl()`, because the login
+      // page is served directly from the backend and is not part of the web
+      // app.
+      assign(window.location, this.loginUrl);
     }
   }
 
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 94241ea..3d21e07 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -10,7 +10,12 @@
 export interface NavigationService {
   /**
    * This is similar to letting the browser navigate to this URL when the user
-   * clicks it, or to just setting `window.location.href` directly.
+   * clicks it, or to just calling `window.location.assign()` directly.
+   *
+   * CAUTION: You should actually use `window.location.assign()` directly for
+   * URLs that are not handled by gr-router. Otherwise we will call
+   * `pushState()` and then `window.location.reload()` from the router, which
+   * will break the browser's back button.
    *
    * This adds a new entry to the browser location history. Consier using
    * `replaceUrl()`, if you want to avoid that.
@@ -23,6 +28,11 @@
    * Navigate to this URL, but replace the current URL in the history instead of
    * adding a new one (which is what `setUrl()` would do).
    *
+   * CAUTION: You should actually use `window.location.replace()` directly for
+   * URLs that are not handled by gr-router. Otherwise we will call
+   * `replaceState()` and then `window.location.reload()` from the router, which
+   * will break the browser's back button.
+   *
    * page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string): void;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
index 5e7cc3b..cb96d77 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
@@ -53,6 +53,11 @@
   path?: string;
 }
 
+export const UNHANDLED_URL_PATTERNS = [
+  /^\/log(in|out)(\/(.+))?$/,
+  /^\/plugins\/(.+)$/,
+];
+
 const clickEvent = document.ontouchstart ? 'touchstart' : 'click';
 
 export class Page {
@@ -239,6 +244,18 @@
     if (this.base && orig === path && window.location.protocol !== 'file:') {
       return;
     }
+
+    // See issue 40015337: We have to make sure that we only use
+    // show()/pushState() for URLs that gr-router will actually handle.
+    // Calling pushState() tells the browser that both the previous and the
+    // next URL are handled by the same single page application with a
+    // popstate event handler. But if we call pushState() and then
+    // later `window.location.reload()` from the router and a separate page
+    // and document are loaded, then the BACK button will stop working.
+    if (UNHANDLED_URL_PATTERNS.find(pattern => pattern.test(path))) {
+      return;
+    }
+
     e.preventDefault();
     this.show(orig);
   };
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
index d194bf55..729a15b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
@@ -20,14 +20,49 @@
     page.stop();
   });
 
-  test('click handler', async () => {
-    const spy = sinon.spy();
-    page.registerRoute(/\/settings/, spy);
-    const link = await fixture<HTMLAnchorElement>(
-      html`<a href="/settings"></a>`
-    );
-    link.click();
-    assert.isTrue(spy.calledOnce);
+  suite('click handler', () => {
+    const clickListener = (e: Event) => e.preventDefault();
+    let spy: sinon.SinonSpy;
+    let link: HTMLAnchorElement;
+
+    setup(async () => {
+      spy = sinon.spy();
+      link = await fixture<HTMLAnchorElement>(html`<a href="/settings"></a>`);
+
+      document.addEventListener('click', clickListener);
+    });
+
+    teardown(() => {
+      document.removeEventListener('click', clickListener);
+    });
+
+    test('click handled by specific route', async () => {
+      page.registerRoute(/\/settings/, spy);
+      link.href = '/settings';
+      link.click();
+      assert.isTrue(spy.calledOnce);
+    });
+
+    test('click handled by default route', async () => {
+      page.registerRoute(/.*/, spy);
+      link.href = '/something';
+      link.click();
+      assert.isTrue(spy.called);
+    });
+
+    test('click not handled for /plugins/... links', async () => {
+      page.registerRoute(/.*/, spy);
+      link.href = '/plugins/gitiles';
+      link.click();
+      assert.isFalse(spy.called);
+    });
+
+    test('click not handled for /login/... links', async () => {
+      page.registerRoute(/.*/, spy);
+      link.href = '/login';
+      link.click();
+      assert.isFalse(spy.called);
+    });
   });
 
   test('register route and exit', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index a3cb0ee..ad3e15c 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -106,6 +106,7 @@
   timeoutPromise,
 } from '../../../utils/async-util';
 import {Finalizable} from '../../../types/types';
+import {assign} from '../../../utils/location-util';
 
 // TODO: Move all patterns to view model files and use the `Route` interface,
 // which will enforce using `RegExp` in its `urlPattern` property.
@@ -120,12 +121,6 @@
   NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
   REGISTER: /^\/register(\/.*)?$/,
 
-  // Pattern for login and logout URLs intended to be passed-through. May
-  // include a return URL.
-  // TODO: Maybe this pattern and its handler can just be removed, because
-  // passing through is what the default router would eventually do anyway.
-  LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
-
   // Pattern for a catchall route when no other pattern is matched.
   DEFAULT: /.*/,
 
@@ -171,8 +166,6 @@
   // Matches /admin/repos/<repos>,access.
   REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
 
-  PLUGINS: /^\/plugins\/(.+)$/,
-
   // Matches /admin/plugins with optional filter and offset.
   PLUGIN_LIST: /^\/admin\/plugins\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
   // Matches /admin/groups with optional filter and offset.
@@ -460,7 +453,11 @@
    */
   redirectToLogin(returnUrl: string) {
     const basePath = getBaseUrl() || '';
-    this.setUrl(
+    // We are not using `this.getNavigation().setUrl()`, because the login
+    // page is served directly from the backend and is not part of the web
+    // app.
+    assign(
+      window.location,
       '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
     );
   }
@@ -583,6 +580,8 @@
    * page.show() eventually just calls `window.history.pushState()`.
    */
   setUrl(url: string) {
+    // TODO: Use window.location.assign() instead of page.show(), if the URL is
+    // external, i.e. not handled by the router.
     this.page.show(url);
   }
 
@@ -593,6 +592,8 @@
    * this.page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string) {
+    // TODO: Use window.location.replace() instead of page.redirect(), if the
+    // URL is external, i.e. not handled by the router.
     this.redirect(url);
   }
 
@@ -809,10 +810,6 @@
       this.handleRepoRoute(ctx)
     );
 
-    this.mapRoute(RoutePattern.PLUGINS, 'handlePassThroughRoute', () =>
-      this.handlePassThroughRoute()
-    );
-
     this.mapRoute(
       RoutePattern.PLUGIN_LIST,
       'handlePluginListFilterRoute',
@@ -921,10 +918,6 @@
       this.handleRegisterRoute(ctx)
     );
 
-    this.mapRoute(RoutePattern.LOG_IN_OR_OUT, 'handlePassThroughRoute', () =>
-      this.handlePassThroughRoute()
-    );
-
     this.mapRoute(
       RoutePattern.IMPROPERLY_ENCODED_PLUS,
       'handleImproperlyEncodedPlusRoute',
@@ -1589,14 +1582,6 @@
   }
 
   /**
-   * Handler for routes that should pass through the router and not be caught
-   * by the catchall _handleDefaultRoute handler.
-   */
-  handlePassThroughRoute() {
-    windowLocationReload();
-  }
-
-  /**
    * URL may sometimes have /+/ encoded to / /.
    * Context: Issue 6888, Issue 7100
    */
@@ -1651,10 +1636,15 @@
       this.show404();
     } else {
       // Route can be recognized by server, so we pass it to server.
-      this.handlePassThroughRoute();
+      this.windowReload();
     }
   }
 
+  // Allows stubbing in tests.
+  windowReload() {
+    windowLocationReload();
+  }
+
   private show404() {
     // Note: the app's 404 display is tightly-coupled with catching 404
     // network responses, so we simulate a 404 response status to display it.
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index fbc0338..6f4e528 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -181,7 +181,6 @@
       'handleDocumentationSearchRedirectRoute',
       'handleLegacyLinenum',
       'handleImproperlyEncodedPlusRoute',
-      'handlePassThroughRoute',
       'handleProjectDashboardRoute',
       'handleLegacyProjectDashboardRoute',
       'handleProjectsOldRoute',
@@ -334,7 +333,7 @@
   suite('route handlers', () => {
     let redirectStub: sinon.SinonStub;
     let setStateStub: sinon.SinonStub;
-    let handlePassThroughRoute: sinon.SinonStub;
+    let windowReloadStub: sinon.SinonStub;
     let redirectToLoginStub: sinon.SinonStub;
 
     async function checkUrlToState<T extends ViewState>(
@@ -367,18 +366,12 @@
       assert.equal(redirectToLoginStub.lastCall.firstArg, toUrl);
     }
 
-    async function checkUrlNotMatched(url: string) {
-      handlePassThroughRoute.reset();
-      router.page.show(url);
-      await waitUntilCalled(handlePassThroughRoute, 'handlePassThroughRoute');
-    }
-
     setup(() => {
       stubRestApi('addRepoNameToCache');
       redirectStub = sinon.stub(router, 'redirect');
       redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
       setStateStub = sinon.stub(router, 'setState');
-      handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
+      windowReloadStub = sinon.stub(router, 'windowReload');
       router._testOnly_startRouter();
     });
 
@@ -445,7 +438,7 @@
       onExit!('', () => {}); // we left page;
 
       router.handleDefaultRoute();
-      assert.isTrue(handlePassThroughRoute.calledOnce);
+      assert.isTrue(windowReloadStub.calledOnce);
     });
 
     test('IMPROPERLY_ENCODED_PLUS', async () => {
@@ -1039,12 +1032,6 @@
       });
     });
 
-    test('LOG_IN_OR_OUT pass through', async () => {
-      // LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
-      await checkUrlNotMatched('/login/asdf');
-      await checkUrlNotMatched('/logout/asdf');
-    });
-
     test('PLUGIN_SCREEN', async () => {
       // PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
       await checkUrlToState('/x/foo/bar', {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index bc9bd12..1640b7a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -15,6 +15,7 @@
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import '../gr-account-label/gr-account-label';
 import '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import '../gr-fix-suggestions/gr-fix-suggestions';
 import {getAppContext} from '../../../services/app-context';
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
@@ -91,7 +92,7 @@
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
 export const GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS = 1500;
 export const ENABLE_GENERATE_SUGGESTION_STORAGE_KEY =
-  'enableGenerateSuggestionStorageKey';
+  'enableGenerateSuggestionStorageKeyForCommentWithId-';
 
 declare global {
   interface HTMLElementEventMap {
@@ -313,8 +314,16 @@
     for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
       this.shortcuts.addLocal(
         {key: Key.ENTER, modifiers: [modifier]},
-        () => {
+        e => {
           this.save();
+          // We don't stop propagation for patchset comment
+          // (this.permanentEditingMode = true), but we stop it for normal
+          // comments. This prevents accidentally sending a reply when
+          // editing/saving them in the reply dialog.
+          if (!this.permanentEditingMode) {
+            e.preventDefault();
+            e.stopPropagation();
+          }
         },
         {preventDefault: false}
       );
@@ -416,12 +425,14 @@
         this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
       });
 
-    const generateSuggestionStoredContent =
-      this.getStorage().getEditableContentItem(
-        ENABLE_GENERATE_SUGGESTION_STORAGE_KEY
-      );
-    if (generateSuggestionStoredContent?.message === 'false') {
-      this.generateSuggestion = false;
+    if (this.comment?.id) {
+      const generateSuggestionStoredContent =
+        this.getStorage().getEditableContentItem(
+          ENABLE_GENERATE_SUGGESTION_STORAGE_KEY + this.comment.id
+        );
+      if (generateSuggestionStoredContent?.message === 'false') {
+        this.generateSuggestion = false;
+      }
     }
   }
 
@@ -661,8 +672,8 @@
             <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
             ${this.renderHumanActions()} ${this.renderRobotActions()}
           </div>
-          ${this.renderGeneratedSuggestionPreview()}
-          ${this.renderFixSuggestionPreview()}
+          ${/* if this.editing */ this.renderGeneratedSuggestionPreview()}
+          ${/* if !this.editing */ this.renderFixSuggestionPreview()}
         </div>
       </gr-endpoint-decorator>
       ${this.renderConfirmDialog()}
@@ -999,10 +1010,11 @@
   }
 
   private renderFixSuggestionPreview() {
-    if (!this.comment?.fix_suggestions || this.editing) return nothing;
-    return html`<gr-suggestion-diff-preview
-      .fixReplacementInfos=${this.comment?.fix_suggestions?.[0].replacements}
-    ></gr-suggestion-diff-preview>`;
+    if (!this.comment?.fix_suggestions || this.editing || isRobot(this.comment))
+      return nothing;
+    return html`<gr-fix-suggestions
+      .comment=${this.comment}
+    ></gr-fix-suggestions>`;
   }
 
   // private but used in test
@@ -1029,13 +1041,17 @@
   }
 
   private renderGeneratedSuggestionPreview() {
-    if (!this.showGeneratedSuggestion() || !this.generateSuggestion)
+    if (
+      !this.editing ||
+      !this.showGeneratedSuggestion() ||
+      !this.generateSuggestion
+    )
       return nothing;
     if (!isDraft(this.comment)) return nothing;
 
     if (this.generatedFixSuggestion) {
       return html`<gr-suggestion-diff-preview
-        .fixReplacementInfos=${this.generatedFixSuggestion.replacements}
+        .fixSuggestionInfo=${this.generatedFixSuggestion}
       ></gr-suggestion-diff-preview>`;
     } else if (this.generatedSuggestion) {
       return html`<gr-suggestion-diff-preview
@@ -1063,10 +1079,12 @@
             ?checked=${this.generateSuggestion}
             @change=${() => {
               this.generateSuggestion = !this.generateSuggestion;
-              this.getStorage().setEditableContentItem(
-                ENABLE_GENERATE_SUGGESTION_STORAGE_KEY,
-                this.generateSuggestion.toString()
-              );
+              if (this.comment?.id) {
+                this.getStorage().setEditableContentItem(
+                  ENABLE_GENERATE_SUGGESTION_STORAGE_KEY + this.comment.id,
+                  this.generateSuggestion.toString()
+                );
+              }
               if (this.generateSuggestion) {
                 this.generateSuggestionTrigger$.next();
               } else {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 4c32da7..098b79a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -39,7 +39,7 @@
 import {ReplyToCommentEvent} from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {assertIsDefined} from '../../../utils/common-util';
-import {Modifier} from '../../../utils/dom-util';
+import {Key, Modifier} from '../../../utils/dom-util';
 import {SinonStubbedMember} from 'sinon';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../gr-button/gr-button';
@@ -241,7 +241,6 @@
                   </gr-button>
                 </div>
               </div>
-              <gr-suggestion-diff-preview></gr-suggestion-diff-preview>
             </div>
           </gr-endpoint-decorator>
           <dialog id="confirmDeleteModal" tabindex="-1">
@@ -597,6 +596,46 @@
       assert.isTrue(spy.called);
     });
 
+    suite('ctrl+ENTER  ', () => {
+      test('saves comment', async () => {
+        const spy = sinon.stub(element, 'save');
+        element.messageText = 'is that the horse from horsing around??';
+        element.editing = true;
+        await element.updateComplete;
+        pressKey(
+          element.textarea!.textarea!.textarea,
+          Key.ENTER,
+          Modifier.CTRL_KEY
+        );
+        assert.isTrue(spy.called);
+      });
+      test('propagates on patchset comment', async () => {
+        const event = new KeyboardEvent('keydown', {
+          key: Key.ENTER,
+          ctrlKey: true,
+        });
+        const stopPropagationStub = sinon.stub(event, 'stopPropagation');
+        element.permanentEditingMode = true;
+        element.messageText = 'is that the horse from horsing around??';
+        element.editing = true;
+        await element.updateComplete;
+        element.dispatchEvent(event);
+        assert.isFalse(stopPropagationStub.called);
+      });
+      test('does not propagate on normal comment', async () => {
+        const event = new KeyboardEvent('keydown', {
+          key: Key.ENTER,
+          ctrlKey: true,
+        });
+        const stopPropagationStub = sinon.stub(event, 'stopPropagation');
+        element.messageText = 'is that the horse from horsing around??';
+        element.editing = true;
+        await element.updateComplete;
+        element.dispatchEvent(event);
+        assert.isTrue(stopPropagationStub.called);
+      });
+    });
+
     test('save', async () => {
       const savePromise = mockPromise<DraftInfo>();
       const stub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
@@ -956,8 +995,8 @@
       element.editing = false;
       await element.updateComplete;
       assert.dom.equal(
-        queryAndAssert(element, 'gr-suggestion-diff-preview'),
-        /* HTML */ '<gr-suggestion-diff-preview> </gr-suggestion-diff-preview>'
+        queryAndAssert(element, 'gr-fix-suggestions'),
+        /* HTML */ '<gr-fix-suggestions> </gr-fix-suggestions>'
       );
     });
 
@@ -985,8 +1024,8 @@
       element.editing = false;
       await element.updateComplete;
       assert.dom.equal(
-        queryAndAssert(element, 'gr-suggestion-diff-preview'),
-        /* HTML */ '<gr-suggestion-diff-preview> </gr-suggestion-diff-preview>'
+        queryAndAssert(element, 'gr-fix-suggestions'),
+        /* HTML */ '<gr-fix-suggestions> </gr-fix-suggestions>'
       );
     });
 
@@ -1035,7 +1074,7 @@
           .initiallyCollapsed=${false}
         ></gr-comment>`
       );
-      element.editing = false;
+      element.editing = true;
       sinon.stub(element, 'showGeneratedSuggestion').returns(true);
       element.generateSuggestion = true;
       element.generatedFixSuggestion = generatedFixSuggestion;
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
new file mode 100644
index 0000000..02d64ff
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
@@ -0,0 +1,162 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {css, html, LitElement} from 'lit';
+import {customElement, state, query, property} from 'lit/decorators.js';
+import {fire} from '../../../utils/event-util';
+import {getDocUrl} from '../../../utils/url-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {changeModelToken} from '../../../models/change/change-model';
+import {Comment, isDraft, PatchSetNumber} from '../../../types/common';
+import {OpenFixPreviewEventDetail} from '../../../types/events';
+
+/**
+ * gr-fix-suggestions is UI for comment.fix_suggestions.
+ * gr-fix-suggestions is wrapper for gr-suggestion-diff-preview with buttons
+ * to preview and apply fix and for giving a context about suggestion.
+ */
+@customElement('gr-fix-suggestions')
+export class GrFixSuggestions extends LitElement {
+  @query('gr-suggestion-diff-preview')
+  suggestionDiffPreview?: GrSuggestionDiffPreview;
+
+  @property({type: Object})
+  comment?: Comment;
+
+  @state() private docsBaseUrl = '';
+
+  @state() private applyingFix = false;
+
+  @state() latestPatchNum?: PatchSetNumber;
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
+  }
+
+  static override get styles() {
+    return [
+      css`
+        .header {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--border-color);
+          padding: var(--spacing-xs) var(--spacing-xl);
+          display: flex;
+          align-items: center;
+          border-top-left-radius: var(--border-radius);
+          border-top-right-radius: var(--border-radius);
+        }
+        .header .title {
+          flex: 1;
+        }
+        .copyButton {
+          margin-right: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<div class="header">
+        <div class="title">
+          <span>Suggested edit</span>
+          <a
+            href=${getDocUrl(this.docsBaseUrl, 'user-suggest-edits.html')}
+            target="_blank"
+            rel="noopener noreferrer"
+            ><gr-icon icon="help" title="read documentation"></gr-icon
+          ></a>
+        </div>
+        <div>
+          <gr-button
+            secondary
+            flatten
+            class="action show-fix"
+            @click=${this.handleShowFix}
+          >
+            Show edit
+          </gr-button>
+          <gr-button
+            secondary
+            flatten
+            .loading=${this.applyingFix}
+            .disabled=${this.isApplyEditDisabled()}
+            class="action show-fix"
+            @click=${this.handleApplyFix}
+            .title=${this.computeApplyEditTooltip()}
+          >
+            Apply edit
+          </gr-button>
+        </div>
+      </div>
+      <gr-suggestion-diff-preview
+        .fixSuggestionInfo=${this.comment?.fix_suggestions?.[0]}
+      ></gr-suggestion-diff-preview>`;
+  }
+
+  handleShowFix() {
+    if (!this.comment?.fix_suggestions || !this.comment?.patch_set) return;
+    const eventDetail: OpenFixPreviewEventDetail = {
+      fixSuggestions: this.comment.fix_suggestions.map(s => {
+        return {
+          ...s,
+          description: 'Suggested Edit from comment',
+        };
+      }),
+      patchNum: this.comment.patch_set,
+      onCloseFixPreviewCallbacks: [],
+    };
+    fire(this, 'open-fix-preview', eventDetail);
+  }
+
+  async handleApplyFix() {
+    if (!this.comment?.fix_suggestions) return;
+    this.applyingFix = true;
+    try {
+      await this.suggestionDiffPreview?.applyFixSuggestion();
+    } finally {
+      this.applyingFix = false;
+    }
+  }
+
+  private isApplyEditDisabled() {
+    if (this.comment?.patch_set === undefined) return true;
+    if (isDraft(this.comment)) return true;
+    return this.comment.patch_set !== this.latestPatchNum;
+  }
+
+  private computeApplyEditTooltip() {
+    if (this.comment?.patch_set === undefined) return '';
+    return this.comment.patch_set !== this.latestPatchNum
+      ? 'You cannot apply this fix because it is from a previous patchset'
+      : '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-fix-suggestions': GrFixSuggestions;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 01f137a..b57e4ff 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -305,8 +305,7 @@
 
   private convertCodeToSuggestions() {
     const marks = this.renderRoot.querySelectorAll('mark');
-    if (marks.length > 0) {
-      const userSuggestionMark = marks[0];
+    for (const userSuggestionMark of marks) {
       const userSuggestion = document.createElement('gr-user-suggestion-fix');
       // Temporary workaround for bug - tabs replacement
       if (this.content.includes('\t')) {
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 497ed2b..923a00e 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -19,7 +19,7 @@
 import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
 import {resolve} from '../../../models/dependency';
 import {highlightServiceToken} from '../../../services/highlight/highlight-service';
-import {FixReplacementInfo, NumericChangeId} from '../../../api/rest-api';
+import {FixSuggestionInfo, NumericChangeId} from '../../../api/rest-api';
 import {changeModelToken} from '../../../models/change/change-model';
 import {subscribe} from '../../lit/subscription-controller';
 import {FilePreview} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
@@ -45,9 +45,11 @@
 
 /**
  * Diff preview for
- * 1. suggestion vs commented Text
- * or 2. fixReplacementInfos
- * that are attached to a comment.
+ * 1. code block suggestion vs commented Text
+ * or 2. fixSuggestionInfo that are attached to a comment.
+ *
+ * It shouldn't be created with both 1. and 2. but if it is
+ * it shows just for 1. (code block suggestion)
  */
 @customElement('gr-suggestion-diff-preview')
 export class GrSuggestionDiffPreview extends LitElement {
@@ -55,7 +57,7 @@
   suggestion?: string;
 
   @property({type: Object})
-  fixReplacementInfos?: FixReplacementInfo[];
+  fixSuggestionInfo?: FixSuggestionInfo;
 
   @property({type: Boolean})
   showAddSuggestionButton = false;
@@ -73,7 +75,7 @@
   layers: DiffLayer[] = [];
 
   @state()
-  previewLoadedFor?: string | FixReplacementInfo[];
+  previewLoadedFor?: string | FixSuggestionInfo;
 
   @state() repo?: RepoName;
 
@@ -178,14 +180,14 @@
     }
 
     if (changed.has('changeNum') || changed.has('comment')) {
-      if (this.previewLoadedFor !== this.fixReplacementInfos) {
-        this.fetchFixReplacementInfosPreview();
+      if (this.previewLoadedFor !== this.fixSuggestionInfo) {
+        this.fetchfixSuggestionInfoPreview();
       }
     }
   }
 
   override render() {
-    if (!this.suggestion && !this.fixReplacementInfos) return nothing;
+    if (!this.suggestion && !this.fixSuggestionInfo) return nothing;
     const code = this.suggestion;
     return html`
       ${when(
@@ -259,29 +261,20 @@
     return res;
   }
 
-  private async fetchFixReplacementInfosPreview() {
+  private async fetchfixSuggestionInfoPreview() {
     if (
       this.suggestion ||
       !this.changeNum ||
       !this.comment?.patch_set ||
-      !this.fixReplacementInfos
+      !this.fixSuggestionInfo
     )
       return;
 
-    // TODO (milutin): This is a temporary fix for the broken path issue.
-    // Our experimental plugin currently returns only the file extension.
-    const replacements = this.fixReplacementInfos.map(fixInfo => {
-      return {
-        ...fixInfo,
-        path: this.comment?.path ?? fixInfo.path,
-      };
-    });
-
     this.reporting.time(Timing.PREVIEW_FIX_LOAD);
     const res = await this.restApiService.getFixPreview(
       this.changeNum,
       this.comment?.patch_set,
-      replacements
+      this.fixSuggestionInfo.replacements
     );
 
     if (!res) return;
@@ -293,43 +286,58 @@
     });
     if (currentPreviews.length > 0) {
       this.preview = currentPreviews[0];
-      this.previewLoadedFor = this.fixReplacementInfos;
+      this.previewLoadedFor = this.fixSuggestionInfo;
     }
 
     return res;
   }
 
   /**
-   * Applies a fix previewed in `suggestion-diff-preview`,
-   * navigating to the new change URL with the EDIT patchset.
+   * Applies a fix (fix_suggestion in comment) previewed in
+   * `suggestion-diff-preview`, navigating to the new change URL with the EDIT
+   * patchset.
+   *
+   * Similar code flow is in gr-apply-fix-dialog.handleApplyFix
+   * Used in gr-fix-suggestions
+   */
+  public applyFixSuggestion() {
+    if (this.suggestion || !this.fixSuggestionInfo) return;
+    this.applyFix(this.fixSuggestionInfo);
+  }
+
+  /**
+   * Applies a fix (codeblock in comment message) previewed in
+   * `suggestion-diff-preview`, navigating to the new change URL with the EDIT
+   * patchset.
    *
    * Similar code flow is in gr-apply-fix-dialog.handleApplyFix
    * Used in gr-user-suggestion-fix
    */
-  public async applyFix() {
-    if (
-      !this.changeNum ||
-      !this.comment?.patch_set ||
-      !this.suggestion ||
-      !this.commentedText
-    )
-      return;
-    const changeNum = this.changeNum;
-    const basePatchNum = this.comment?.patch_set as BasePatchSetNum;
+  public applyUserSuggestedFix() {
+    if (!this.comment || !this.suggestion || !this.commentedText) return;
+
     const fixSuggestions = createUserFixSuggestion(
       this.comment,
       this.commentedText,
       this.suggestion
     );
+    this.applyFix(fixSuggestions[0]);
+  }
+
+  private async applyFix(fixSuggestion: FixSuggestionInfo) {
+    const changeNum = this.changeNum;
+    const basePatchNum = this.comment?.patch_set as BasePatchSetNum;
+    if (!changeNum || !basePatchNum || !fixSuggestion) return;
+
     this.reporting.time(Timing.APPLY_FIX_LOAD);
     const res = await this.restApiService.applyFixSuggestion(
-      this.changeNum,
-      this.comment?.patch_set,
-      fixSuggestions[0].replacements
+      changeNum,
+      basePatchNum,
+      fixSuggestion.replacements
     );
     this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, {
       method: '1-click',
-      description: fixSuggestions?.[0].description,
+      description: fixSuggestion.description,
     });
     if (res?.ok) {
       this.getNavigation().setUrl(
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 be73b87..c8de209 100644
--- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
@@ -147,7 +147,7 @@
   async handleApplyFix() {
     if (!this.textContent) return;
     this.applyingFix = true;
-    await this.suggestionDiffPreview?.applyFix();
+    await this.suggestionDiffPreview?.applyUserSuggestedFix();
     this.applyingFix = false;
   }
 
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 19a6457..8b333e6 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -582,7 +582,8 @@
   gr-diff-row tr.diff-row,
   gr-diff-row td.content,
   gr-diff-section tbody.contextControl,
-  gr-diff-row td.blame {
+  gr-diff-row td.blame,
+  #diffHeader {
     -webkit-user-select: none;
     -moz-user-select: none;
     -ms-user-select: none;
diff --git a/polygerrit-ui/app/utils/location-util.ts b/polygerrit-ui/app/utils/location-util.ts
new file mode 100644
index 0000000..d0eac74
--- /dev/null
+++ b/polygerrit-ui/app/utils/location-util.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// This file adds some simple checks to match internal Google rules.
+// Internally at Google it has different a implementation.
+
+import {safeLocation} from 'safevalues/dom';
+
+export function setHref(loc: Location, url: string) {
+  safeLocation.setHref(loc, url);
+}
+
+export function replace(loc: Location, url: string) {
+  safeLocation.replace(loc, url);
+}
+
+export function assign(loc: Location, url: string) {
+  safeLocation.assign(loc, url);
+}
diff --git a/proto/entities.proto b/proto/entities.proto
index 335db62..9d4b394f 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -62,6 +62,31 @@
   reserved 101;  // note_db_state
 }
 
+// Serialized form of com.google.gerrit.extensions.common.ChangeInput.
+// Next ID: 19
+message ChangeInput {
+  optional string project = 1;
+  optional string branch = 2;
+  optional string subject = 3;
+  optional string topic = 4;
+  optional ChangeStatus status = 5;
+  optional bool is_private = 6;
+  optional bool work_in_progress = 7;
+  optional string base_change = 8;
+  optional string base_commit = 9;
+  optional bool new_branch = 10;
+  map<string, string> validation_options = 11;
+  map<string, string> custom_keyed_values = 12;
+  optional MergeInput merge = 13;
+  optional ApplyPatchInput patch = 14;
+  optional AccountInput author = 15;
+  repeated ListChangesOption response_format_options = 16;
+  optional NotifyHandling notify = 17 [default = ALL];
+  // The key is the string representation of the RecipientType enum.
+  // We use a string here because proto does not allow enum keys in maps.
+  map<string, NotifyInfo> notify_details = 18;
+}
+
 // Serialized form of com.google.gerrit.enities.ChangeMessage.
 // Next ID: 3
 message ChangeMessage_Key {
@@ -81,6 +106,97 @@
   optional Account_Id real_author = 7;
 }
 
+// Serialized form of com.google.gerrit.extensions.client.ChangeStatus.
+// Next ID: 3
+enum ChangeStatus {
+ NEW = 0;
+ MERGED = 1;
+ ABANDONED = 2;
+}
+
+// Serialized form of com.google.gerrit.extensions.common.MergeInput.
+// Next ID: 5
+message MergeInput {
+ optional string source = 1;
+ optional string source_branch = 2;
+ optional string strategy = 3;
+ optional bool allow_conflicts = 4;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.changes.ApplyPatchInput.
+// Next ID: 2
+message ApplyPatchInput {
+ optional string patch = 1;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.accounts.AccountInput.
+// Next ID: 8
+message AccountInput {
+ optional string username = 1;
+ optional string name = 2;
+ optional string display_name = 3;
+ optional string email = 4;
+ optional string ssh_key = 5;
+ optional string http_password = 6;
+ repeated string groups = 7;
+}
+
+// Serialized form of com.google.gerrit.extensions.client.ListChangesOption.
+// Next ID: 28
+enum ListChangesOption {
+  LABELS = 0;
+  CURRENT_REVISION = 1;
+  ALL_REVISIONS = 2;
+  CURRENT_COMMIT = 3;
+  ALL_COMMITS = 4;
+  CURRENT_FILES = 5;
+  ALL_FILES = 6;
+  DETAILED_ACCOUNTS = 7;
+  DETAILED_LABELS = 8;
+  MESSAGES = 9;
+  CURRENT_ACTIONS = 10;
+  REVIEWED = 11;
+  DRAFT_COMMENTS = 12;
+  DOWNLOAD_COMMANDS = 13;
+  WEB_LINKS = 14;
+  CHECK = 15;
+  CHANGE_ACTIONS = 16;
+  COMMIT_FOOTERS = 17;
+  PUSH_CERTIFICATES = 18;
+  REVIEWER_UPDATES = 19;
+  SUBMITTABLE = 20;
+  TRACKING_IDS = 21;
+  SKIP_MERGEABLE = 22;
+  SKIP_DIFFSTAT = 23;
+  SUBMIT_REQUIREMENTS = 24;
+  CUSTOM_KEYED_VALUES = 25;
+  STAR = 26;
+  PARENTS = 27;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.changes.NotifyHandling.
+// Next ID: 4
+enum NotifyHandling {
+  NONE = 0;
+  OWNER = 1;
+  OWNER_REVIEWERS = 2;
+  ALL = 3;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.changes.RecipientType.
+// Next ID: 3
+enum RecipientType {
+  TO = 0;
+  CC = 1;
+  BCC = 2;
+}
+
+// Serialized form of com.google.gerrit.extensions.api.changes.NotifyInfo.
+// Next ID: 2
+message NotifyInfo {
+  repeated string accounts = 1;
+}
+
 // Serialized form of com.google.gerrit.entities.PatchSet.Id.
 // Next ID: 3
 message PatchSet_Id {
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index f5fe24c..5e7ddb9 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.9.2-SNAPSHOT</version>
+  <version>3.9.3-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index abe1793..95a911a 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.9.2-SNAPSHOT</version>
+  <version>3.9.3-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 3240b14..1823f72 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.9.2-SNAPSHOT</version>
+  <version>3.9.3-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 52e98a04..499ad32 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.9.2-SNAPSHOT</version>
+  <version>3.9.3-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index deee0d4..ac3f668 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -271,30 +271,30 @@
         sha1 = "48462eb319817c90c27d377341684b6b81372e08",
     )
 
-    TRUTH_VERS = "1.4.0"
+    TRUTH_VERS = "1.4.2"
 
     maven_jar(
         name = "truth",
         artifact = "com.google.truth:truth:" + TRUTH_VERS,
-        sha1 = "2a9475ed8cf2081b859fda8f9c860d5c449cd9ed",
+        sha1 = "2322d861290bd84f84cbb178e43539725a4588fd",
     )
 
     maven_jar(
         name = "truth-java8-extension",
         artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-        sha1 = "807a8226b465e5df74cc0591762f729195c3f144",
+        sha1 = "bfa44a01e1bb5a1df50bc9c678d6588b4d9eb73a",
     )
 
     maven_jar(
         name = "truth-liteproto-extension",
         artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
-        sha1 = "849821a1b377119104df95f87cb3e5b9d674c8a8",
+        sha1 = "062a2716b3b0ba9d8e72c913dad43a8139b12202",
     )
 
     maven_jar(
         name = "truth-proto-extension",
         artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
-        sha1 = "20a6fbb4276320e4df5fb42fe327bc9fb0f56dd1",
+        sha1 = "53cfc94dfa435c5dcd6f8b6844b82b423ea0a5af",
     )
 
     LUCENE_VERS = "9.8.0"
diff --git a/version.bzl b/version.bzl
index d55ddba..c5fca8b 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.9.2-SNAPSHOT"
+GERRIT_VERSION = "3.9.3-SNAPSHOT"