Merge "Fix FilesInCommitCollection when parent = 0"
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 477641b..01857da 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -113,6 +113,11 @@
 nags and pester you if you haven't replied or made a fix, so it helps
 them know if you missed it or decided against it.
 
+Features or API extensions, even if they are small, will incur
+long-time maintenance and support burden, so they should be left
+pending for at least 24 hours to give maintainers in all timezones a
+chance to evaluate.
+
 [[design-driven-contribution-process]]
 === Design-driven Contribution Process
 
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index f59daf5..5b27088 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -812,8 +812,7 @@
                 .boxed()
                 .collect(
                     Collectors.toMap(
-                        i -> fileNames.get((int) i - 1),
-                        i -> fileNames.get((int) i - 1) + "-" + i)));
+                        i -> fileNames.get(i - 1), i -> fileNames.get(i - 1) + "-" + i)));
 
     m.setParents(pushResults.stream().map(PushOneCommit.Result::getCommit).collect(toList()));
     PushOneCommit.Result result = m.to(ref);
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index 29dc6a3..91fbf9e 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -288,7 +288,6 @@
 
     try (AutoCloseable ignored =
         installPlugin("my-plugin", PluginDefinedBulkExceptionModule.class)) {
-      PluginDefinedInfo errorInfo = new PluginDefinedInfo();
       List<PluginDefinedInfo> outputInfos = getter.call(id).get(id);
       assertThat(outputInfos).hasSize(1);
       assertThat(outputInfos.get(0).name).isEqualTo("my-plugin");
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index afbc74e..7012944 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -138,8 +138,9 @@
   }
 
   /**
-   * Returns all {@link ExternalId.Key}s associated with this user. For {@link AnonymousUser} and
-   * other users that don't represent a person user or service account, this set will be empty.
+   * Returns all {@link com.google.gerrit.server.account.externalids.ExternalId.Key}s associated
+   * with this user. For {@link AnonymousUser} and other users that don't represent a person user or
+   * service account, this set will be empty.
    */
   public ImmutableSet<ExternalId.Key> getExternalIdKeys() {
     return ImmutableSet.of();
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 4f85412..1eee10f 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -365,8 +365,7 @@
   @Override
   public void starChange(String changeId) throws RestApiException {
     try {
-      starredChangesCreate.apply(
-          account, IdString.fromUrl(changeId), new StarredChanges.EmptyInput());
+      starredChangesCreate.apply(account, IdString.fromUrl(changeId), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot star change", e);
     }
@@ -378,7 +377,7 @@
       ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
       AccountResource.StarredChange starredChange =
           new AccountResource.StarredChange(account.getUser(), rsrc);
-      starredChangesDelete.apply(starredChange, new StarredChanges.EmptyInput());
+      starredChangesDelete.apply(starredChange, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot unstar change", e);
     }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index d349dda..8893039 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -767,10 +767,7 @@
     private final CmdLineParser.Factory cmdLineParserFactory;
 
     @Inject
-    DynamicOptionParser(
-        CmdLineParser.Factory cmdLineParserFactory,
-        Injector injector,
-        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+    DynamicOptionParser(CmdLineParser.Factory cmdLineParserFactory) {
       this.cmdLineParserFactory = cmdLineParserFactory;
     }
 
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
new file mode 100644
index 0000000..9553acc
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Base class for persistent cache factory. If the cache.directory property is unset, or disk limit
+ * is zero or negative, it will fall back to in-memory only caches.
+ */
+public abstract class PersistentCacheBaseFactory implements PersistentCacheFactory {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  protected final MemoryCacheFactory memCacheFactory;
+  protected final Path cacheDir;
+  protected boolean diskEnabled;
+  protected final Config config;
+
+  public PersistentCacheBaseFactory(
+      MemoryCacheFactory memCacheFactory, @GerritServerConfig Config config, SitePaths site) {
+    this.cacheDir = getCacheDir(site, config.getString("cache", null, "directory"));
+    this.diskEnabled = cacheDir != null;
+    this.memCacheFactory = memCacheFactory;
+    this.config = config;
+  }
+
+  protected abstract <K, V> Cache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, long diskLimit, CacheBackend backend);
+
+  protected abstract <K, V> LoadingCache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long diskLimit, CacheBackend backend);
+
+  @Override
+  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in, CacheBackend backend) {
+    long limit = getDiskLimit(in);
+
+    if (isInMemoryCache(limit)) {
+      return memCacheFactory.build(in, backend);
+    }
+
+    return buildImpl(in, limit, backend);
+  }
+
+  @Override
+  public <K, V> LoadingCache<K, V> build(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, CacheBackend backend) {
+    long limit = getDiskLimit(in);
+
+    if (isInMemoryCache(limit)) {
+      return memCacheFactory.build(in, loader, backend);
+    }
+
+    return buildImpl(in, loader, limit, backend);
+  }
+
+  private <K, V> long getDiskLimit(PersistentCacheDef<K, V> in) {
+    return config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
+  }
+
+  private <K, V> boolean isInMemoryCache(long diskLimit) {
+    return !diskEnabled || diskLimit <= 0;
+  }
+
+  private static Path getCacheDir(SitePaths site, String name) {
+    if (name == null) {
+      return null;
+    }
+    Path loc = site.resolve(name);
+    if (!Files.exists(loc)) {
+      try {
+        Files.createDirectories(loc);
+      } catch (IOException e) {
+        logger.atWarning().log("Can't create disk cache: %s", loc.toAbsolutePath());
+        return null;
+      }
+    }
+    if (!Files.isWritable(loc)) {
+      logger.atWarning().log("Can't write to disk cache: %s", loc.toAbsolutePath());
+      return null;
+    }
+    logger.atInfo().log("Enabling disk cache %s", loc.toAbsolutePath());
+    return loc;
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 82615a4..16d62b3 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
+import com.google.gerrit.server.cache.PersistentCacheBaseFactory;
 import com.google.gerrit.server.cache.PersistentCacheDef;
-import com.google.gerrit.server.cache.PersistentCacheFactory;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -34,9 +34,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
@@ -52,12 +49,9 @@
  * is unset, it will fall back to in-memory caches.
  */
 @Singleton
-class H2CacheFactory implements PersistentCacheFactory, LifecycleListener {
+class H2CacheFactory extends PersistentCacheBaseFactory implements LifecycleListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final MemoryCacheFactory memCacheFactory;
-  private final Config config;
-  private final Path cacheDir;
   private final List<H2CacheImpl<?, ?>> caches;
   private final DynamicMap<Cache<?, ?>> cacheMap;
   private final ExecutorService executor;
@@ -71,15 +65,13 @@
       @GerritServerConfig Config cfg,
       SitePaths site,
       DynamicMap<Cache<?, ?>> cacheMap) {
-    this.memCacheFactory = memCacheFactory;
-    config = cfg;
-    cacheDir = getCacheDir(site, cfg.getString("cache", null, "directory"));
+    super(memCacheFactory, cfg, site);
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
     h2AutoServer = cfg.getBoolean("cache", null, "h2AutoServer", false);
     caches = new LinkedList<>();
     this.cacheMap = cacheMap;
 
-    if (cacheDir != null) {
+    if (diskEnabled) {
       executor =
           new LoggingContextAwareExecutorService(
               Executors.newFixedThreadPool(
@@ -98,27 +90,6 @@
     }
   }
 
-  private static Path getCacheDir(SitePaths site, String name) {
-    if (name == null) {
-      return null;
-    }
-    Path loc = site.resolve(name);
-    if (!Files.exists(loc)) {
-      try {
-        Files.createDirectories(loc);
-      } catch (IOException e) {
-        logger.atWarning().log("Can't create disk cache: %s", loc.toAbsolutePath());
-        return null;
-      }
-    }
-    if (!Files.isWritable(loc)) {
-      logger.atWarning().log("Can't write to disk cache: %s", loc.toAbsolutePath());
-      return null;
-    }
-    logger.atInfo().log("Enabling disk cache %s", loc.toAbsolutePath());
-    return loc;
-  }
-
   @Override
   public void start() {
     if (executor != null) {
@@ -161,13 +132,8 @@
 
   @SuppressWarnings({"unchecked"})
   @Override
-  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in, CacheBackend backend) {
-    long limit = config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
-
-    if (cacheDir == null || limit <= 0) {
-      return memCacheFactory.build(in, backend);
-    }
-
+  public <K, V> Cache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, long limit, CacheBackend backend) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     H2CacheImpl<K, V> cache =
@@ -184,14 +150,8 @@
 
   @SuppressWarnings("unchecked")
   @Override
-  public <K, V> LoadingCache<K, V> build(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, CacheBackend backend) {
-    long limit = config.getLong("cache", in.configKey(), "diskLimit", in.diskLimit());
-
-    if (cacheDir == null || limit <= 0) {
-      return memCacheFactory.build(in, loader, backend);
-    }
-
+  public <K, V> LoadingCache<K, V> buildImpl(
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long limit, CacheBackend backend) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     Cache<K, ValueHolder<V>> mem =
diff --git a/java/com/google/gerrit/server/change/ChangeETagComputation.java b/java/com/google/gerrit/server/change/ChangeETagComputation.java
index a5b7d49..2fd5755 100644
--- a/java/com/google/gerrit/server/change/ChangeETagComputation.java
+++ b/java/com/google/gerrit/server/change/ChangeETagComputation.java
@@ -26,7 +26,7 @@
  * <ul>
  *   <li>providing plugin defined attributes to {@link
  *       com.google.gerrit.extensions.common.ChangeInfo#plugins} (see {@link
- *       ChangeAttributeFactory})
+ *       ChangePluginDefinedInfoFactory})
  *   <li>implementing a {@link com.google.gerrit.server.rules.SubmitRule} which affects the
  *       computation of {@link com.google.gerrit.extensions.common.ChangeInfo#submittable}
  * </ul>
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
index 16e9023..0dd71507 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
@@ -47,10 +47,9 @@
     try {
       if (base == null) {
         return asFileInfo(
-            diffs.getModifiedFilesAgainstParentOrAutoMerge(change.getProject(), objectId, null));
+            diffs.listModifiedFilesAgainstParent(change.getProject(), objectId, null));
       } else {
-        return asFileInfo(
-            diffs.getModifiedFilesBetweenPatchsets(change.getProject(), base.commitId(), objectId));
+        return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
       }
     } catch (DiffNotAvailableException e) {
       convertException(e);
@@ -64,7 +63,7 @@
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       Map<String, FileDiffOutput> modifiedFiles =
-          diffs.getModifiedFilesAgainstParentOrAutoMerge(project, objectId, parent + 1);
+          diffs.listModifiedFilesAgainstParent(project, objectId, parent + 1);
       return asFileInfo(modifiedFiles);
     } catch (DiffNotAvailableException e) {
       convertException(e);
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index 76992e8..acff03c 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -20,14 +20,12 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
-import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
@@ -38,7 +36,6 @@
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.VotingRangeInfo;
@@ -156,11 +153,6 @@
     return permitted.asMap();
   }
 
-  private static boolean containsAnyOf(
-      ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
-    return !Sets.intersection(toFind, set).isEmpty();
-  }
-
   private static boolean isOnlyZero(Collection<String> values) {
     return values.isEmpty() || (values.size() == 1 && values.contains(" 0"));
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index b816264..e8f1fe1 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -138,8 +138,9 @@
     }
 
     /**
-     * Create change notes based on a {@link Change.Id}. This requires using the Change index and
-     * should only be used when {@link Project.NameKey} and the numeric change ID are not available.
+     * Create change notes based on a {@link com.google.gerrit.entities.Change.Id}. This requires
+     * using the Change index and should only be used when {@link
+     * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
      */
     public ChangeNotes createCheckedUsingIndexLookup(Change.Id changeId) {
       InternalChangeQuery query = queryProvider.get().noFields();
@@ -155,9 +156,9 @@
     }
 
     /**
-     * Create change notes based on a list of {@link Change.Id}s. This requires using the Change
-     * index and should only be used when {@link Project.NameKey} and the numeric change ID are not
-     * available.
+     * Create change notes based on a list of {@link com.google.gerrit.entities.Change.Id}s. This
+     * requires using the Change index and should only be used when {@link
+     * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
      */
     public List<ChangeNotes> createUsingIndexLookup(Collection<Change.Id> changeIds) {
       List<ChangeNotes> notes = new ArrayList<>();
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 846d4b8..97fec4c 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -1027,7 +1027,7 @@
    * @return {@link Optional} value of the parsed footer or {@code null} if the footer is missing in
    *     this commit.
    * @throws ConfigInvalidException if the footer value could not be parsed as a valid {@link
-   *     PatchSet.Id}.
+   *     com.google.gerrit.entities.PatchSet.Id}.
    */
   @Nullable
   private Optional<PatchSet.Id> parseCherryPickOf(ChangeNotesCommit commit)
diff --git a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
index e75adec..ea92a99 100644
--- a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
+++ b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
@@ -22,6 +22,8 @@
  * if the implementations failed to retrieve the modified files between the 2 commits.
  */
 public class DiffNotAvailableException extends Exception {
+  private static final long serialVersionUID = 1L;
+
   public DiffNotAvailableException(Throwable cause) {
     super(cause);
   }
@@ -29,4 +31,8 @@
   public DiffNotAvailableException(String message) {
     super(message);
   }
+
+  public DiffNotAvailableException(String message, Throwable cause) {
+    super(message, cause);
+  }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index a6c7c81..1d8438e 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -27,7 +27,7 @@
  * <ul>
  *   <li>The list of modified files between two commits.
  *   <li>The list of modified files between a commit and its parent or the auto-merge.
- *   <li>The detailed file diff for a single file path (TODO:ghareeb).
+ *   <li>The detailed file diff for a single file path.
  *   <li>The Intra-line diffs for a single file path (TODO:ghareeb).
  * </ul>
  */
@@ -51,7 +51,7 @@
    *     parents, if the {@code newCommit} could not be parsed for extracting the base commit, or if
    *     an internal error occurred in Git while evaluating the diff.
    */
-  Map<String, FileDiffOutput> getModifiedFilesAgainstParentOrAutoMerge(
+  Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
       Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
       throws DiffNotAvailableException;
 
@@ -66,7 +66,41 @@
    * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
    *     diff.
    */
-  Map<String, FileDiffOutput> getModifiedFilesBetweenPatchsets(
+  Map<String, FileDiffOutput> listModifiedFiles(
       Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
       throws DiffNotAvailableException;
+
+  /**
+   * Returns the diff for a single file between a patchset commit against its parent or the
+   * auto-merge commit. For deleted files, the {@code fileName} parameter should contain the old
+   * name of the file.
+   *
+   * @param project a project name representing a git repository.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
+   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @param fileName the file name for which the diff should be evaluated.
+   * @return the diff for the single file between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff, or if an exception happened while parsing the base commit.
+   */
+  FileDiffOutput getModifiedFileAgainstParent(
+      Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum, String fileName)
+      throws DiffNotAvailableException;
+
+  /**
+   * Returns the diff for a single file between two patchset commits. For deleted files, the {@code
+   * fileName} parameter should contain the old name of the file.
+   *
+   * @param project a project name representing a git repository.
+   * @param oldCommit 20 bytes SHA-1 of the old commit used in the diff.
+   * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
+   * @param fileName the file name for which the diff should be evaluated.
+   * @return the diff for the single file between the two commits.
+   * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
+   *     diff.
+   */
+  FileDiffOutput getModifiedFile(
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, String fileName)
+      throws DiffNotAvailableException;
 }
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 719536a..2d98d42 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.server.patch;
 
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
+import static com.google.gerrit.entities.Patch.MERGE_LIST;
+
+import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
@@ -37,11 +41,10 @@
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevObject;
 
 /**
  * Provides different file diff operations. Uses the underlying Git/Gerrit caches to speed up the
@@ -81,73 +84,84 @@
   }
 
   @Override
-  public Map<String, FileDiffOutput> getModifiedFilesAgainstParentOrAutoMerge(
+  public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
       Project.NameKey project, ObjectId newCommit, @Nullable Integer parent)
       throws DiffNotAvailableException {
     try {
-      if (parent != null) {
-        RevObject base = baseCommitUtil.getBaseCommit(project, newCommit, parent);
-        return getModifiedFiles(project, base, newCommit, ComparisonType.againstParent(parent));
-      }
-      int numParents = baseCommitUtil.getNumParents(project, newCommit);
-      if (numParents == 1) {
-        RevObject base = baseCommitUtil.getBaseCommit(project, newCommit, parent);
-        ComparisonType cmp = ComparisonType.againstParent(1);
-        return getModifiedFiles(project, base, newCommit, cmp);
-      }
-      if (numParents > 2) {
-        logger.atFine().log(
-            "Diff against auto-merge for merge commits "
-                + "with more than two parents is not supported. Commit "
-                + newCommit
-                + " has "
-                + numParents
-                + " parents. Falling back to the diff against the first parent.");
-        ObjectId firstParentId = baseCommitUtil.getBaseCommit(project, newCommit, 1).getId();
-        ImmutableList.Builder<FileDiffCacheKey> keys = ImmutableList.builder();
-        keys.add(createFileDiffCacheKey(project, firstParentId, newCommit, Patch.COMMIT_MSG));
-        keys.add(createFileDiffCacheKey(project, firstParentId, newCommit, Patch.MERGE_LIST));
-        return getModifiedFilesForKeys(keys.build());
-      }
-      RevObject autoMerge = baseCommitUtil.getBaseCommit(project, newCommit, null);
-      return getModifiedFiles(project, autoMerge, newCommit, ComparisonType.againstAutoMerge());
+      DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
+      return getModifiedFiles(project, newCommit, diffParams);
     } catch (IOException e) {
-      throw new DiffNotAvailableException(e);
+      throw new DiffNotAvailableException(
+          "Failed to evaluate the parent/base commit for commit " + newCommit, e);
     }
   }
 
   @Override
-  public Map<String, FileDiffOutput> getModifiedFilesBetweenPatchsets(
+  public Map<String, FileDiffOutput> listModifiedFiles(
       Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
       throws DiffNotAvailableException {
-    ComparisonType cmp = ComparisonType.againstOtherPatchSet();
-    return getModifiedFiles(project, oldCommit, newCommit, cmp);
+    DiffParameters params =
+        DiffParameters.builder()
+            .project(project)
+            .newCommit(newCommit)
+            .baseCommit(oldCommit)
+            .comparisonType(ComparisonType.againstOtherPatchSet())
+            .build();
+    return getModifiedFiles(project, newCommit, params);
+  }
+
+  @Override
+  public FileDiffOutput getModifiedFileAgainstParent(
+      Project.NameKey project, ObjectId newCommit, @Nullable Integer parent, String fileName)
+      throws DiffNotAvailableException {
+    try {
+      DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
+      FileDiffCacheKey key =
+          createFileDiffCacheKey(project, diffParams.baseCommit(), newCommit, fileName);
+      return getModifiedFilesForKeys(ImmutableList.of(key)).get(fileName);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          "Failed to evaluate the parent/base commit for commit " + newCommit, e);
+    }
+  }
+
+  @Override
+  public FileDiffOutput getModifiedFile(
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, String fileName)
+      throws DiffNotAvailableException {
+    FileDiffCacheKey key = createFileDiffCacheKey(project, oldCommit, newCommit, fileName);
+    return getModifiedFilesForKeys(ImmutableList.of(key)).get(fileName);
   }
 
   private Map<String, FileDiffOutput> getModifiedFiles(
-      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, ComparisonType cmp)
+      Project.NameKey project, ObjectId newCommit, DiffParameters diffParams)
       throws DiffNotAvailableException {
     try {
+      ObjectId oldCommit = diffParams.baseCommit();
+      ComparisonType cmp = diffParams.comparisonType();
+
       ImmutableList<ModifiedFile> modifiedFiles =
           modifiedFilesCache.get(createModifiedFilesKey(project, oldCommit, newCommit));
 
-      List<FileDiffCacheKey> fileCacheKeys =
-          modifiedFiles.stream()
-              .map(
-                  entity ->
-                      createFileDiffCacheKey(
-                          project,
-                          oldCommit,
-                          newCommit,
-                          entity.newPath().isPresent()
-                              ? entity.newPath().get()
-                              : entity.oldPath().get()))
-              .collect(Collectors.toList());
-
-      fileCacheKeys.add(createFileDiffCacheKey(project, oldCommit, newCommit, Patch.COMMIT_MSG));
+      List<FileDiffCacheKey> fileCacheKeys = new ArrayList<>();
+      fileCacheKeys.add(createFileDiffCacheKey(project, oldCommit, newCommit, COMMIT_MSG));
 
       if (cmp.isAgainstAutoMerge() || isMergeAgainstParent(cmp, project, newCommit)) {
-        fileCacheKeys.add(createFileDiffCacheKey(project, oldCommit, newCommit, Patch.MERGE_LIST));
+        fileCacheKeys.add(createFileDiffCacheKey(project, oldCommit, newCommit, MERGE_LIST));
+      }
+
+      if (diffParams.skipFiles() == null) {
+        modifiedFiles.stream()
+            .map(
+                entity ->
+                    createFileDiffCacheKey(
+                        project,
+                        oldCommit,
+                        newCommit,
+                        entity.newPath().isPresent()
+                            ? entity.newPath().get()
+                            : entity.oldPath().get()))
+            .forEach(fileCacheKeys::add);
       }
       return getModifiedFilesForKeys(fileCacheKeys);
     } catch (IOException e) {
@@ -206,4 +220,78 @@
         .whitespace(Whitespace.IGNORE_NONE)
         .build();
   }
+
+  @AutoValue
+  abstract static class DiffParameters {
+    abstract Project.NameKey project();
+
+    abstract ObjectId newCommit();
+
+    abstract ObjectId baseCommit();
+
+    abstract ComparisonType comparisonType();
+
+    @Nullable
+    abstract Integer parent();
+
+    /** Compute the diff for {@value Patch#COMMIT_MSG} and {@link Patch#MERGE_LIST} only. */
+    @Nullable
+    abstract Boolean skipFiles();
+
+    static Builder builder() {
+      return new AutoValue_DiffOperationsImpl_DiffParameters.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+
+      abstract Builder project(Project.NameKey project);
+
+      abstract Builder newCommit(ObjectId newCommit);
+
+      abstract Builder baseCommit(ObjectId baseCommit);
+
+      abstract Builder parent(@Nullable Integer parent);
+
+      abstract Builder skipFiles(@Nullable Boolean skipFiles);
+
+      abstract Builder comparisonType(ComparisonType comparisonType);
+
+      public abstract DiffParameters build();
+    }
+  }
+
+  /** Compute Diff parameters - the base commit and the comparison type - using the input args. */
+  private DiffParameters computeDiffParameters(
+      Project.NameKey project, ObjectId newCommit, Integer parent) throws IOException {
+    DiffParameters.Builder result =
+        DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
+    if (parent != null) {
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
+      result.comparisonType(ComparisonType.againstParent(parent));
+      return result.build();
+    }
+    int numParents = baseCommitUtil.getNumParents(project, newCommit);
+    if (numParents == 1) {
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
+      result.comparisonType(ComparisonType.againstParent(1));
+      return result.build();
+    }
+    if (numParents > 2) {
+      logger.atFine().log(
+          "Diff against auto-merge for merge commits "
+              + "with more than two parents is not supported. Commit "
+              + newCommit
+              + " has "
+              + numParents
+              + " parents. Falling back to the diff against the first parent.");
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, 1).getId());
+      result.comparisonType(ComparisonType.againstParent(1));
+      result.skipFiles(true);
+    } else {
+      result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, null));
+      result.comparisonType(ComparisonType.againstAutoMerge());
+    }
+    return result.build();
+  }
 }
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 091b02c..b2a628b 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.patch.filediff;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
@@ -418,7 +419,7 @@
 
     private static ImmutableList<TaggedEdit> asTaggedEdits(
         List<Edit> normalEdits, List<Edit> rebaseEdits) {
-      Set<Edit> rebaseEditsSet = new HashSet(rebaseEdits);
+      Set<Edit> rebaseEditsSet = new HashSet<>(rebaseEdits);
       ImmutableList.Builder<TaggedEdit> result =
           ImmutableList.builderWithExpectedSize(normalEdits.size());
       for (Edit e : normalEdits) {
@@ -457,9 +458,7 @@
           new EditTransformer(
               ImmutableList.of(
                   FileEdits.create(
-                      parentVsParentDiff.edits().stream()
-                          .map(Edit::toJGitEdit)
-                          .collect(Collectors.toList()),
+                      parentVsParentDiff.edits().stream().collect(toImmutableList()),
                       parentVsParentDiff.oldPath(),
                       parentVsParentDiff.newPath())));
 
@@ -468,9 +467,7 @@
         editTransformer.transformReferencesOfSideA(
             ImmutableList.of(
                 FileEdits.create(
-                    oldVsParDiff.edits().stream()
-                        .map(Edit::toJGitEdit)
-                        .collect(Collectors.toList()),
+                    oldVsParDiff.edits().stream().collect(toImmutableList()),
                     oldVsParDiff.oldPath(),
                     oldVsParDiff.newPath())));
       }
@@ -480,9 +477,7 @@
         editTransformer.transformReferencesOfSideB(
             ImmutableList.of(
                 FileEdits.create(
-                    newVsParDiff.edits().stream()
-                        .map(Edit::toJGitEdit)
-                        .collect(Collectors.toList()),
+                    newVsParDiff.edits().stream().collect(toImmutableList()),
                     newVsParDiff.oldPath(),
                     newVsParDiff.newPath())));
       }
@@ -497,11 +492,12 @@
       String filePath = editsPerFilePath.keys().iterator().next();
       Collection<ContextAwareEdit> edits = editsPerFilePath.get(filePath);
       return FileEdits.create(
-          Streams.stream(edits)
+          edits.stream()
               .map(ContextAwareEdit::toEdit)
               .filter(Optional::isPresent)
               .map(Optional::get)
-              .collect(Collectors.toList()),
+              .map(Edit::fromJGitEdit)
+              .collect(toImmutableList()),
           edits.iterator().next().getOldFilePath(),
           edits.iterator().next().getNewFilePath());
     }
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index e89f148..46ce28b 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -32,6 +32,7 @@
 /** File diff for a single file path. Produced as output of the {@link FileDiffCache}. */
 @AutoValue
 public abstract class FileDiffOutput implements Serializable {
+  private static final long serialVersionUID = 1L;
 
   /**
    * The file path at the old commit. Returns an empty Optional if {@link #changeType()} is equal to
diff --git a/java/com/google/gerrit/server/patch/filediff/FileEdits.java b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
index 376bbc2..a009a02 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileEdits.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
@@ -18,7 +18,6 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
-import java.util.List;
 import java.util.Optional;
 
 /**
@@ -28,12 +27,16 @@
 @AutoValue
 public abstract class FileEdits {
   public static FileEdits create(
-      List<org.eclipse.jgit.diff.Edit> jgitEdits,
+      ImmutableList<Edit> edits, Optional<String> oldPath, Optional<String> newPath) {
+    return new AutoValue_FileEdits(edits, oldPath, newPath);
+  }
+
+  public static FileEdits createFromJgitEdits(
+      ImmutableList<org.eclipse.jgit.diff.Edit> jgitEdits,
       Optional<String> oldPath,
       Optional<String> newPath) {
-    ImmutableList<Edit> edits =
-        jgitEdits.stream().map(Edit::fromJGitEdit).collect(toImmutableList());
-    return new AutoValue_FileEdits(edits, oldPath, newPath);
+    return new AutoValue_FileEdits(
+        jgitEdits.stream().map(Edit::fromJGitEdit).collect(toImmutableList()), oldPath, newPath);
   }
 
   public abstract ImmutableList<Edit> edits();
diff --git a/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java b/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
index 5b7eddb..d1c0b45 100644
--- a/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
@@ -348,7 +348,7 @@
         newName = Optional.of(patchListEntry.getNewName());
         break;
     }
-    return FileEdits.create(patchListEntry.getEdits(), oldName, newName);
+    return FileEdits.createFromJgitEdits(patchListEntry.getEdits(), oldName, newName);
   }
 
   private static boolean isRootOrMergeCommit(RevCommit commit) {
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index bc859f3..6272cda 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -318,7 +318,7 @@
                       && !r.getName().startsWith(RefNames.REFS_TAGS)
                       && !r.isSymbolic()
                       && !r.getName().equals(RefNames.REFS_CONFIG))
-          .collect(toCollection(() -> new ArrayList<>(allRefs.size())));
+          .collect(Collectors.toList());
     } catch (IOException e) {
       throw new PermissionBackendException(e);
     }
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index c108dcb..e67fe9e 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -17,6 +17,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -96,8 +97,7 @@
 
   @Singleton
   public static class Create
-      implements RestCollectionCreateView<
-          AccountResource, AccountResource.StarredChange, EmptyInput> {
+      implements RestCollectionCreateView<AccountResource, AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
     private final ChangesCollection changes;
     private final StarredChangesUtil starredChangesUtil;
@@ -113,7 +113,7 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource rsrc, IdString id, EmptyInput in)
+    public Response<?> apply(AccountResource rsrc, IdString id, Input in)
         throws RestApiException, IOException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to add starred change");
@@ -148,7 +148,7 @@
   }
 
   @Singleton
-  public static class Put implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
+  public static class Put implements RestModifyView<AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
 
     @Inject
@@ -157,8 +157,7 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
-        throws AuthException {
+    public Response<?> apply(AccountResource.StarredChange rsrc, Input in) throws AuthException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed update starred changes");
       }
@@ -167,7 +166,7 @@
   }
 
   @Singleton
-  public static class Delete implements RestModifyView<AccountResource.StarredChange, EmptyInput> {
+  public static class Delete implements RestModifyView<AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
     private final StarredChangesUtil starredChangesUtil;
 
@@ -178,7 +177,7 @@
     }
 
     @Override
-    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
+    public Response<?> apply(AccountResource.StarredChange rsrc, Input in)
         throws AuthException, IOException, IllegalLabelException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed remove starred change");
@@ -192,6 +191,4 @@
       return Response.none();
     }
   }
-
-  public static class EmptyInput {}
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index a3e0cf0..ee6484c 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -44,7 +43,6 @@
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.ResetCherryPickOp;
 import com.google.gerrit.server.change.SetCherryPickOp;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -114,7 +112,6 @@
   private final ApprovalsUtil approvalsUtil;
   private final NotifyResolver notifyResolver;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final DynamicItem<UrlFormatter> urlFormatter;
 
   @Inject
   CherryPickChange(
@@ -131,8 +128,7 @@
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
       NotifyResolver notifyResolver,
-      BatchUpdate.Factory batchUpdateFactory,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      BatchUpdate.Factory batchUpdateFactory) {
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
@@ -147,7 +143,6 @@
     this.approvalsUtil = approvalsUtil;
     this.notifyResolver = notifyResolver;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.urlFormatter = urlFormatter;
   }
 
   /**
@@ -585,9 +580,8 @@
       String commitMessage, @Nullable ObjectId changeIdForNewChange) {
     if (changeIdForNewChange != null) {
       return CommitMessageUtil.getChangeIdFromObjectId(changeIdForNewChange);
-    } else {
-      return CommitMessageUtil.getChangeIdFromCommitMessageFooter(commitMessage).orElse(null);
     }
+    return CommitMessageUtil.getChangeIdFromCommitMessageFooter(commitMessage).orElse(null);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
index 6a0fc97..fd18c8d 100644
--- a/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListChildProjects.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectResource;
@@ -40,16 +39,11 @@
   @Option(name = "--limit", usage = "maximum number of parents projects to list")
   private int limit;
 
-  private final PermissionBackend permissionBackend;
   private final ChildProjects childProjects;
   private final Provider<QueryProjects> queryProvider;
 
   @Inject
-  ListChildProjects(
-      PermissionBackend permissionBackend,
-      ChildProjects childProjects,
-      Provider<QueryProjects> queryProvider) {
-    this.permissionBackend = permissionBackend;
+  ListChildProjects(ChildProjects childProjects, Provider<QueryProjects> queryProvider) {
     this.childProjects = childProjects;
     this.queryProvider = queryProvider;
   }
@@ -82,7 +76,6 @@
   }
 
   private List<ProjectInfo> directChildProjects(Project.NameKey parent) throws RestApiException {
-    PermissionBackend.WithUser currentUser = permissionBackend.currentUser();
     return queryProvider.get().withQuery("parent:" + parent.get()).withLimit(limit).apply().stream()
         .collect(toList());
   }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index b27cb9b..7f434ca 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -533,6 +533,9 @@
             // Multiply the timeout by the number of projects we're actually attempting to
             // submit.
             .defaultTimeoutMultiplier(cs.projects().size())
+            // By default, we only retry lock failures. Here it's better to also retry unexpected
+            // runtime exceptions.
+            .retryOn(t -> t instanceof RuntimeException)
             .call();
         submissionExecutor.afterExecutions(orm);
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 04e8706..0d92c60 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -767,7 +767,6 @@
         pushFactory
             .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a")
             .to("refs/for/master");
-    String t1 = project.get() + "~master~" + r1.getChangeId();
 
     BranchInput bin = new BranchInput();
     bin.revision = r1.getCommit().getParent(0).name();
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 4139eeb..845c461 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -2753,6 +2753,14 @@
     assertThat(r.getChange().attentionSet()).isEmpty();
   }
 
+  @Test
+  public void pushForMasterWithUnknownOption() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of("unknown=foo"));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("\"--unknown\" is not a valid option");
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 7f8add8..02916c7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -43,7 +43,6 @@
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
-import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.validators.CommitValidationException;
@@ -713,7 +712,7 @@
 
   @Test
   @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void autoRetryWithTrace() throws Exception {
+  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
     String changeId = createChange().getChangeId();
     approve(changeId);
 
@@ -723,49 +722,6 @@
       RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
       assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).startsWith("retry-on-failure-");
-      assertThat(traceSubmitRule.traceId).startsWith("retry-on-failure-");
-      assertThat(traceSubmitRule.isLoggingForced).isTrue();
-    }
-  }
-
-  @Test
-  @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-
-    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
-    traceSubmitRule.failAlways = true;
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(traceSubmitRule)
-            .add(
-                new ExceptionHook() {
-                  @Override
-                  public boolean shouldRetry(String actionType, String actionName, Throwable t) {
-                    return true;
-                  }
-                })) {
-      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
-      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
-      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
-      assertThat(traceSubmitRule.traceId).isNull();
-      assertThat(traceSubmitRule.isLoggingForced).isFalse();
-    }
-  }
-
-  @Test
-  public void noAutoRetryWithTraceIfDisabled() throws Exception {
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-
-    TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
-    traceSubmitRule.failOnce = true;
-    try (Registration registration = extensionRegistry.newRegistration().add(traceSubmitRule)) {
-      RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
-      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
-      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
       assertThat(traceSubmitRule.traceId).isNull();
       assertThat(traceSubmitRule.isLoggingForced).isFalse();
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
index 3b26459..d3dd801 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -204,7 +204,7 @@
   public void crossDomainPutTopic() throws Exception {
     Result change = createChange();
     BasicCookieStore cookies = new BasicCookieStore();
-    Executor http = Executor.newInstance().cookieStore(cookies);
+    Executor http = Executor.newInstance().use(cookies);
 
     Request req = Request.Get(canonicalWebUrl.get() + "/login/?account_id=" + admin.id().get());
     http.execute(req);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index a322faf..fff3cb6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -388,7 +387,6 @@
     // Create a change
     PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
     PushOneCommit.Result changeResult = change.to("refs/for/master");
-    PatchSet.Id patchSetId = changeResult.getPatchSetId();
 
     // Create a successor change.
     PushOneCommit change2 =
diff --git a/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java b/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java
index c0f2b36..4efa247 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsIT.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Module;
-import java.io.IOException;
 import java.util.List;
 import org.junit.Test;
 
@@ -46,7 +45,7 @@
     }
   }
 
-  protected List<String> getSamplesList(String sshOutput) throws IOException {
+  protected List<String> getSamplesList(String sshOutput) {
     return GSON.fromJson(sshOutput, new TypeToken<List<String>>() {}.getType());
   }
 }
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
index 933ffb4..b185558 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -174,23 +173,6 @@
     assertThat(convertedChangeMessage).isEqualTo(changeMessage);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.ChangeMessage proto =
-        Entities.ChangeMessage.newBuilder()
-            .setKey(
-                Entities.ChangeMessage_Key.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(543))
-                    .setUuid("change-message-21"))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.ChangeMessage> parser = changeMessageProtoConverter.getParser();
-    Entities.ChangeMessage parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
index bca5eea..bf39ff8 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.Date;
@@ -165,29 +164,6 @@
     assertThat(patchSetApproval.postSubmit()).isEqualTo(false);
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSetApproval proto =
-        Entities.PatchSetApproval.newBuilder()
-            .setKey(
-                Entities.PatchSetApproval_Key.newBuilder()
-                    .setPatchSetId(
-                        Entities.PatchSet_Id.newBuilder()
-                            .setChangeId(Entities.Change_Id.newBuilder().setId(42))
-                            .setId(14))
-                    .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
-                    .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
-            .setValue(456)
-            .setGranted(987654L)
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSetApproval> parser = protoConverter.getParser();
-    Entities.PatchSetApproval parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index 2519e75..efeb24f 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
-import com.google.protobuf.Parser;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.util.Optional;
@@ -148,23 +147,6 @@
                 .build());
   }
 
-  @Test
-  public void protoCanBeParsedFromBytes() throws Exception {
-    Entities.PatchSet proto =
-        Entities.PatchSet.newBuilder()
-            .setId(
-                Entities.PatchSet_Id.newBuilder()
-                    .setChangeId(Entities.Change_Id.newBuilder().setId(103))
-                    .setId(73))
-            .build();
-    byte[] bytes = proto.toByteArray();
-
-    Parser<Entities.PatchSet> parser = patchSetProtoConverter.getParser();
-    Entities.PatchSet parsedProto = parser.parseFrom(bytes);
-
-    assertThat(parsedProto).isEqualTo(proto);
-  }
-
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
   @Test
   public void fieldsExistAsExpected() {
diff --git a/plugins/replication b/plugins/replication
index b730e71..ab80790 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit b730e71f3287e49bba0d2bff220ea9597873bd13
+Subproject commit ab8079055a92fa4068a2982306c11425f347e12f
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index a0c53c6..3548ec8 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit a0c53c6c5ad1ba8f8967ed6d2bcb18995f734cad
+Subproject commit 3548ec83c0c271a8768a6b03b0c28711521ed6cf
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
index 91c2b7b..5820139 100644
--- a/polygerrit-ui/app/api/core.ts
+++ b/polygerrit-ui/app/api/core.ts
@@ -32,7 +32,7 @@
  * however a range with end_line set to 5 and end_character equal to 0 will not
  * include any characters on line 5.
  */
-export interface CommentRange {
+export declare interface CommentRange {
   /** The start line number of the range. (1-based) */
   start_line: number;
 
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 0a6366d..b5624c6 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -219,7 +219,7 @@
   end_line: number;
 }
 
-export interface CoverageRange {
+export declare interface CoverageRange {
   type: CoverageType;
   side: Side;
   code_range: LineRange;
@@ -245,3 +245,41 @@
   side: Side;
   lineNum: LineNumber;
 }
+
+export enum GrDiffLineType {
+  ADD = 'add',
+  BOTH = 'both',
+  BLANK = 'blank',
+  REMOVE = 'remove',
+}
+
+/** Describes a line to be rendered in a diff. */
+export declare interface GrDiffLine {
+  readonly type: GrDiffLineType;
+  /** The line number on the left side of the diff - 0 means none.  */
+  beforeNumber: LineNumber;
+  /** The line number on the right side of the diff - 0 means none.  */
+  afterNumber: LineNumber;
+}
+
+/**
+ * Interface to implemented to define a new layer in the diff.
+ *
+ * Layers can affect how the text of the diff or its line numbers
+ * are rendered.
+ */
+export declare interface DiffLayer {
+  /**
+   * Called during rendering and allows annotating the diff text or line number
+   * by mutating those elements.
+   *
+   * @param textElement The rendered text of one side of the diff.
+   * @param lineNumberElement The rendered line number of one side of the diff.
+   * @param line Describes the line that should be annotated.
+   */
+  annotate(
+    textElement: HTMLElement,
+    lineNumberElement: HTMLElement,
+    line: GrDiffLine
+  ): void;
+}
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index b0f87d7..03d5000 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -53,6 +53,7 @@
   TAG_SET_WIP = 'autogenerated:gerrit:setWorkInProgress',
   TAG_SET_ASSIGNEE = 'autogenerated:gerrit:setAssignee',
   TAG_UNSET_ASSIGNEE = 'autogenerated:gerrit:deleteAssignee',
+  TAG_MERGED = 'autogenerated:gerrit:merged',
 }
 
 /**
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index 2c66a3e..e77a5e8 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -178,7 +178,9 @@
   }
 
   _handleCreateClicked() {
-    this.$.createOverlay.open();
+    this.$.createOverlay.open().then(() => {
+      this.$.createNewModal.focus();
+    });
   }
 
   _visibleToAll(item: GroupInfo) {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
index ed196e4..2df1ac6 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
@@ -155,7 +155,8 @@
     });
 
     test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open');
+      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
+          Promise.resolve());
       element._handleCreateClicked();
       assert.isTrue(openStub.called);
     });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index 5902717..b68f720 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -55,6 +55,10 @@
     this.hasNewGroupName = !!name;
   }
 
+  focus() {
+    this.shadowRoot?.querySelector('input')?.focus();
+  }
+
   handleCreateGroup() {
     const name = this._name as GroupName;
     return this.restApiService.createGroup({name}).then(groupRegistered => {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index d3ce98a..1b16052 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -83,6 +83,10 @@
     return getBaseUrl() + '/admin/repos/' + encodeURL(repoName, true);
   }
 
+  focus() {
+    this.shadowRoot?.querySelector('input')?.focus();
+  }
+
   @observe('_repoConfig.name')
   _updateRepoName(name: string) {
     this.hasNewRepoName = !!name;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index 6f6f926..c1c8475 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -169,7 +169,9 @@
   }
 
   _handleCreateClicked() {
-    this.$.createOverlay.open();
+    this.$.createOverlay.open().then(() => {
+      this.$.createNewModal.focus();
+    });
   }
 
   _readOnly(repo: ProjectInfoWithName) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
index 6bf73d1..4904bf4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
@@ -151,7 +151,8 @@
     });
 
     test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open');
+      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
+          Promise.resolve());
       element._handleCreateClicked();
       assert.isTrue(openStub.called);
     });
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 78c48f8..5089b95 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -114,6 +114,7 @@
   getApprovalInfo,
   getVotingRange,
 } from '../../../utils/label-util';
+import {CommentThread} from '../../../utils/comment-util';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -442,6 +443,9 @@
   @property({type: String})
   _actionLoadingMessage = '';
 
+  @property({type: Array})
+  commentThreads: CommentThread[] = [];
+
   @property({
     type: Array,
     computed:
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
index 2bbfbbf..71500ac 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
@@ -216,6 +216,7 @@
       action="[[_revisionSubmitAction]]"
       on-cancel="_handleConfirmDialogCancel"
       on-confirm="_handleSubmitConfirm"
+      comment-threads="[[commentThreads]]"
       hidden=""
     ></gr-confirm-submit-dialog>
     <gr-dialog
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 3813a03..eded5a1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -21,8 +21,13 @@
 import {appContext} from '../../../services/app-context';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {Category, CheckRun, Link} from '../../../api/checks';
-import {allRuns$, RunResult} from '../../../services/checks/checks-model';
+import {
+  allRuns$,
+  aPluginHasRegistered,
+  RunResult,
+} from '../../../services/checks/checks-model';
 import {fireShowPrimaryTab} from '../../../utils/event-util';
+import '../../shared/gr-avatar/gr-avatar';
 import {
   hasCompleted,
   isRunning,
@@ -33,8 +38,12 @@
   CommentThread,
   isResolved,
   isUnresolved,
+  getFirstComment,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
+import {AccountInfo} from '../../../types/common';
+import {notUndefined} from '../../../types/types';
+import {uniqueDefinedAvatar} from '../../../utils/account-util';
 
 function filterResults(runs: CheckRun[], category: Category): RunResult[] {
   return runs.filter(isRunningOrHasCompleted).reduce((results, run) => {
@@ -71,8 +80,7 @@
           color: var(--chip-color);
           cursor: pointer;
           display: inline-block;
-          margin-right: var(--spacing-s);
-          padding: var(--spacing-xxs) var(--spacing-m) var(--spacing-xxs)
+          padding: var(--spacing-xxs) var(--spacing-s) var(--spacing-xxs)
             var(--spacing-s);
           border-radius: 12px;
           border: 1px solid gray;
@@ -263,26 +271,29 @@
 
 @customElement('gr-change-summary')
 export class GrChangeSummary extends GrLitElement {
-  private readonly ciRebootChecksEnabled = appContext.flagsService.isEnabled(
-    KnownExperimentId.CI_REBOOT_CHECKS
-  );
-
   private readonly newChangeSummaryUiEnabled = appContext.flagsService.isEnabled(
     KnownExperimentId.NEW_CHANGE_SUMMARY_UI
   );
 
-  @property({type: Array})
+  @property({type: Object})
   changeComments?: ChangeComments;
 
-  @property({type: Object})
+  @property({type: Array})
   commentThreads?: CommentThread[];
 
+  @property({type: Object})
+  selfAccount?: AccountInfo;
+
   @property()
   runs: CheckRun[] = [];
 
+  @property()
+  showChecksSummary = false;
+
   constructor() {
     super();
     this.subscribe('runs', allRuns$);
+    this.subscribe('showChecksSummary', aPluginHasRegistered);
   }
 
   static get styles() {
@@ -307,6 +318,12 @@
           margin-right: var(--spacing-s);
           margin-left: var(--spacing-m);
         }
+        gr-avatar {
+          height: var(--line-height-small, 16px);
+          width: var(--line-height-small, 16px);
+          vertical-align: top;
+          margin-right: var(--spacing-xs);
+        }
       `,
     ];
   }
@@ -316,15 +333,16 @@
     const errors = filterResults(runs, Category.ERROR);
     const warnings = filterResults(runs, Category.WARNING);
     const infos = filterResults(runs, Category.INFO);
-    const numResolvedComments =
+    const countResolvedComments =
       this.commentThreads?.filter(isResolved).length ?? 0;
-    const numUnResolvedComments =
-      this.commentThreads?.filter(isUnresolved).length ?? 0;
+    const unresolvedThreads = this.commentThreads?.filter(isUnresolved) ?? [];
+    const countUnresolvedComments = unresolvedThreads.length;
+    const unresolvedAuthors = this.getAccounts(unresolvedThreads);
     const draftCount = this.changeComments?.computeDraftCount() ?? 0;
     return html`
       <div>
         <table>
-          <tr ?hidden=${!this.ciRebootChecksEnabled}>
+          <tr ?hidden=${!this.showChecksSummary}>
             <td class="key">Checks</td>
             <td class="value">
               <gr-checks-chip
@@ -359,9 +377,9 @@
             <td class="value">
               <gr-summary-chip
                 styleType=${SummaryChipStyles.INFO}
-                ?hidden=${!!numResolvedComments ||
+                ?hidden=${!!countResolvedComments ||
                 !!draftCount ||
-                !!numUnResolvedComments}
+                !!countUnresolvedComments}
               >
                 No Comments</gr-summary-chip
               >
@@ -375,14 +393,23 @@
               <gr-summary-chip
                 styleType=${SummaryChipStyles.WARNING}
                 icon="message"
-                ?hidden=${!numUnResolvedComments}
-                >${numUnResolvedComments} unresolved</gr-summary-chip
+                ?hidden=${!countUnresolvedComments}
+              >
+                ${unresolvedAuthors.map(
+                  account =>
+                    html`<gr-avatar
+                      .account="${account}"
+                      image-size="32"
+                      aria-label="Account avatar"
+                    ></gr-avatar>`
+                )}
+                ${countUnresolvedComments} unresolved</gr-summary-chip
               >
               <gr-summary-chip
                 styleType=${SummaryChipStyles.CHECK}
                 icon="markChatRead"
-                ?hidden=${!numResolvedComments}
-                >${numResolvedComments} resolved</gr-summary-chip
+                ?hidden=${!countResolvedComments}
+                >${countResolvedComments} resolved</gr-summary-chip
               >
             </td>
           </tr>
@@ -394,6 +421,16 @@
       </div>
     `;
   }
+
+  getAccounts(commentThreads: CommentThread[]): AccountInfo[] {
+    const uniqueAuthors = commentThreads
+      .map(getFirstComment)
+      .map(comment => comment?.author ?? this.selfAccount)
+      .filter(notUndefined)
+      .filter(account => !!account?.avatars?.[0]?.url)
+      .filter(uniqueDefinedAvatar);
+    return uniqueAuthors.slice(0, 3);
+  }
 }
 
 declare global {
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 2d140a9f..e361f9f 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
@@ -161,6 +161,10 @@
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
+import {takeUntil} from 'rxjs/operators';
+import {aPluginHasRegistered} from '../../../services/checks/checks-model';
+import {Subject} from 'rxjs';
+import {GrRelatedChangesListExperimental} from '../gr-related-changes-list-experimental/gr-related-changes-list-experimental';
 
 const CHANGE_ID_ERROR = {
   MISMATCH: 'mismatch',
@@ -544,13 +548,16 @@
 
   _throttledToggleChangeStar?: EventListener;
 
-  _isChecksEnabled = false;
+  @property({type: Boolean})
+  _showChecksTab = false;
 
   @property({type: Boolean})
   _isNewChangeSummaryUiEnabled = false;
 
   restApiService = appContext.restApiService;
 
+  checksService = appContext.checksService;
+
   keyboardShortcuts() {
     return {
       [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
@@ -574,12 +581,14 @@
     };
   }
 
+  disconnected$ = new Subject();
+
   /** @override */
   ready() {
     super.ready();
-    this._isChecksEnabled = this.flagsService.isEnabled(
-      KnownExperimentId.CI_REBOOT_CHECKS
-    );
+    aPluginHasRegistered.pipe(takeUntil(this.disconnected$)).subscribe(b => {
+      this._showChecksTab = b;
+    });
     this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
       KnownExperimentId.NEW_CHANGE_SUMMARY_UI
     );
@@ -594,6 +603,12 @@
   }
 
   /** @override */
+  disconnectedCallback() {
+    this.disconnected$.next();
+    super.disconnectedCallback();
+  }
+
+  /** @override */
   created() {
     super.created();
 
@@ -2195,6 +2210,7 @@
     // are loaded.
     const detailCompletes = this._getChangeDetail();
     allDataPromises.push(detailCompletes);
+    this.checksService.reloadAll();
 
     // Resolves when the loading flag is set to false, meaning that some
     // change content may start appearing.
@@ -2281,6 +2297,9 @@
       this._editingCommitMessage = false;
       const relatedChangesLoaded = coreDataPromise.then(() => {
         this.getRelatedChangesList()?.reload();
+        if (this._isNewChangeSummaryUiEnabled) {
+          this.getRelatedChangesListExperimental()?.reload();
+        }
       });
       allDataPromises.push(relatedChangesLoaded);
     }
@@ -2804,6 +2823,12 @@
       '#relatedChanges'
     );
   }
+
+  getRelatedChangesListExperimental() {
+    return this.shadowRoot!.querySelector<GrRelatedChangesListExperimental>(
+      '#relatedChangesExperimental'
+    );
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index 36e73b2..ed33916 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -449,6 +449,7 @@
             on-edit-tap="_handleEditTap"
             on-stop-edit-tap="_handleStopEditTap"
             on-download-tap="_handleOpenDownloadDialog"
+            comment-threads="[[_commentThreads]]"
           ></gr-change-actions>
         </div>
         <!-- end commit actions -->
@@ -580,6 +581,7 @@
                 class$="new-change-summary-[[_isNewChangeSummaryUiEnabled]]"
                 change-comments="[[_changeComments]]"
                 comment-threads="[[_commentThreads]]"
+                self-account="[[_account]]"
               >
               </gr-change-summary>
               <gr-endpoint-decorator name="commit-container">
@@ -592,11 +594,14 @@
                 </gr-endpoint-param>
               </gr-endpoint-decorator>
             </div>
-            <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
-              <gr-related-changes-list-experimental></gr-related-changes-list-experimental>
-            </template>
-            <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
-              <div class="relatedChanges">
+            <div class="relatedChanges">
+              <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+                <gr-related-changes-list-experimental
+                  change="[[_change]]"
+                  id="relatedChangesExperimental"
+                ></gr-related-changes-list-experimental>
+              </template>
+              <template is="dom-if" if="[[!_isNewChangeSummaryUiEnabled]]">
                 <gr-related-changes-list
                   id="relatedChanges"
                   class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]"
@@ -618,8 +623,8 @@
                     [[_computeCollapseText(_relatedChangesCollapsed)]]
                   </gr-button>
                 </div>
-              </div>
-            </template>
+              </template>
+            </div>
           </div>
         </div>
       </div>
@@ -639,7 +644,7 @@
           <span>Comments</span></gr-tooltip-content
         >
       </paper-tab>
-      <template is="dom-if" if="[[_isChecksEnabled]]">
+      <template is="dom-if" if="[[_showChecksTab]]">
         <paper-tab data-name$="[[_constants.PrimaryTab.CHECKS]]"
           >Checks</paper-tab
         >
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index 6e2e595..45b61ae 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -20,6 +20,7 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../../styles/shared-styles';
+import '../gr-thread-list/gr-thread-list';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -28,6 +29,7 @@
 import {ChangeInfo, ActionInfo} from '../../../types/common';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {pluralize} from '../../../utils/string-util';
+import {CommentThread, isUnresolved} from '../../../utils/comment-util';
 
 export interface GrConfirmSubmitDialog {
   $: {
@@ -60,6 +62,9 @@
   @property({type: Object})
   action?: ActionInfo;
 
+  @property({type: Array})
+  commentThreads: CommentThread[] = [];
+
   resetFocus() {
     this.$.dialog.resetFocus();
   }
@@ -72,6 +77,10 @@
     );
   }
 
+  _computeUnresolvedThreads(commentThreads: CommentThread[]) {
+    return commentThreads.filter(thread => isUnresolved(thread));
+  }
+
   _computeUnresolvedCommentsWarning(change: ChangeInfo) {
     const unresolvedCount = change.unresolved_comment_count;
     if (!unresolvedCount) throw new Error('unresolved comments undefined or 0');
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
index 6c7b1c2..f62c5b82 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
@@ -67,6 +67,15 @@
             ></iron-icon>
             [[_computeUnresolvedCommentsWarning(change)]]
           </p>
+          <gr-thread-list
+            id="commentList"
+            threads="[[_computeUnresolvedThreads(commentThreads)]]"
+            change="[[change]]"
+            change-num="[[change._number]]"
+            logged-in="true"
+            hide-toggle-buttons
+          >
+          </gr-thread-list>
         </template>
         <template is="dom-if" if="[[_computeHasChangeEdit(change)]]">
           <iron-icon
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index bc61dad..39cdf22 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -27,7 +27,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-message_html';
-import {SpecialFilePath} from '../../../constants/constants';
+import {MessageTag, SpecialFilePath} from '../../../constants/constants';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
   ChangeInfo,
@@ -54,6 +54,8 @@
 
 const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/;
 const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
+const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
+const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -268,28 +270,49 @@
     return this._patchsetCommentSummary(commentThreads);
   }
 
+  _showViewDiffButton(message?: ChangeMessage) {
+    return (
+      this._isNewPatchsetTag(message?.tag) || this._isMergePatchset(message)
+    );
+  }
+
+  _isMergePatchset(message?: ChangeMessage) {
+    return (
+      message?.tag === MessageTag.TAG_MERGED &&
+      message?.message.match(MERGED_PATCHSET_PATTERN)
+    );
+  }
+
   _isNewPatchsetTag(tag?: ReviewInputTag) {
-    return tag?.endsWith(':newPatchSet') || tag?.endsWith(':newWipPatchSet');
+    return (
+      tag === MessageTag.TAG_NEW_PATCHSET ||
+      tag === MessageTag.TAG_NEW_WIP_PATCHSET
+    );
   }
 
   _handleViewPatchsetDiff(e: Event) {
     if (!this.message || !this.change) return;
-    const match = this.message.message.match(/Uploaded patch set (\d+)./);
     let patchNum: PatchSetNum;
-    // Message is of the form "Commit Message was updated" or "Patchset X
-    // was rebased"
-    if (!match || match.length < 1) {
-      patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
-    } else {
+    let basePatchNum: PatchSetNum;
+    if (this.message.message.match(UPLOADED_NEW_PATCHSET_PATTERN)) {
+      const match = this.message.message.match(UPLOADED_NEW_PATCHSET_PATTERN)!;
       if (isNaN(Number(match[1])))
         throw new Error('invalid patchnum in message');
       patchNum = Number(match[1]) as PatchSetNum;
+      basePatchNum = computePredecessor(patchNum)!;
+    } else if (this.message.message.match(MERGED_PATCHSET_PATTERN)) {
+      const match = this.message.message.match(MERGED_PATCHSET_PATTERN)!;
+      if (isNaN(Number(match[1])))
+        throw new Error('invalid patchnum in message');
+      basePatchNum = Number(match[1]) as PatchSetNum;
+      patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
+    } else {
+      // Message is of the form "Commit Message was updated" or "Patchset X
+      // was rebased"
+      patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
+      basePatchNum = computePredecessor(patchNum)!;
     }
-    GerritNav.navigateToChange(
-      this.change,
-      patchNum,
-      computePredecessor(patchNum)
-    );
+    GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
     // stop propagation to stop message expansion
     e.stopPropagation();
   }
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 48e5e24..070aa2d 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -279,7 +279,7 @@
         </div>
       </template>
       <span class="dateContainer">
-        <template is="dom-if" if="[[_isNewPatchsetTag(message.tag)]]">
+        <template is="dom-if" if="[[_showViewDiffButton(message)]]">
           <gr-button
             class="patchsetDiffButton"
             on-click="_handleViewPatchsetDiff"
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
index e2c7e86..3f4e13a 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
@@ -266,6 +266,14 @@
         element._handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(navStub.calledWithExactly(element.change, 4, 3));
       });
+
+      test('Merged patchset change message', () => {
+        element.message = {
+          message: 'abcd↵3 is the latest approved patch-set.↵abc',
+        };
+        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        assert.isTrue(navStub.calledWithExactly(element.change, 4, 3));
+      });
     });
 
     suite('compute messages', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
index ab7566f..a66bb04 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-changes-list-experimental.ts
@@ -16,22 +16,217 @@
  */
 import {html} from 'lit-html';
 import {GrLitElement} from '../../lit/gr-lit-element';
-import {customElement} from 'lit-element';
+import {customElement, property, css} from 'lit-element';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {
+  SubmittedTogetherInfo,
+  ChangeInfo,
+  RelatedChangeAndCommitInfo,
+} from '../../../types/common';
+import {appContext} from '../../../services/app-context';
+import {ParsedChangeInfo} from '../../../types/types';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {pluralize} from '../../../utils/string-util';
+import {ChangeStatus} from '../../../constants/constants';
 
+function isChangeInfo(
+  x: ChangeInfo | RelatedChangeAndCommitInfo | ParsedChangeInfo
+): x is ChangeInfo | ParsedChangeInfo {
+  return (x as ChangeInfo)._number !== undefined;
+}
 @customElement('gr-related-changes-list-experimental')
 export class GrRelatedChangesListExperimental extends GrLitElement {
+  @property()
+  change?: ParsedChangeInfo;
+
+  @property()
+  _submittedTogether?: SubmittedTogetherInfo = {
+    changes: [],
+    non_visible_changes: 0,
+  };
+
+  private readonly restApiService = appContext.restApiService;
+
   static get styles() {
-    return [sharedStyles];
+    return [
+      sharedStyles,
+      css`
+        .title {
+          font-weight: var(--font-weight-bold);
+          color: var(--deemphasized-text-color);
+          padding-left: var(--metadata-horizontal-padding);
+        }
+        h4,
+        section gr-related-change {
+          display: flex;
+        }
+        h4:before,
+        section gr-related-change:before {
+          content: ' ';
+          flex-shrink: 0;
+          width: 1.2em;
+        }
+        .note {
+          color: var(--error-text-color);
+        }
+      `,
+    ];
   }
 
   render() {
-    return html``;
+    const submittedTogetherChanges = this._submittedTogether?.changes ?? [];
+    const countNonVisibleChanges =
+      this._submittedTogether?.non_visible_changes ?? 0;
+    return html` <section
+      id="submittedTogether"
+      ?hidden=${!submittedTogetherChanges?.length &&
+      !this._submittedTogether?.non_visible_changes}
+    >
+      <h4 class="title">Submitted together</h4>
+      ${submittedTogetherChanges.map(
+        relatedChange =>
+          html`<gr-related-change
+            .currentChange="${this._changesEqual(relatedChange, this.change)}"
+            .change="${relatedChange}"
+          ></gr-related-change>`
+      )}
+      <div class="note" ?hidden=${!countNonVisibleChanges}>
+        (+ ${pluralize(countNonVisibleChanges, 'non-visible change')})
+      </div>
+    </section>`;
+  }
+
+  reload() {
+    if (!this.change) return Promise.reject(new Error('change missing'));
+    return this.restApiService
+      .getChangesSubmittedTogether(this.change._number)
+      .then(response => {
+        this._submittedTogether = response;
+      });
+  }
+
+  /**
+   * Do the given objects describe the same change? Compares the changes by
+   * their numbers.
+   */
+  _changesEqual(
+    a?: ChangeInfo | RelatedChangeAndCommitInfo,
+    b?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
+  ) {
+    const aNum = this._getChangeNumber(a);
+    const bNum = this._getChangeNumber(b);
+    return aNum === bNum;
+  }
+
+  /**
+   * Get the change number from either a ChangeInfo (such as those included in
+   * SubmittedTogetherInfo responses) or get the change number from a
+   * RelatedChangeAndCommitInfo (such as those included in a
+   * RelatedChangesInfo response).
+   */
+  _getChangeNumber(
+    change?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
+  ) {
+    // Default to 0 if change property is not defined.
+    if (!change) return 0;
+
+    if (isChangeInfo(change)) {
+      return change._number;
+    }
+    return change._change_number;
+  }
+}
+
+@customElement('gr-related-change')
+export class GrRelatedChange extends GrLitElement {
+  @property()
+  change?: ChangeInfo;
+
+  @property()
+  currentChange = false;
+
+  static get styles() {
+    return [
+      sharedStyles,
+      css`
+        a {
+          display: block;
+        }
+        .changeContainer,
+        a {
+          max-width: 100%;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .changeContainer {
+          display: flex;
+        }
+        .strikethrough {
+          color: var(--deemphasized-text-color);
+          text-decoration: line-through;
+        }
+        .submittableCheck {
+          padding-left: var(--spacing-s);
+          color: var(--positive-green-text-color);
+          display: none;
+        }
+        .submittableCheck.submittable {
+          display: inline;
+        }
+        .arrowToCurrentChange {
+          position: absolute;
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const change = this.change;
+    if (!change) throw new Error('Missing change');
+    const linkClass = this._computeLinkClass(change);
+    return html`<span
+        role="img"
+        class="arrowToCurrentChange"
+        aria-label="Arrow marking current change"
+        ?hidden=${!this.currentChange}
+        >➔</span
+      >
+      <div class="changeContainer">
+        <a
+          href="${GerritNav.getUrlForChangeById(
+            change._number,
+            change.project
+          )}"
+          class="${linkClass}"
+          >${change.project}: ${change.branch}: ${change.subject}</a
+        >
+        <span
+          tabindex="-1"
+          title="Submittable"
+          class="submittableCheck ${linkClass}"
+          role="img"
+          aria-label="Submittable"
+          >✓</span
+        >
+      </div> `;
+  }
+
+  _computeLinkClass(change: ChangeInfo) {
+    const statuses = [];
+    if (change.status === ChangeStatus.ABANDONED) {
+      statuses.push('strikethrough');
+    }
+    if (change.submittable) {
+      statuses.push('submittable');
+    }
+    return statuses.join(' ');
   }
 }
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-related-changes-list-experimental': GrRelatedChangesListExperimental;
+    'gr-related-change': GrRelatedChange;
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
index 26d413f..2927101 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
@@ -15,20 +15,17 @@
  * limitations under the License.
  */
 
-import {LineNumber} from '../../../api/diff';
+import {
+  GrDiffLine as GrDiffLineApi,
+  GrDiffLineType,
+  LineNumber,
+} from '../../../api/diff';
 
-export {LineNumber};
+export {GrDiffLineType, LineNumber};
 
 export const FILE = 'FILE';
 
-export enum GrDiffLineType {
-  ADD = 'add',
-  BOTH = 'both',
-  BLANK = 'blank',
-  REMOVE = 'remove',
-}
-
-export class GrDiffLine {
+export class GrDiffLine implements GrDiffLineApi {
   constructor(
     readonly type: GrDiffLineType,
     public beforeNumber: LineNumber = 0,
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
index b7a1216..82c1087 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -46,7 +46,7 @@
   constructor(readonly plugin: PluginApi) {}
 
   announceUpdate() {
-    this.checksService.announceUpdate(this.plugin.getPluginName());
+    this.checksService.reload(this.plugin.getPluginName());
   }
 
   register(provider: ChecksProvider, config?: ChecksApiConfig): void {
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index bce46c3..1116db0 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -24,7 +24,7 @@
   LinkIcon,
   RunStatus,
 } from '../../api/checks';
-import {map} from 'rxjs/operators';
+import {distinctUntilChanged, map} from 'rxjs/operators';
 
 // This is a convenience type for working with results, because when working
 // with a bunch of results you will typically also want to know about the run
@@ -48,6 +48,11 @@
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const checksState$: Observable<ChecksState> = privateState$;
 
+export const aPluginHasRegistered = checksState$.pipe(
+  map(state => Object.keys(state).length > 0),
+  distinctUntilChanged()
+);
+
 export const allRuns$ = checksState$.pipe(
   map(state => {
     return Object.values(state).reduce(
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index 439a44f..a8dd8b8 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -15,7 +15,13 @@
  * limitations under the License.
  */
 
-import {switchMap, takeWhile, tap, withLatestFrom} from 'rxjs/operators';
+import {
+  switchMap,
+  takeWhile,
+  tap,
+  throttleTime,
+  withLatestFrom,
+} from 'rxjs/operators';
 import {
   ChecksApiConfig,
   ChecksProvider,
@@ -36,12 +42,16 @@
 export class ChecksService {
   private readonly providers: {[name: string]: ChecksProvider} = {};
 
-  private readonly anouncementSubjects: {[name: string]: Subject<void>} = {};
+  private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
 
   private changeAndPatchNum$ = change$.pipe(withLatestFrom(currentPatchNum$));
 
-  announceUpdate(pluginName: string) {
-    this.anouncementSubjects[pluginName].next();
+  reload(pluginName: string) {
+    this.reloadSubjects[pluginName].next();
+  }
+
+  reloadAll() {
+    Object.keys(this.providers).forEach(key => this.reload(key));
   }
 
   register(
@@ -50,12 +60,12 @@
     config: ChecksApiConfig
   ) {
     this.providers[pluginName] = provider;
-    this.anouncementSubjects[pluginName] = new BehaviorSubject<void>(undefined);
+    this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
     updateStateSetProvider(pluginName, config);
     // Both, changed numbers and and announceUpdate request should trigger.
     combineLatest([
       this.changeAndPatchNum$,
-      this.anouncementSubjects[pluginName],
+      this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
     ])
       .pipe(
         takeWhile(_ => !!this.providers[pluginName]),
@@ -77,5 +87,6 @@
         })
       )
       .subscribe(() => {});
+    this.reload(pluginName);
   }
 }
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index c43a8fd..5edbf87 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,6 +25,9 @@
  */
 export enum KnownExperimentId {
   NEW_CONTEXT_CONTROLS = 'UiFeature__new_context_controls',
+  // Note that this flag is not supposed to be used by Gerrit itself, but can
+  // be used by plugins. The new Checks UI will show up, if a plugin registers
+  // with the new Checks plugin API.
   CI_REBOOT_CHECKS = 'UiFeature__ci_reboot_checks',
   NEW_CHANGE_SUMMARY_UI = 'UiFeature__new_change_summary_ui',
   PORTING_COMMENTS = 'UiFeature__porting_comments',
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index a7e654c..be80411 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -14,9 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {DiffLayer as DiffLayerApi} from '../api/diff';
 import {DiffViewMode, MessageTag, Side} from '../constants/constants';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {GrDiffLine} from '../elements/diff/gr-diff/gr-diff-line';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {
@@ -150,8 +150,7 @@
   side: Side
 ) => void;
 
-export interface DiffLayer {
-  annotate(el: HTMLElement, lineEl: HTMLElement, line: GrDiffLine): void;
+export interface DiffLayer extends DiffLayerApi {
   addListener?(listener: DiffLayerListener): void;
   removeListener?(listener: DiffLayerListener): void;
 }
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 99c864e..bb9f328 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -35,3 +35,17 @@
 export function removeServiceUsers(accounts?: AccountInfo[]): AccountInfo[] {
   return accounts?.filter(a => !isServiceUser(a)) || [];
 }
+
+export function hasSameAvatar(account?: AccountInfo, other?: AccountInfo) {
+  return account?.avatars?.[0]?.url === other?.avatars?.[0]?.url;
+}
+
+export function uniqueDefinedAvatar(
+  account: AccountInfo,
+  index: number,
+  accountArray: AccountInfo[]
+) {
+  return (
+    index === accountArray.findIndex(other => hasSameAvatar(account, other))
+  );
+}
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 9c34b60..d7e7595 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -173,6 +173,10 @@
   return thread && len ? thread.comments[len - 1] : undefined;
 }
 
+export function getFirstComment(thread?: CommentThread): UIComment | undefined {
+  return thread?.comments?.[0];
+}
+
 export function isUnresolved(thread?: CommentThread): boolean {
   return !isResolved(thread);
 }