Merge "Add @plugins_npm for plugin dependencies"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 94c4552..ceef953 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1703,7 +1703,8 @@
 
 [[container.slave]]container.slave::
 +
-Backward compatibility for 'container.slave' config setting.
+Backward compatibility for link:#container.replica[`container.replica`]
+config setting.
 
 [[container.startupTimeout]]container.startupTimeout::
 +
@@ -2242,6 +2243,25 @@
 +
 By default false.
 
+[[gerrit.xframeOption]]gerrit.xframeOption::
++
+Add link:https://tools.ietf.org/html/rfc7034[`X-Frame-Options`] header to all HTTP
+responses. The `X-Frame-Options` HTTP response header can be used to indicate
+whether or not a browser should be allowed to render a page in a
+`<frame>`, `<iframe>`, `<embed>` or `<object>`.
++
+Available values:
++
+1. ALLOW - The page can be displayed in a frame.
+2. SAMEORIGIN - The page can only be displayed in a frame on the same origin as the page itself.
++
+If link:#gerrit.canLoadInIFrame is set to false this option is ignored and the
+`X-Frame-Options` header is always set to `DENY`.
+Setting this option to `ALLOW` will cause the `X-Frame-Options` header to be omitted
+the the page can be displayed in a frame.
++
+By default SAMEORIGIN.
+
 [[gerrit.cdnPath]]gerrit.cdnPath::
 +
 Path prefix for PolyGerrit's static resources if using a CDN.
@@ -2976,7 +2996,7 @@
 [[index.batchThreads]]index.batchThreads::
 +
 Number of threads to use for indexing in background operations, such as
-online schema upgrades.
+online schema upgrades, and also for offline reindexing.
 +
 If not set or set to a zero, defaults to the number of logical CPUs as returned
 by the JVM. If set to a negative value, defaults to a direct executor.
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index c298ba1..a01df50 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -316,27 +316,35 @@
 The submit section includes configuration of project-specific
 submit settings:
 
-[[content_merge]]
-- 'mergeContent': Defines whether Gerrit will try to
+[[content_merge]]submit.mergeContent::
++
+Defines whether Gerrit will try to
 do a content merge when a path conflict occurs. Valid values are
 'true', 'false', or 'INHERIT'.  Default is 'INHERIT'. This option can
 be modified by any project owner through the project console, `Browse`
 > `Repositories` > my/project > `Allow content merges`.
 
-- 'action': Defines the link:#submit-type[submit type].  Valid
+[[submit.action]]submit.action::
++
+Defines the link:#submit-type[submit type].  Valid
 values are 'fast forward only', 'merge if necessary', 'rebase if necessary',
 'rebase always', 'merge always' and 'cherry pick'.  The default is 'merge if necessary'.
 
-- 'matchAuthorToCommitterDate': Defines whether to the author date will be changed to match the
-submitter date upon submit, so that git log shows when the change was submitted instead of when the
-author last committed. Valid values are 'true', 'false', or 'INHERIT'. The default is 'INHERIT'.
-This option only takes effect in submit strategies which already modify the commit, i.e.
-Cherry Pick, Rebase Always, and (perhaps) Rebase If Necessary.
+[[submit.matchAuthorToCommitterDate]]submit.matchAuthorToCommitterDate::
++
+Defines whether the author date will be changed to match the submitter date upon submit, so that
+git log shows when the change was submitted instead of when the author last committed. Valid
+values are 'true', 'false', or 'INHERIT'. The default is 'INHERIT'. This option only takes effect
+in submit strategies which already modify the commit, i.e. Cherry Pick, Rebase Always, and
+(when rebase is necessary) Rebase If Necessary.
 
-- 'rejectEmptyCommit': Defines whether empty commits should be rejected when a change is merged.
-Changes might not seem empty at first but when attempting to merge, rebasing can lead to an empty
-commit. If this option is set to 'true' the merge would fail. An empty commit is still allowed as
-the initial commit on a branch.
+[[submit.rejectEmptyCommit]]submit.rejectEmptyCommit::
++
+Defines whether empty commits should be rejected when a change is merged. When using
+link:#submit.action[submit action] Cherry Pick, Rebase If Necessary or Rebase Always changes may
+become empty upon submit, since the rebase|cherry-pick can lead to an empty commit. If this option
+is set to 'true' the merge would fail in such a case. An empty commit is still allowed as the
+initial commit on a branch.
 
 [[submit-type]]
 ==== Submit Type
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index 5a54c5f..9bc7f0b 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -116,7 +116,7 @@
 link:https://github.com/google/google-java-format[`google-java-format`,role=external,window=_blank]
 tool (version 1.7), and to format Bazel BUILD, WORKSPACE and .bzl files the
 link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`,role=external,window=_blank]
-tool (version 3.0.0). Unused dependencies are found and removed using the
+tool (version 3.2.1). Unused dependencies are found and removed using the
 link:https://github.com/bazelbuild/buildtools/tree/master/unused_deps[`unused_deps`,role=external,window=_blank]
 build tool, a sibling of `buildifier`.
 
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index 0505dd2..89758a0 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -110,6 +110,13 @@
 normally run `gerrit.war daemon` with an `-Xmx` flag, pass that to the migration
 tool as well.
 
+[NOTE]
+Note that by appending `--reindex false` to the above command, you can skip the
+lengthy, implicit reindexing step of the migration. This is useful if you plan
+to perform further Gerrit upgrades while the server is offline and have to
+reindex later anyway (E.g.: a follow-up upgrade to Gerrit 3.2 or newer, which
+requires to reindex changes anyway).
+
 *Advantages*
 
 * Much faster than online; can use all available CPUs, since no live traffic
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 4006d1d..066f6d8 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -4249,7 +4249,6 @@
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/submit HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
 ----
 
 As response a link:#submit-info[SubmitInfo] entity is returned that
@@ -4471,7 +4470,7 @@
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/test.submit_type HTTP/1.0
-  Content-Type: text/plain; charset-UTF-8
+  Content-Type: text/plain; charset=UTF-8
 
   submit_type(cherry_pick).
 ----
@@ -4502,7 +4501,7 @@
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/test.submit_rule?filters=SKIP HTTP/1.0
-  Content-Type: text/plain; charset-UTF-8
+  Content-Type: text/plain; charset=UTF-8
 
   submit_rule(submit(R)) :-
     R = label('Any-Label-Name', reject(_)).
@@ -6191,7 +6190,7 @@
 Notify handling that defines to whom email notifications should be sent
 after the cherry-pick. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
-If not set, the default is `NONE`.
+If not set, the default is `ALL`.
 |`notify_details`   |optional|
 Additional information about whom to notify about the update as a map
 of recipient type to link:#notify-info[NotifyInfo] entity.
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
index 746a386..62fcfda 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -28,7 +28,8 @@
   V7_4("7.4.*"),
   V7_5("7.5.*"),
   V7_6("7.6.*"),
-  V7_7("7.7.*");
+  V7_7("7.7.*"),
+  V7_8("7.8.*");
 
   private final String version;
   private final Pattern pattern;
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index 3926d47..e6b2167 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -19,6 +19,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.UsedAt;
 import java.util.List;
 
 /**
@@ -107,6 +108,16 @@
     public char getCode() {
       return code;
     }
+
+    @UsedAt(UsedAt.Project.COLLABNET)
+    public static ChangeType forCode(char c) {
+      for (ChangeType s : ChangeType.values()) {
+        if (s.code == c) {
+          return s;
+        }
+      }
+      return null;
+    }
   }
 
   /** Type of formatting for this patch. */
diff --git a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index 69c1790..fb03bc5 100644
--- a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -24,7 +24,7 @@
   public String base;
   public Integer parent;
 
-  public NotifyHandling notify = NotifyHandling.NONE;
+  public NotifyHandling notify = NotifyHandling.ALL;
   public Map<RecipientType, NotifyInfo> notifyDetails;
 
   public boolean keepReviewers;
diff --git a/java/com/google/gerrit/extensions/client/ListOption.java b/java/com/google/gerrit/extensions/client/ListOption.java
index 4dea42f..dba2eee 100644
--- a/java/com/google/gerrit/extensions/client/ListOption.java
+++ b/java/com/google/gerrit/extensions/client/ListOption.java
@@ -48,9 +48,9 @@
     return r;
   }
 
-  static String toHex(Set<ListChangesOption> options) {
+  static <T extends Enum<T> & ListOption> String toHex(Set<T> options) {
     int v = 0;
-    for (ListChangesOption option : options) {
+    for (T option : options) {
       v |= 1 << option.getValue();
     }
 
diff --git a/java/com/google/gerrit/httpd/AllRequestFilter.java b/java/com/google/gerrit/httpd/AllRequestFilter.java
index 9d171d5..1c3cafe 100644
--- a/java/com/google/gerrit/httpd/AllRequestFilter.java
+++ b/java/com/google/gerrit/httpd/AllRequestFilter.java
@@ -18,6 +18,8 @@
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.StopPluginListener;
 import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Scopes;
 import com.google.inject.Singleton;
 import com.google.inject.internal.UniqueAnnotations;
 import com.google.inject.servlet.ServletModule;
@@ -32,11 +34,15 @@
 
 /** Filters all HTTP requests passing through the server. */
 public abstract class AllRequestFilter implements Filter {
-  public static ServletModule module() {
+  public static Module module() {
     return new ServletModule() {
       @Override
       protected void configureServlets() {
         DynamicSet.setOf(binder(), AllRequestFilter.class);
+        DynamicSet.bind(binder(), AllRequestFilter.class)
+            .to(AllowRenderInFrameFilter.class)
+            .in(Scopes.SINGLETON);
+
         filter("/*").through(FilterProxy.class);
 
         bind(StopPluginListener.class)
diff --git a/java/com/google/gerrit/httpd/AllowRenderInFrameFilter.java b/java/com/google/gerrit/httpd/AllowRenderInFrameFilter.java
new file mode 100644
index 0000000..b414aad
--- /dev/null
+++ b/java/com/google/gerrit/httpd/AllowRenderInFrameFilter.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+
+public class AllowRenderInFrameFilter extends AllRequestFilter {
+  static final String X_FRAME_OPTIONS_HEADER_NAME = "X-Frame-Options";
+
+  public static enum XFrameOption {
+    ALLOW,
+    SAMEORIGIN;
+  }
+
+  private final String xframeOptionString;
+  private final boolean skipXFrameOption;
+
+  @Inject
+  public AllowRenderInFrameFilter(@GerritServerConfig Config cfg) {
+    XFrameOption xframeOption =
+        cfg.getEnum("gerrit", null, "xframeOption", XFrameOption.SAMEORIGIN);
+    boolean canLoadInIFrame = cfg.getBoolean("gerrit", "canLoadInIFrame", false);
+    xframeOptionString = canLoadInIFrame ? xframeOption.name() : "DENY";
+
+    skipXFrameOption = xframeOption.equals(XFrameOption.ALLOW) && canLoadInIFrame;
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    if (skipXFrameOption) {
+      chain.doFilter(request, response);
+    } else {
+      HttpServletResponse httpResponse = (HttpServletResponse) response;
+      httpResponse.addHeader(X_FRAME_OPTIONS_HEADER_NAME, xframeOptionString);
+      chain.doFilter(request, httpResponse);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 41d2f83..cddaea4 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -153,8 +153,7 @@
           serializeObject(GSON, accountApi.getEditPreferences()));
       data.put("userIsAuthenticated", true);
     } catch (AuthException e) {
-      logger.atFine().withCause(e).log(
-          "Can't inline account-related data because user is unauthenticated");
+      logger.atFine().log("Can't inline account-related data because user is unauthenticated");
       // Don't render data
     }
 
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 89ebdc1..f743578 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -601,6 +601,7 @@
         }
       } catch (MalformedJsonException | JsonParseException e) {
         cause = Optional.of(e);
+        logger.atFine().withCause(e).log("REST call failed on JSON parsing");
         responseBytes =
             replyError(
                 req, res, statusCode = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 0aa374b..3aa9de0 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -176,6 +177,33 @@
     return true;
   }
 
+  private Values<T> fieldValues(T obj, FieldDef<T, ?> f, ImmutableSet<String> skipFields) {
+    if (skipFields.contains(f.getName())) {
+      return null;
+    }
+
+    Object v;
+    try {
+      v = f.get(obj);
+    } catch (StorageException e) {
+      // StorageException is thrown when the object is not found. On this case,
+      // it is pointless to make further attempts for each field, so propagate
+      // the exception to return an empty list.
+      logger.atSevere().withCause(e).log("error getting field %s of %s", f.getName(), obj);
+      throw e;
+    } catch (RuntimeException e) {
+      logger.atSevere().withCause(e).log("error getting field %s of %s", f.getName(), obj);
+      return null;
+    }
+    if (v == null) {
+      return null;
+    } else if (f.isRepeatable()) {
+      return new Values<>(f, (Iterable<?>) v);
+    } else {
+      return new Values<>(f, Collections.singleton(v));
+    }
+  }
+
   /**
    * Build all fields in the schema from an input object.
    *
@@ -186,31 +214,14 @@
    * @return all non-null field values from the object.
    */
   public final Iterable<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
-    return fields.values().stream()
-        .map(
-            f -> {
-              if (skipFields.contains(f.getName())) {
-                return null;
-              }
-
-              Object v;
-              try {
-                v = f.get(obj);
-              } catch (RuntimeException e) {
-                logger.atSevere().withCause(e).log(
-                    "error getting field %s of %s", f.getName(), obj);
-                return null;
-              }
-              if (v == null) {
-                return null;
-              } else if (f.isRepeatable()) {
-                return new Values<>(f, (Iterable<?>) v);
-              } else {
-                return new Values<>(f, Collections.singleton(v));
-              }
-            })
-        .filter(Objects::nonNull)
-        .collect(toImmutableList());
+    try {
+      return fields.values().stream()
+          .map(f -> fieldValues(obj, f, skipFields))
+          .filter(Objects::nonNull)
+          .collect(toImmutableList());
+    } catch (StorageException e) {
+      return ImmutableList.of();
+    }
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/SiteIndexer.java b/java/com/google/gerrit/index/SiteIndexer.java
index ecfc7bd..32b4b21 100644
--- a/java/com/google/gerrit/index/SiteIndexer.java
+++ b/java/com/google/gerrit/index/SiteIndexer.java
@@ -83,7 +83,7 @@
   }
 
   protected PrintWriter newPrintWriter(OutputStream out) {
-    return new PrintWriter(new OutputStreamWriter(out, UTF_8));
+    return new PrintWriter(new OutputStreamWriter(out, UTF_8), true);
   }
 
   private static class ErrorListener implements Runnable {
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 2e526bb..966801f 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -202,6 +202,9 @@
     if (result.success()) {
       index.markReady(true);
     }
+    System.out.format(
+        "Index %s in version %d is %sready\n",
+        def.getName(), index.getSchema().getVersion(), result.success() ? "" : "NOT ");
     return result.success();
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 2a8a5ba..e9349c4 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -21,7 +21,6 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
 import com.google.common.base.Stopwatch;
-import com.google.common.collect.ComparisonChain;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -43,10 +42,9 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
-import java.util.SortedSet;
-import java.util.TreeSet;
 import java.util.concurrent.Callable;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -62,6 +60,7 @@
  */
 public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final int PROJECT_SLICE_MAX_REFS = 1000;
 
   private final ChangeData.Factory changeDataFactory;
   private final GitRepositoryManager repoManager;
@@ -86,22 +85,27 @@
     this.projectCache = projectCache;
   }
 
-  private static class ProjectHolder implements Comparable<ProjectHolder> {
-    final Project.NameKey name;
-    private final long size;
+  private static class ProjectSlice {
+    private final Project.NameKey name;
+    private final int slice;
+    private final int slices;
 
-    ProjectHolder(Project.NameKey name, long size) {
+    ProjectSlice(Project.NameKey name, int slice, int slices) {
       this.name = name;
-      this.size = size;
+      this.slice = slice;
+      this.slices = slices;
     }
 
-    @Override
-    public int compareTo(ProjectHolder other) {
-      // Sort projects based on size first to maximize utilization of threads early on.
-      return ComparisonChain.start()
-          .compare(other.size, size)
-          .compare(other.name.get(), name.get())
-          .result();
+    public Project.NameKey getName() {
+      return name;
+    }
+
+    public int getSlice() {
+      return slice;
+    }
+
+    public int getSlices() {
+      return slices;
     }
   }
 
@@ -109,19 +113,39 @@
   public Result indexAll(ChangeIndex index) {
     ProgressMonitor pm = new TextProgressMonitor();
     pm.beginTask("Collecting projects", ProgressMonitor.UNKNOWN);
-    SortedSet<ProjectHolder> projects = new TreeSet<>();
+    List<ProjectSlice> projectSlices = new ArrayList<>();
     int changeCount = 0;
     Stopwatch sw = Stopwatch.createStarted();
     int projectsFailed = 0;
     for (Project.NameKey name : projectCache.all()) {
       try (Repository repo = repoManager.openRepository(name)) {
+        // The simplest approach to distribute indexing would be to let each thread grab a project
+        // and index it fully. But if a site has one big project and 100s of small projects, then
+        // in the beginning all CPUs would be busy reindexing projects. But soon enough all small
+        // projects have been reindexed, and only the thread that reindexes the big project is
+        // still working. The other threads would idle. Reindexing the big project on a single
+        // thread becomes the critical path. Bringing in more CPUs would not speed up things.
+        //
+        // To avoid such situations, we split big repos into smaller parts and let
+        // the thread pool index these smaller parts. This splitting introduces an overhead in the
+        // workload setup and there might be additional slow-downs from multiple threads
+        // concurrently working on different parts of the same project. But for Wikimedia's Gerrit,
+        // which had 2 big projects, many middle sized ones, and lots of smaller ones, the
+        // splitting of repos into smaller parts reduced indexing time from 1.5 hours to 55 minutes
+        // in 2020.
         int size = estimateSize(repo);
         changeCount += size;
-        projects.add(new ProjectHolder(name, size));
+        int slices = 1 + size / PROJECT_SLICE_MAX_REFS;
+        if (slices > 1) {
+          verboseWriter.println("Submitting " + name + " for indexing in " + slices + " slices");
+        }
+        for (int slice = 0; slice < slices; slice++) {
+          projectSlices.add(new ProjectSlice(name, slice, slices));
+        }
       } catch (IOException e) {
         logger.atSevere().withCause(e).log("Error collecting project %s", name);
         projectsFailed++;
-        if (projectsFailed > projects.size() / 2) {
+        if (projectsFailed > projectCache.all().size() / 2) {
           logger.atSevere().log("Over 50%% of the projects could not be collected: aborted");
           return Result.create(sw, false, 0, 0);
         }
@@ -130,7 +154,15 @@
     }
     pm.endTask();
     setTotalWork(changeCount);
-    return indexAll(index, projects);
+
+    // projectSlices are currently grouped by projects. First all slices for project1, followed
+    // by all slices for project2, and so on. As workers pick tasks sequentially, multiple threads
+    // would typically work concurrently on different slices of the same project. While this is not
+    // a big issue, shuffling the list beforehand helps with ungrouping the project slices, so
+    // different slices are less likely to be worked on concurrently.
+    // This shuffling gave a 6% runtime reduction for Wikimedia's Gerrit in 2020.
+    Collections.shuffle(projectSlices);
+    return indexAll(index, projectSlices);
   }
 
   private int estimateSize(Repository repo) throws IOException {
@@ -146,10 +178,10 @@
     return Ints.saturatedCast(size);
   }
 
-  private SiteIndexer.Result indexAll(ChangeIndex index, SortedSet<ProjectHolder> projects) {
+  private SiteIndexer.Result indexAll(ChangeIndex index, List<ProjectSlice> projectSlices) {
     Stopwatch sw = Stopwatch.createStarted();
     MultiProgressMonitor mpm = new MultiProgressMonitor(progressOut, "Reindexing changes");
-    Task projTask = mpm.beginSubTask("projects", projects.size());
+    Task projTask = mpm.beginSubTask("project-slices", projectSlices.size());
     checkState(totalWork >= 0);
     Task doneTask = mpm.beginSubTask(null, totalWork);
     Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
@@ -157,12 +189,21 @@
     List<ListenableFuture<?>> futures = new ArrayList<>();
     AtomicBoolean ok = new AtomicBoolean(true);
 
-    for (ProjectHolder project : projects) {
+    for (ProjectSlice projectSlice : projectSlices) {
+      Project.NameKey name = projectSlice.getName();
+      int slice = projectSlice.getSlice();
+      int slices = projectSlice.getSlices();
       ListenableFuture<?> future =
           executor.submit(
               reindexProject(
-                  indexerFactory.create(executor, index), project.name, doneTask, failedTask));
-      addErrorListener(future, "project " + project.name, projTask, ok);
+                  indexerFactory.create(executor, index),
+                  name,
+                  slice,
+                  slices,
+                  doneTask,
+                  failedTask));
+      String description = "project " + name + " (" + slice + "/" + slices + ")";
+      addErrorListener(future, description, projTask, ok);
       futures.add(future);
     }
 
@@ -197,22 +238,38 @@
 
   public Callable<Void> reindexProject(
       ChangeIndexer indexer, Project.NameKey project, Task done, Task failed) {
-    return new ProjectIndexer(indexer, project, done, failed);
+    return reindexProject(indexer, project, 0, 1, done, failed);
+  }
+
+  public Callable<Void> reindexProject(
+      ChangeIndexer indexer,
+      Project.NameKey project,
+      int slice,
+      int slices,
+      Task done,
+      Task failed) {
+    return new ProjectIndexer(indexer, project, slice, slices, done, failed);
   }
 
   private class ProjectIndexer implements Callable<Void> {
     private final ChangeIndexer indexer;
     private final Project.NameKey project;
+    private final int slice;
+    private final int slices;
     private final ProgressMonitor done;
     private final ProgressMonitor failed;
 
     private ProjectIndexer(
         ChangeIndexer indexer,
         Project.NameKey project,
+        int slice,
+        int slices,
         ProgressMonitor done,
         ProgressMonitor failed) {
       this.indexer = indexer;
       this.project = project;
+      this.slice = slice;
+      this.slices = slices;
       this.done = done;
       this.failed = failed;
     }
@@ -227,7 +284,7 @@
         // It does mean that reindexing after invalidating the DiffSummary cache will be expensive,
         // but the goal is to invalidate that cache as infrequently as we possibly can. And besides,
         // we don't have concrete proof that improving packfile locality would help.
-        notesFactory.scan(repo, project).forEach(r -> index(r));
+        notesFactory.scan(repo, project, id -> (id.get() % slices) == slice).forEach(r -> index(r));
       } catch (RepositoryNotFoundException rnfe) {
         logger.atSevere().log(rnfe.getMessage());
       } finally {
@@ -244,7 +301,8 @@
       try {
         indexer.index(changeDataFactory.create(r.notes()));
         done.update(1);
-        verboseWriter.println("Reindexed change " + r.id());
+        verboseWriter.format(
+            "Reindexed change %d (project: %s)\n", r.id().get(), r.notes().getProjectName().get());
       } catch (RejectedExecutionException e) {
         // Server shutdown, don't spam the logs.
         failSilently();
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 0a71fa1..36a61cc0 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -207,9 +207,19 @@
 
     public Stream<ChangeNotesResult> scan(Repository repo, Project.NameKey project)
         throws IOException {
+      return scan(repo, project, null);
+    }
+
+    public Stream<ChangeNotesResult> scan(
+        Repository repo, Project.NameKey project, Predicate<Change.Id> changeIdPredicate)
+        throws IOException {
       ScanResult sr = scanChangeIds(repo);
 
-      return sr.all().stream().map(id -> scanOneChange(project, sr, id)).filter(Objects::nonNull);
+      Stream<Change.Id> idStream = sr.all().stream();
+      if (changeIdPredicate != null) {
+        idStream = idStream.filter(changeIdPredicate);
+      }
+      return idStream.map(id -> scanOneChange(project, sr, id)).filter(Objects::nonNull);
     }
 
     @Nullable
diff --git a/java/com/google/gerrit/server/project/ProjectJson.java b/java/com/google/gerrit/server/project/ProjectJson.java
index f2254d6..f00df53 100644
--- a/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/java/com/google/gerrit/server/project/ProjectJson.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.Project;
@@ -34,6 +35,7 @@
 /** Collection of routines to populate {@link ProjectInfo}. */
 @Singleton
 public class ProjectJson {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final AllProjectsName allProjects;
   private final WebLinks webLinks;
@@ -50,7 +52,17 @@
     for (LabelType t : projectState.getLabelTypes().getLabelTypes()) {
       LabelTypeInfo labelInfo = new LabelTypeInfo();
       labelInfo.values =
-          t.getValues().stream().collect(toMap(LabelValue::formatValue, LabelValue::getText));
+          t.getValues().stream()
+              .collect(
+                  toMap(
+                      LabelValue::formatValue,
+                      LabelValue::getText,
+                      (v1, v2) -> {
+                        logger.atSevere().log(
+                            "Duplicate values for project: %s, label: %s found: '%s':'%s'",
+                            projectState.getName(), t.getName(), v1, v2);
+                        return v1;
+                      }));
       labelInfo.defaultValue = t.getDefaultValue();
       info.labels.put(t.getName(), labelInfo);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 534c164..e77bfe7 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -214,7 +214,7 @@
 
       updatedChange = op.merge(change, submitter, true, input, false);
       if (updatedChange.isMerged()) {
-        return Response.ok(new Output(change));
+        return Response.ok(new Output(updatedChange));
       }
 
       throw new IllegalStateException(
diff --git a/java/com/google/gerrit/server/rules/PrologOptions.java b/java/com/google/gerrit/server/rules/PrologOptions.java
index da9b3ab..a176f04 100644
--- a/java/com/google/gerrit/server/rules/PrologOptions.java
+++ b/java/com/google/gerrit/server/rules/PrologOptions.java
@@ -15,18 +15,21 @@
 package com.google.gerrit.server.rules;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import java.util.Optional;
 
 @AutoValue
 public abstract class PrologOptions {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static PrologOptions defaultOptions() {
     return new AutoValue_PrologOptions.Builder().logErrors(true).skipFilters(false).build();
   }
 
   public static PrologOptions dryRunOptions(String ruleToTest, boolean skipFilters) {
     return new AutoValue_PrologOptions.Builder()
-        .logErrors(false)
+        .logErrors(logger.atFine().isEnabled())
         .skipFilters(skipFilters)
         .rule(ruleToTest)
         .build();
diff --git a/java/com/google/gerrit/server/submit/CircularPathFinder.java b/java/com/google/gerrit/server/submit/CircularPathFinder.java
new file mode 100644
index 0000000..d1920da
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/CircularPathFinder.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+
+class CircularPathFinder {
+  private CircularPathFinder() {}
+
+  /**
+   * Prints a circular path according to the nodes in {@code p} and the start node {@code target}.
+   */
+  public static <T> String printCircularPath(Collection<T> p, T target) {
+    StringBuilder sb = new StringBuilder();
+    sb.append(target);
+    ArrayList<T> reverseP = new ArrayList<>(p);
+    Collections.reverse(reverseP);
+    for (T t : reverseP) {
+      sb.append("->");
+      sb.append(t);
+      if (t.equals(target)) {
+        break;
+      }
+    }
+    return sb.toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 9018d9d..f96b0c5 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -635,13 +635,13 @@
         throw e;
       }
 
-      // BatchUpdate may have inadvertently wrapped an IntegrationException
+      // BatchUpdate may have inadvertently wrapped an IntegrationConflictException
       // thrown by some legacy SubmitStrategyOp code that intended the error
       // message to be user-visible. Copy the message from the wrapped
       // exception.
       //
       // If you happen across one of these, the correct fix is to convert the
-      // inner IntegrationException to a ResourceConflictException.
+      // inner IntegrationConflictException to a ResourceConflictException.
       if (e.getCause() instanceof IntegrationConflictException) {
         throw (IntegrationConflictException) e.getCause();
       }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 5558c74..ab28aa9 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -23,7 +23,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -36,13 +35,11 @@
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -394,24 +391,13 @@
     }
   }
 
-  private String getByAccountName() {
-    requireNonNull(submitter, "getByAccountName called before submitter populated");
-    Optional<Account> account =
-        args.accountCache.get(submitter.accountId()).map(AccountState::account);
-    if (account.isPresent() && account.get().fullName() != null) {
-      return " by " + ChangeNoteUtil.getAccountIdAsUsername(account.get().id());
-    }
-    return "";
-  }
-
   private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s) {
     requireNonNull(s, "CommitMergeStatus may not be null");
     String txt = s.getDescription();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
-      return message(ctx, commit.getPatchsetId(), txt + getByAccountName());
+      return message(ctx, commit.getPatchsetId(), txt);
     } else if (s == CommitMergeStatus.CLEAN_REBASE || s == CommitMergeStatus.CLEAN_PICK) {
-      return message(
-          ctx, commit.getPatchsetId(), txt + " as " + commit.name() + getByAccountName());
+      return message(ctx, commit.getPatchsetId(), txt + " as " + commit.name());
     } else if (s == CommitMergeStatus.SKIPPED_IDENTICAL_TREE) {
       return message(ctx, commit.getPatchsetId(), txt);
     } else if (s == CommitMergeStatus.ALREADY_MERGED) {
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 6854e90..5adda2c 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -14,19 +14,14 @@
 
 package com.google.gerrit.server.submit;
 
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmoduleSubscription;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -46,17 +41,11 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.Deque;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -72,10 +61,8 @@
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.RefSpec;
 
 public class SubmoduleOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -126,40 +113,15 @@
     }
   }
 
-  private final GitModules.Factory gitmodulesFactory;
   private final PersonIdent myIdent;
-  private final ProjectCache projectCache;
   private final VerboseSuperprojectUpdate verboseSuperProject;
-  private final boolean enableSuperProjectSubscriptions;
   private final long maxCombinedCommitMessageSize;
   private final long maxCommitMessages;
   private final MergeOpRepoManager orm;
-  private final Map<BranchNameKey, GitModules> branchGitModules;
-
-  /** Branches updated as part of the enclosing submit or push batch. */
-  private final ImmutableSet<BranchNameKey> updatedBranches;
-
-  /**
-   * Branches in a superproject that contain submodule subscriptions, plus branches in submodules
-   * which are subscribed to by some superproject.
-   */
-  private final Set<BranchNameKey> affectedBranches;
-
-  /** Copy of {@link #affectedBranches}, sorted by submodule traversal order. */
-  private final ImmutableSet<BranchNameKey> sortedBranches;
-
-  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
-  private final SetMultimap<BranchNameKey, SubmoduleSubscription> targets;
+  private final SubscriptionGraph.Factory subscriptionGraphFactory;
+  private final SubscriptionGraph subscriptionGraph;
 
   private final BranchTips branchTips = new BranchTips();
-  /**
-   * Multimap of superproject name to all branch names within that superproject which have submodule
-   * subscriptions.
-   */
-  private final SetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
-
-  /** All branches subscribed by other projects. */
-  private final Set<BranchNameKey> subscribedBranches;
 
   private SubmoduleOp(
       GitModules.Factory gitmodulesFactory,
@@ -169,239 +131,27 @@
       Set<BranchNameKey> updatedBranches,
       MergeOpRepoManager orm)
       throws SubmoduleConflictException {
-    this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
-    this.projectCache = projectCache;
     this.verboseSuperProject =
         cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
-    this.enableSuperProjectSubscriptions =
-        cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true);
     this.maxCombinedCommitMessageSize =
         cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
     this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
     this.orm = orm;
-    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
-    this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.affectedBranches = new HashSet<>();
-    this.branchGitModules = new HashMap<>();
-    this.branchesByProject = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.subscribedBranches = new HashSet<>();
-    this.sortedBranches = calculateSubscriptionMaps();
-  }
-
-  /**
-   * Calculate the internal maps used by the operation.
-   *
-   * <p>In addition to the return value, the following fields are populated as a side effect:
-   *
-   * <ul>
-   *   <li>{@link #affectedBranches}
-   *   <li>{@link #targets}
-   *   <li>{@link #branchesByProject}
-   *   <li>{@link #subscribedBranches}
-   * </ul>
-   *
-   * @return the ordered set to be stored in {@link #sortedBranches}.
-   */
-  // TODO(dborowitz): This setup process is hard to follow, in large part due to the accumulation of
-  // mutable maps, which makes this whole class difficult to understand.
-  //
-  // A cleaner architecture for this process might be:
-  //   1. Separate out the code to parse submodule subscriptions and build up an in-memory data
-  //      structure representing the subscription graph, using a separate class with a properly-
-  //      documented interface.
-  //   2. Walk the graph to produce a work plan. This would be a list of items indicating: create a
-  //      commit in project X reading branch tips for submodules S1..Sn and updating gitlinks in X.
-  //   3. Execute the work plan, i.e. convert the items into BatchUpdate.Ops and add them to the
-  //      relevant updates.
-  //
-  // In addition to improving readability, this approach has the advantage of making (1) and (2)
-  // testable using small tests.
-  private ImmutableSet<BranchNameKey> calculateSubscriptionMaps()
-      throws SubmoduleConflictException {
-    if (!enableSuperProjectSubscriptions) {
+    this.subscriptionGraphFactory =
+        new SubscriptionGraph.DefaultFactory(gitmodulesFactory, projectCache, orm);
+    if (cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true)) {
+      this.subscriptionGraph = subscriptionGraphFactory.compute(updatedBranches);
+    } else {
       logger.atFine().log("Updating superprojects disabled");
-      return null;
+      this.subscriptionGraph =
+          SubscriptionGraph.createEmptyGraph(ImmutableSet.copyOf(updatedBranches));
     }
-
-    logger.atFine().log("Calculating superprojects - submodules map");
-    LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
-    for (BranchNameKey updatedBranch : updatedBranches) {
-      if (allVisited.contains(updatedBranch)) {
-        continue;
-      }
-
-      searchForSuperprojects(updatedBranch, new LinkedHashSet<>(), allVisited);
-    }
-
-    // Since the searchForSuperprojects will add all branches (related or
-    // unrelated) and ensure the superproject's branches get added first before
-    // a submodule branch. Need remove all unrelated branches and reverse
-    // the order.
-    allVisited.retainAll(affectedBranches);
-    reverse(allVisited);
-    return ImmutableSet.copyOf(allVisited);
-  }
-
-  private void searchForSuperprojects(
-      BranchNameKey current,
-      LinkedHashSet<BranchNameKey> currentVisited,
-      LinkedHashSet<BranchNameKey> allVisited)
-      throws SubmoduleConflictException {
-    logger.atFine().log("Now processing %s", current);
-
-    if (currentVisited.contains(current)) {
-      throw new SubmoduleConflictException(
-          "Branch level circular subscriptions detected:  "
-              + printCircularPath(currentVisited, current));
-    }
-
-    if (allVisited.contains(current)) {
-      return;
-    }
-
-    currentVisited.add(current);
-    try {
-      Collection<SubmoduleSubscription> subscriptions =
-          superProjectSubscriptionsForSubmoduleBranch(current);
-      for (SubmoduleSubscription sub : subscriptions) {
-        BranchNameKey superBranch = sub.getSuperProject();
-        searchForSuperprojects(superBranch, currentVisited, allVisited);
-        targets.put(superBranch, sub);
-        branchesByProject.put(superBranch.project(), superBranch);
-        affectedBranches.add(superBranch);
-        affectedBranches.add(sub.getSubmodule());
-        subscribedBranches.add(sub.getSubmodule());
-      }
-    } catch (IOException e) {
-      throw new StorageException("Cannot find superprojects for " + current, e);
-    }
-    currentVisited.remove(current);
-    allVisited.add(current);
-  }
-
-  private static <T> void reverse(LinkedHashSet<T> set) {
-    if (set == null) {
-      return;
-    }
-
-    Deque<T> q = new ArrayDeque<>(set);
-    set.clear();
-
-    while (!q.isEmpty()) {
-      set.add(q.removeLast());
-    }
-  }
-
-  private <T> String printCircularPath(LinkedHashSet<T> p, T target) {
-    StringBuilder sb = new StringBuilder();
-    sb.append(target);
-    ArrayList<T> reverseP = new ArrayList<>(p);
-    Collections.reverse(reverseP);
-    for (T t : reverseP) {
-      sb.append("->");
-      sb.append(t);
-      if (t.equals(target)) {
-        break;
-      }
-    }
-    return sb.toString();
-  }
-
-  private Collection<BranchNameKey> getDestinationBranches(BranchNameKey src, SubscribeSection s)
-      throws IOException {
-    Collection<BranchNameKey> ret = new HashSet<>();
-    logger.atFine().log("Inspecting SubscribeSection %s", s);
-    for (RefSpec r : s.getMatchingRefSpecs()) {
-      logger.atFine().log("Inspecting [matching] ref %s", r);
-      if (!r.matchSource(src.branch())) {
-        continue;
-      }
-      if (r.isWildcard()) {
-        // refs/heads/*[:refs/somewhere/*]
-        ret.add(
-            BranchNameKey.create(
-                s.getProject(), r.expandFromSource(src.branch()).getDestination()));
-      } else {
-        // e.g. refs/heads/master[:refs/heads/stable]
-        String dest = r.getDestination();
-        if (dest == null) {
-          dest = r.getSource();
-        }
-        ret.add(BranchNameKey.create(s.getProject(), dest));
-      }
-    }
-
-    for (RefSpec r : s.getMultiMatchRefSpecs()) {
-      logger.atFine().log("Inspecting [all] ref %s", r);
-      if (!r.matchSource(src.branch())) {
-        continue;
-      }
-      OpenRepo or;
-      try {
-        or = orm.getRepo(s.getProject());
-      } catch (NoSuchProjectException e) {
-        // A project listed a non existent project to be allowed
-        // to subscribe to it. Allow this for now, i.e. no exception is
-        // thrown.
-        continue;
-      }
-
-      for (Ref ref : or.repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS)) {
-        if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
-          continue;
-        }
-        BranchNameKey b = BranchNameKey.create(s.getProject(), ref.getName());
-        if (!ret.contains(b)) {
-          ret.add(b);
-        }
-      }
-    }
-    logger.atFine().log("Returning possible branches: %s for project %s", ret, s.getProject());
-    return ret;
-  }
-
-  private Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
-      BranchNameKey srcBranch) throws IOException {
-    logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
-    Collection<SubmoduleSubscription> ret = new ArrayList<>();
-    Project.NameKey srcProject = srcBranch.project();
-    for (SubscribeSection s :
-        projectCache
-            .get(srcProject)
-            .orElseThrow(illegalState(srcProject))
-            .getSubscribeSections(srcBranch)) {
-      logger.atFine().log("Checking subscribe section %s", s);
-      Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s);
-      for (BranchNameKey targetBranch : branches) {
-        Project.NameKey targetProject = targetBranch.project();
-        try {
-          OpenRepo or = orm.getRepo(targetProject);
-          ObjectId id = or.repo.resolve(targetBranch.branch());
-          if (id == null) {
-            logger.atFine().log("The branch %s doesn't exist.", targetBranch);
-            continue;
-          }
-        } catch (NoSuchProjectException e) {
-          logger.atFine().log("The project %s doesn't exist", targetProject);
-          continue;
-        }
-
-        GitModules m = branchGitModules.get(targetBranch);
-        if (m == null) {
-          m = gitmodulesFactory.create(targetBranch, orm);
-          branchGitModules.put(targetBranch, m);
-        }
-        ret.addAll(m.subscribedTo(srcBranch));
-      }
-    }
-    logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
-    return ret;
   }
 
   @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
   public boolean hasSuperproject(BranchNameKey branch) {
-    return subscribedBranches.contains(branch);
+    return subscriptionGraph.hasSuperproject(branch);
   }
 
   public void updateSuperProjects() throws RestApiException {
@@ -414,11 +164,11 @@
     try {
       for (Project.NameKey project : projects) {
         // only need superprojects
-        if (branchesByProject.containsKey(project)) {
+        if (subscriptionGraph.isAffectedSuperProject(project)) {
           superProjects.add(project);
           // get a new BatchUpdate for the super project
           OpenRepo or = orm.getRepo(project);
-          for (BranchNameKey branch : branchesByProject.get(project)) {
+          for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
             addOp(or.getUpdate(), branch);
           }
         }
@@ -454,7 +204,7 @@
     int count = 0;
 
     List<SubmoduleSubscription> subscriptions =
-        targets.get(subscriber).stream()
+        subscriptionGraph.getSubscriptions(subscriber).stream()
             .sorted(comparing(SubmoduleSubscription::getPath))
             .collect(toList());
     for (SubmoduleSubscription s : subscriptions) {
@@ -507,7 +257,7 @@
     StringBuilder msgbuf = new StringBuilder();
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
-    for (SubmoduleSubscription s : targets.get(subscriber)) {
+    for (SubmoduleSubscription s : subscriptionGraph.getSubscriptions(subscriber)) {
       updateSubmodule(dc, ed, msgbuf, s);
     }
     ed.finish();
@@ -584,6 +334,7 @@
     }
 
     CodeReviewCommit newCommit = maybeNewCommit.get();
+
     if (Objects.equals(newCommit, oldCommit)) {
       // gitlink have already been updated for this submodule
       return null;
@@ -669,11 +420,11 @@
 
   ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleConflictException {
     LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
-    for (Project.NameKey project : branchesByProject.keySet()) {
+    for (Project.NameKey project : subscriptionGraph.getAffectedSuperProjects()) {
       addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
     }
 
-    for (BranchNameKey branch : updatedBranches) {
+    for (BranchNameKey branch : subscriptionGraph.getUpdatedBranches()) {
       projects.add(branch.project());
     }
     return ImmutableSet.copyOf(projects);
@@ -686,7 +437,8 @@
       throws SubmoduleConflictException {
     if (current.contains(project)) {
       throw new SubmoduleConflictException(
-          "Project level circular subscriptions detected:  " + printCircularPath(current, project));
+          "Project level circular subscriptions detected:  "
+              + CircularPathFinder.printCircularPath(current, project));
     }
 
     if (projects.contains(project)) {
@@ -695,8 +447,8 @@
 
     current.add(project);
     Set<Project.NameKey> subprojects = new HashSet<>();
-    for (BranchNameKey branch : branchesByProject.get(project)) {
-      Collection<SubmoduleSubscription> subscriptions = targets.get(branch);
+    for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
+      Collection<SubmoduleSubscription> subscriptions = subscriptionGraph.getSubscriptions(branch);
       for (SubmoduleSubscription s : subscriptions) {
         subprojects.add(s.getSubmodule().project());
       }
@@ -712,15 +464,13 @@
 
   ImmutableSet<BranchNameKey> getBranchesInOrder() {
     LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
-    if (sortedBranches != null) {
-      branches.addAll(sortedBranches);
-    }
-    branches.addAll(updatedBranches);
+    branches.addAll(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches());
+    branches.addAll(subscriptionGraph.getUpdatedBranches());
     return ImmutableSet.copyOf(branches);
   }
 
   boolean hasSubscription(BranchNameKey branch) {
-    return targets.containsKey(branch);
+    return subscriptionGraph.hasSubscription(branch);
   }
 
   void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
diff --git a/java/com/google/gerrit/server/submit/SubscriptionGraph.java b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
new file mode 100644
index 0000000..406d878
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
@@ -0,0 +1,375 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.RefSpec;
+
+/**
+ * A container which stores subscription relationship. A SubscriptionGraph is calculated every time
+ * changes are pushed. Some branches are updated in these changes, and if these branches are
+ * subscribed by other projects, SubscriptionGraph would record information about these updated
+ * branches and branches/projects affected.
+ */
+public class SubscriptionGraph {
+  /** Branches updated as part of the enclosing submit or push batch. */
+  private final ImmutableSet<BranchNameKey> updatedBranches;
+
+  /**
+   * All branches affected, including those in superprojects and submodules, sorted by submodule
+   * traversal order. To support nested subscriptions, GitLink commits need to be updated in order.
+   * The closer to topological "leaf", the earlier a commit should be updated.
+   *
+   * <p>For example, there are three projects, top level project p1 subscribed to p2, p2 subscribed
+   * to bottom level project p3. When submit a change for p3. We need update both p2 and p1. To be
+   * more precise, we need update p2 first and then update p1.
+   */
+  private final ImmutableSet<BranchNameKey> sortedBranches;
+
+  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
+  private final ImmutableSetMultimap<BranchNameKey, SubmoduleSubscription> targets;
+
+  /**
+   * Multimap of superproject name to all branch names within that superproject which have submodule
+   * subscriptions.
+   */
+  private final ImmutableSetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
+
+  /** All branches subscribed by other projects. */
+  private final ImmutableSet<BranchNameKey> subscribedBranches;
+
+  public SubscriptionGraph(
+      Set<BranchNameKey> updatedBranches,
+      SetMultimap<BranchNameKey, SubmoduleSubscription> targets,
+      SetMultimap<Project.NameKey, BranchNameKey> branchesByProject,
+      Set<BranchNameKey> subscribedBranches,
+      Set<BranchNameKey> sortedBranches) {
+    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
+    this.targets = ImmutableSetMultimap.copyOf(targets);
+    this.branchesByProject = ImmutableSetMultimap.copyOf(branchesByProject);
+    this.subscribedBranches = ImmutableSet.copyOf(subscribedBranches);
+    this.sortedBranches = ImmutableSet.copyOf(sortedBranches);
+  }
+
+  /** Returns an empty {@code SubscriptionGraph}. */
+  static SubscriptionGraph createEmptyGraph(Set<BranchNameKey> updatedBranches) {
+    return new SubscriptionGraph(
+        updatedBranches,
+        ImmutableSetMultimap.of(),
+        ImmutableSetMultimap.of(),
+        ImmutableSet.of(),
+        ImmutableSet.of());
+  }
+
+  /** Get branches updated as part of the enclosing submit or push batch. */
+  ImmutableSet<BranchNameKey> getUpdatedBranches() {
+    return updatedBranches;
+  }
+
+  /** Get all superprojects affected. */
+  ImmutableSet<Project.NameKey> getAffectedSuperProjects() {
+    return branchesByProject.keySet();
+  }
+
+  /** See if a {@code project} is a superproject affected. */
+  boolean isAffectedSuperProject(Project.NameKey project) {
+    return branchesByProject.containsKey(project);
+  }
+
+  /**
+   * Returns all branches within the superproject {@code project} which have submodule
+   * subscriptions.
+   */
+  ImmutableSet<BranchNameKey> getAffectedSuperBranches(Project.NameKey project) {
+    return branchesByProject.get(project);
+  }
+
+  /**
+   * Get all affected branches, including the submodule branches and superproject branches, sorted
+   * by traversal order.
+   *
+   * @see SubscriptionGraph#sortedBranches
+   */
+  ImmutableSet<BranchNameKey> getSortedSuperprojectAndSubmoduleBranches() {
+    return sortedBranches;
+  }
+
+  /** Check if a {@code branch} is a submodule of a superproject. */
+  boolean hasSuperproject(BranchNameKey branch) {
+    return subscribedBranches.contains(branch);
+  }
+
+  /** See if a {@code branch} is a superproject branch affected. */
+  boolean hasSubscription(BranchNameKey branch) {
+    return targets.containsKey(branch);
+  }
+
+  /** Get all related {@code SubmoduleSubscription}s whose super branch is {@code branch}. */
+  ImmutableSet<SubmoduleSubscription> getSubscriptions(BranchNameKey branch) {
+    return targets.get(branch);
+  }
+
+  public interface Factory {
+    SubscriptionGraph compute(Set<BranchNameKey> updatedBranches) throws SubmoduleConflictException;
+  }
+
+  static class DefaultFactory implements Factory {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+    private final ProjectCache projectCache;
+    private final GitModules.Factory gitmodulesFactory;
+    private final Map<BranchNameKey, GitModules> branchGitModules;
+    private final MergeOpRepoManager orm;
+
+    // Fields required to the constructor of SubscriptionGraph.
+    /** All affected branches, including those in superprojects and submodules. */
+    private final Set<BranchNameKey> affectedBranches;
+
+    /** @see SubscriptionGraph#targets */
+    private final SetMultimap<BranchNameKey, SubmoduleSubscription> targets;
+
+    /** @see SubscriptionGraph#branchesByProject */
+    private final SetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
+
+    /** @see SubscriptionGraph#subscribedBranches */
+    private final Set<BranchNameKey> subscribedBranches;
+
+    DefaultFactory(
+        GitModules.Factory gitmodulesFactory, ProjectCache projectCache, MergeOpRepoManager orm) {
+      this.gitmodulesFactory = gitmodulesFactory;
+      this.projectCache = projectCache;
+      this.orm = orm;
+      this.branchGitModules = new HashMap<>();
+
+      this.affectedBranches = new HashSet<>();
+      this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
+      this.branchesByProject = MultimapBuilder.hashKeys().hashSetValues().build();
+      this.subscribedBranches = new HashSet<>();
+    }
+
+    @Override
+    public SubscriptionGraph compute(Set<BranchNameKey> updatedBranches)
+        throws SubmoduleConflictException {
+      return new SubscriptionGraph(
+          updatedBranches,
+          targets,
+          branchesByProject,
+          subscribedBranches,
+          calculateSubscriptionMaps(updatedBranches));
+    }
+
+    /**
+     * Calculate the internal maps used by the operation.
+     *
+     * <p>In addition to the return value, the following fields are populated as a side effect:
+     *
+     * <ul>
+     *   <li>{@link #affectedBranches}
+     *   <li>{@link #targets}
+     *   <li>{@link #branchesByProject}
+     *   <li>{@link #subscribedBranches}
+     * </ul>
+     *
+     * @return the ordered set to be stored in {@link #sortedBranches}.
+     */
+    private Set<BranchNameKey> calculateSubscriptionMaps(Set<BranchNameKey> updatedBranches)
+        throws SubmoduleConflictException {
+      logger.atFine().log("Calculating superprojects - submodules map");
+      LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
+      for (BranchNameKey updatedBranch : updatedBranches) {
+        if (allVisited.contains(updatedBranch)) {
+          continue;
+        }
+
+        searchForSuperprojects(updatedBranch, new LinkedHashSet<>(), allVisited);
+      }
+
+      // Since the searchForSuperprojects will add all branches (related or
+      // unrelated) and ensure the superproject's branches get added first before
+      // a submodule branch. Need remove all unrelated branches and reverse
+      // the order.
+      allVisited.retainAll(affectedBranches);
+      reverse(allVisited);
+      return allVisited;
+    }
+
+    private void searchForSuperprojects(
+        BranchNameKey current,
+        LinkedHashSet<BranchNameKey> currentVisited,
+        LinkedHashSet<BranchNameKey> allVisited)
+        throws SubmoduleConflictException {
+      logger.atFine().log("Now processing %s", current);
+
+      if (currentVisited.contains(current)) {
+        throw new SubmoduleConflictException(
+            "Branch level circular subscriptions detected:  "
+                + CircularPathFinder.printCircularPath(currentVisited, current));
+      }
+
+      if (allVisited.contains(current)) {
+        return;
+      }
+
+      currentVisited.add(current);
+      try {
+        Collection<SubmoduleSubscription> subscriptions =
+            superProjectSubscriptionsForSubmoduleBranch(current);
+        for (SubmoduleSubscription sub : subscriptions) {
+          BranchNameKey superBranch = sub.getSuperProject();
+          searchForSuperprojects(superBranch, currentVisited, allVisited);
+          targets.put(superBranch, sub);
+          branchesByProject.put(superBranch.project(), superBranch);
+          affectedBranches.add(superBranch);
+          affectedBranches.add(sub.getSubmodule());
+          subscribedBranches.add(sub.getSubmodule());
+        }
+      } catch (IOException e) {
+        throw new StorageException("Cannot find superprojects for " + current, e);
+      }
+      currentVisited.remove(current);
+      allVisited.add(current);
+    }
+
+    private Collection<BranchNameKey> getDestinationBranches(BranchNameKey src, SubscribeSection s)
+        throws IOException {
+      Collection<BranchNameKey> ret = new HashSet<>();
+      logger.atFine().log("Inspecting SubscribeSection %s", s);
+      for (RefSpec r : s.getMatchingRefSpecs()) {
+        logger.atFine().log("Inspecting [matching] ref %s", r);
+        if (!r.matchSource(src.branch())) {
+          continue;
+        }
+        if (r.isWildcard()) {
+          // refs/heads/*[:refs/somewhere/*]
+          ret.add(
+              BranchNameKey.create(
+                  s.getProject(), r.expandFromSource(src.branch()).getDestination()));
+        } else {
+          // e.g. refs/heads/master[:refs/heads/stable]
+          String dest = r.getDestination();
+          if (dest == null) {
+            dest = r.getSource();
+          }
+          ret.add(BranchNameKey.create(s.getProject(), dest));
+        }
+      }
+
+      for (RefSpec r : s.getMultiMatchRefSpecs()) {
+        logger.atFine().log("Inspecting [all] ref %s", r);
+        if (!r.matchSource(src.branch())) {
+          continue;
+        }
+        OpenRepo or;
+        try {
+          or = orm.getRepo(s.getProject());
+        } catch (NoSuchProjectException e) {
+          // A project listed a non existent project to be allowed
+          // to subscribe to it. Allow this for now, i.e. no exception is
+          // thrown.
+          continue;
+        }
+
+        for (Ref ref : or.repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS)) {
+          if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
+            continue;
+          }
+          BranchNameKey b = BranchNameKey.create(s.getProject(), ref.getName());
+          if (!ret.contains(b)) {
+            ret.add(b);
+          }
+        }
+      }
+      logger.atFine().log("Returning possible branches: %s for project %s", ret, s.getProject());
+      return ret;
+    }
+
+    private Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
+        BranchNameKey srcBranch) throws IOException {
+      logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
+      Collection<SubmoduleSubscription> ret = new ArrayList<>();
+      Project.NameKey srcProject = srcBranch.project();
+      for (SubscribeSection s :
+          projectCache
+              .get(srcProject)
+              .orElseThrow(illegalState(srcProject))
+              .getSubscribeSections(srcBranch)) {
+        logger.atFine().log("Checking subscribe section %s", s);
+        Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s);
+        for (BranchNameKey targetBranch : branches) {
+          Project.NameKey targetProject = targetBranch.project();
+          try {
+            OpenRepo or = orm.getRepo(targetProject);
+            ObjectId id = or.repo.resolve(targetBranch.branch());
+            if (id == null) {
+              logger.atFine().log("The branch %s doesn't exist.", targetBranch);
+              continue;
+            }
+          } catch (NoSuchProjectException e) {
+            logger.atFine().log("The project %s doesn't exist", targetProject);
+            continue;
+          }
+
+          GitModules m = branchGitModules.get(targetBranch);
+          if (m == null) {
+            m = gitmodulesFactory.create(targetBranch, orm);
+            branchGitModules.put(targetBranch, m);
+          }
+          ret.addAll(m.subscribedTo(srcBranch));
+        }
+      }
+      logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
+      return ret;
+    }
+
+    private static <T> void reverse(LinkedHashSet<T> set) {
+      if (set == null) {
+        return;
+      }
+
+      Deque<T> q = new ArrayDeque<>(set);
+      set.clear();
+
+      while (!q.isEmpty()) {
+        set.add(q.removeLast());
+      }
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
index cf349ab..7b99a55 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -31,7 +31,7 @@
 
   @ConfigSuite.Config
   public static Config elasticsearchV7() {
-    return getConfig(ElasticVersion.V7_7);
+    return getConfig(ElasticVersion.V7_8);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 2901361..75950e2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -1344,11 +1344,11 @@
     assertThat(messages).hasSize(3);
     String last = Iterables.getLast(messages);
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      assertThat(last).startsWith("Change has been successfully cherry-picked as ");
+      assertThat(last).startsWith("Change has been successfully cherry-picked as");
     } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
       assertThat(last).startsWith("Change has been successfully rebased and submitted as");
     } else {
-      assertThat(last).isEqualTo("Change has been successfully merged by Gerrit User 1000000");
+      assertThat(last).isEqualTo("Change has been successfully merged");
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
index b539bf8..43a5ceb 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
@@ -33,7 +33,7 @@
 
   @ConfigSuite.Config
   public static Config elasticsearchV7() {
-    return getConfig(ElasticVersion.V7_7);
+    return getConfig(ElasticVersion.V7_8);
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index c20650f..f7a806b 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -60,6 +60,8 @@
         return "blacktop/elasticsearch:7.6.2";
       case V7_7:
         return "blacktop/elasticsearch:7.7.1";
+      case V7_8:
+        return "blacktop/elasticsearch:7.8.0";
     }
     throw new IllegalStateException("No tests for version: " + version.name());
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
index a015103..4826490 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
@@ -36,7 +36,7 @@
   public static void startIndexService() {
     if (container == null) {
       // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_7);
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
     }
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
index b1de591..d9a4d2e 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
@@ -46,7 +46,7 @@
   public static void startIndexService() {
     if (container == null) {
       // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_7);
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
       client = HttpAsyncClients.createDefault();
       client.start();
     }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
index 2e382d4..0fc96f8 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
@@ -36,7 +36,7 @@
   public static void startIndexService() {
     if (container == null) {
       // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_7);
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
     }
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
index 87a14da..1e56af9 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
@@ -36,7 +36,7 @@
   public static void startIndexService() {
     if (container == null) {
       // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_7);
+      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
     }
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index c9a7a46..ac7f33b 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -54,6 +54,9 @@
 
     assertThat(ElasticVersion.forVersion("7.7.0")).isEqualTo(ElasticVersion.V7_7);
     assertThat(ElasticVersion.forVersion("7.7.1")).isEqualTo(ElasticVersion.V7_7);
+
+    assertThat(ElasticVersion.forVersion("7.8.0")).isEqualTo(ElasticVersion.V7_8);
+    assertThat(ElasticVersion.forVersion("7.8.1")).isEqualTo(ElasticVersion.V7_8);
   }
 
   @Test
@@ -81,6 +84,7 @@
     assertThat(ElasticVersion.V7_5.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
     assertThat(ElasticVersion.V7_6.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
     assertThat(ElasticVersion.V7_7.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V7_8.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
   }
 
   @Test
@@ -96,6 +100,7 @@
     assertThat(ElasticVersion.V7_5.isV6OrLater()).isTrue();
     assertThat(ElasticVersion.V7_6.isV6OrLater()).isTrue();
     assertThat(ElasticVersion.V7_7.isV6OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_8.isV6OrLater()).isTrue();
   }
 
   @Test
@@ -111,5 +116,6 @@
     assertThat(ElasticVersion.V7_5.isV7OrLater()).isTrue();
     assertThat(ElasticVersion.V7_6.isV7OrLater()).isTrue();
     assertThat(ElasticVersion.V7_7.isV7OrLater()).isTrue();
+    assertThat(ElasticVersion.V7_8.isV7OrLater()).isTrue();
   }
 }
diff --git a/javatests/com/google/gerrit/httpd/AllowRenderInFrameFilterTest.java b/javatests/com/google/gerrit/httpd/AllowRenderInFrameFilterTest.java
new file mode 100644
index 0000000..2679829
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/AllowRenderInFrameFilterTest.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.AllowRenderInFrameFilter.X_FRAME_OPTIONS_HEADER_NAME;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import com.google.gerrit.httpd.AllowRenderInFrameFilter.XFrameOption;
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AllowRenderInFrameFilterTest {
+
+  private Config cfg = new Config();
+  @Mock ServletRequest request;
+  @Mock HttpServletResponse response;
+  @Mock FilterChain filterChain;
+
+  @Test
+  public void shouldDenyInFrameRenderingWhenCanRenderInFrameIsFalse()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", false);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "DENY");
+  }
+
+  @Test
+  public void shouldDenyInFrameRenderingWhenCanRenderInFrameIsFalseAndXFormOptionIsSAMEORIGIN()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", false);
+    cfg.setEnum("gerrit", null, "xframeOption", XFrameOption.SAMEORIGIN);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "DENY");
+  }
+
+  @Test
+  public void shouldDenyInFrameRenderingWhenCanRenderInFrameIsFalseAndXFormOptionIsALLOW()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", false);
+    cfg.setEnum("gerrit", null, "xframeOption", XFrameOption.ALLOW);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "DENY");
+  }
+
+  @Test
+  public void shouldRestrictAccessToSAMEORIGINWhenCanRenderInFrameIsTrue()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "SAMEORIGIN");
+  }
+
+  @Test
+  public void shouldSkipHeaderWhenCanRenderInFrameIsTrueAndXFormOptionIsALLOW()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+    cfg.setEnum("gerrit", null, "xframeOption", XFrameOption.ALLOW);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, never()).addHeader(eq(X_FRAME_OPTIONS_HEADER_NAME), anyString());
+  }
+
+  @Test
+  public void shouldRestrictAccessToSAMEORIGINWhenCanRenderInFrameIsTrueAndXFormOptionIsSAMEORIGIN()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+    cfg.setEnum("gerrit", null, "xframeOption", XFrameOption.SAMEORIGIN);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "SAMEORIGIN");
+  }
+
+  @Test
+  public void shouldIgnoreXFrameOriginCaseSensitivity() throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+    cfg.setString("gerrit", null, "xframeOption", "sameOrigin");
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "SAMEORIGIN");
+  }
+
+  @Test
+  public void shouldThrowExceptionWhenUnknownXFormOptionValue() {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+    cfg.setString("gerrit", null, "xframeOption", "unsupported value");
+
+    IllegalArgumentException e =
+        assertThrows(IllegalArgumentException.class, () -> new AllowRenderInFrameFilter(cfg));
+    assertThat(e).hasMessageThat().contains("gerrit.xframeOption=unsupported value");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
new file mode 100644
index 0000000..dbcc209
--- /dev/null
+++ b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
@@ -0,0 +1,214 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.submit.SubscriptionGraph.DefaultFactory;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+
+public class SubscriptionGraphTest {
+  private static final String TEST_PATH = "test/path";
+  private static final Project.NameKey SUPER_PROJECT = Project.nameKey("Superproject");
+  private static final Project.NameKey SUB_PROJECT = Project.nameKey("Subproject");
+  private static final BranchNameKey SUPER_BRANCH =
+      BranchNameKey.create(SUPER_PROJECT, "refs/heads/one");
+  private static final BranchNameKey SUB_BRANCH =
+      BranchNameKey.create(SUB_PROJECT, "refs/heads/one");
+  private InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+  private MergeOpRepoManager mergeOpRepoManager;
+
+  @Mock GitModules.Factory mockGitModulesFactory = mock(GitModules.Factory.class);
+  @Mock ProjectCache mockProjectCache = mock(ProjectCache.class);
+  @Mock ProjectState mockProjectState = mock(ProjectState.class);
+
+  @Before
+  public void setUp() throws Exception {
+    when(mockProjectCache.get(any())).thenReturn(Optional.of(mockProjectState));
+    mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
+
+    GitModules emptyMockGitModules = mock(GitModules.class);
+    when(emptyMockGitModules.subscribedTo(any())).thenReturn(ImmutableSet.of());
+    when(mockGitModulesFactory.create(any(), any())).thenReturn(emptyMockGitModules);
+
+    TestRepository<Repository> superProject = createRepo(SUPER_PROJECT);
+    TestRepository<Repository> submoduleProject = createRepo(SUB_PROJECT);
+
+    // Make sure that SUPER_BRANCH and SUB_BRANCH can be subscribed.
+    allowSubscription(SUPER_BRANCH);
+    allowSubscription(SUB_BRANCH);
+
+    setSubscription(SUB_BRANCH, ImmutableList.of(SUPER_BRANCH));
+    setSubscription(SUPER_BRANCH, ImmutableList.of());
+    createBranch(
+        superProject, SUPER_BRANCH, superProject.commit().message("Initial commit").create());
+    createBranch(
+        submoduleProject, SUB_BRANCH, submoduleProject.commit().message("Initial commit").create());
+  }
+
+  @Test
+  public void oneSuperprojectOneSubmodule() throws Exception {
+    SubscriptionGraph.Factory factory =
+        new DefaultFactory(mockGitModulesFactory, mockProjectCache, mergeOpRepoManager);
+    SubscriptionGraph subscriptionGraph = factory.compute(ImmutableSet.of(SUB_BRANCH));
+
+    assertThat(subscriptionGraph.getAffectedSuperProjects()).containsExactly(SUPER_PROJECT);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(SUPER_PROJECT))
+        .containsExactly(SUPER_BRANCH);
+    assertThat(subscriptionGraph.getSubscriptions(SUPER_BRANCH))
+        .containsExactly(new SubmoduleSubscription(SUPER_BRANCH, SUB_BRANCH, TEST_PATH));
+    assertThat(subscriptionGraph.hasSuperproject(SUB_BRANCH)).isTrue();
+    assertThat(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches())
+        .containsExactly(SUB_BRANCH, SUPER_BRANCH)
+        .inOrder();
+  }
+
+  @Test
+  public void circularSubscription() throws Exception {
+    SubscriptionGraph.Factory factory =
+        new DefaultFactory(mockGitModulesFactory, mockProjectCache, mergeOpRepoManager);
+    setSubscription(SUPER_BRANCH, ImmutableList.of(SUB_BRANCH));
+    SubmoduleConflictException e =
+        assertThrows(
+            SubmoduleConflictException.class, () -> factory.compute(ImmutableSet.of(SUB_BRANCH)));
+
+    String expectedErrorMessage =
+        "Subproject,refs/heads/one->Superproject,refs/heads/one->Subproject,refs/heads/one";
+    assertThat(e).hasMessageThat().contains(expectedErrorMessage);
+  }
+
+  @Test
+  public void multipleSuperprojectsToMultipleSubmodules() throws Exception {
+    // Create superprojects and subprojects.
+    Project.NameKey superProject1 = Project.nameKey("superproject1");
+    Project.NameKey superProject2 = Project.nameKey("superproject2");
+    Project.NameKey subProject1 = Project.nameKey("subproject1");
+    Project.NameKey subProject2 = Project.nameKey("subproject2");
+    TestRepository<Repository> superProjectRepo1 = createRepo(superProject1);
+    TestRepository<Repository> superProjectRepo2 = createRepo(superProject2);
+    TestRepository<Repository> submoduleRepo1 = createRepo(subProject1);
+    TestRepository<Repository> submoduleRepo2 = createRepo(subProject2);
+
+    // Initialize super branches.
+    BranchNameKey superBranch1 = BranchNameKey.create(superProject1, "refs/heads/one");
+    BranchNameKey superBranch2 = BranchNameKey.create(superProject2, "refs/heads/one");
+    createBranch(
+        superProjectRepo1,
+        superBranch1,
+        superProjectRepo1.commit().message("Initial commit").create());
+    createBranch(
+        superProjectRepo2,
+        superBranch2,
+        superProjectRepo2.commit().message("Initial commit").create());
+
+    // Initialize sub branches.
+    BranchNameKey submoduleBranch1 = BranchNameKey.create(subProject1, "refs/heads/one");
+    BranchNameKey submoduleBranch2 = BranchNameKey.create(subProject1, "refs/heads/two");
+    BranchNameKey submoduleBranch3 = BranchNameKey.create(subProject2, "refs/heads/one");
+    createBranch(
+        submoduleRepo1, submoduleBranch1, submoduleRepo1.commit().message("Commit1").create());
+    createBranch(
+        submoduleRepo1, submoduleBranch2, submoduleRepo1.commit().message("Commit2").create());
+    createBranch(
+        submoduleRepo2, submoduleBranch3, submoduleRepo2.commit().message("Commit1").create());
+
+    allowSubscription(submoduleBranch1);
+    allowSubscription(submoduleBranch2);
+    allowSubscription(submoduleBranch3);
+
+    // Initialize subscriptions.
+    setSubscription(submoduleBranch1, ImmutableList.of(superBranch1, superBranch2));
+    setSubscription(submoduleBranch2, ImmutableList.of(superBranch1));
+    setSubscription(submoduleBranch3, ImmutableList.of(superBranch1, superBranch2));
+
+    SubscriptionGraph.Factory factory =
+        new DefaultFactory(mockGitModulesFactory, mockProjectCache, mergeOpRepoManager);
+    SubscriptionGraph subscriptionGraph =
+        factory.compute(ImmutableSet.of(submoduleBranch1, submoduleBranch2));
+
+    assertThat(subscriptionGraph.getAffectedSuperProjects())
+        .containsExactly(superProject1, superProject2);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(superProject1))
+        .containsExactly(superBranch1);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(superProject2))
+        .containsExactly(superBranch2);
+
+    assertThat(subscriptionGraph.getSubscriptions(superBranch1))
+        .containsExactly(
+            new SubmoduleSubscription(superBranch1, submoduleBranch1, TEST_PATH),
+            new SubmoduleSubscription(superBranch1, submoduleBranch2, TEST_PATH));
+    assertThat(subscriptionGraph.getSubscriptions(superBranch2))
+        .containsExactly(new SubmoduleSubscription(superBranch2, submoduleBranch1, TEST_PATH));
+
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch1)).isTrue();
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch2)).isTrue();
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch3)).isFalse();
+
+    assertThat(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches())
+        .containsExactly(submoduleBranch2, submoduleBranch1, superBranch2, superBranch1)
+        .inOrder();
+  }
+
+  private TestRepository<Repository> createRepo(Project.NameKey project) throws Exception {
+    Repository repo = repoManager.createRepository(project);
+    return new TestRepository<>(repo);
+  }
+
+  private void createBranch(TestRepository<Repository> repo, BranchNameKey branch, RevCommit commit)
+      throws Exception {
+    repo.update(branch.branch(), commit);
+  }
+
+  private void allowSubscription(BranchNameKey branch) {
+    SubscribeSection s = new SubscribeSection(branch.project());
+    s.addMultiMatchRefSpec("refs/heads/*:refs/heads/*");
+    when(mockProjectState.getSubscribeSections(branch)).thenReturn(ImmutableSet.of(s));
+  }
+
+  private void setSubscription(
+      BranchNameKey submoduleBranch, List<BranchNameKey> superprojectBranches) {
+    List<SubmoduleSubscription> subscriptions =
+        superprojectBranches.stream()
+            .map(
+                (targetBranch) ->
+                    new SubmoduleSubscription(targetBranch, submoduleBranch, TEST_PATH))
+            .collect(Collectors.toList());
+    GitModules mockGitModules = mock(GitModules.class);
+    when(mockGitModules.subscribedTo(submoduleBranch)).thenReturn(subscriptions);
+    when(mockGitModulesFactory.create(submoduleBranch, mergeOpRepoManager))
+        .thenReturn(mockGitModules);
+  }
+}
diff --git a/modules/jgit b/modules/jgit
index 8a44216..8e79d5a 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 8a44216e8bdad1a13ce637b1395467600870849a
+Subproject commit 8e79d5a290843b929f073a536a0d678fc74382ca
diff --git a/plugins/replication b/plugins/replication
index 5838489..dc6762f 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 583848945b150503fb76fb780f53bc5c2b42546c
+Subproject commit dc6762f60e561868cabe4824c4c13291eeaa068d
diff --git a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
index b8d54a4..46dfaf2 100644
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
+++ b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import {descendedFromClass} from '../../utils/dom-util.js';
+
 export const DomUtilBehavior = {
   /**
    * Are any ancestors of the element (or the element itself) members of the
@@ -28,13 +30,9 @@
    * @return {boolean}
    */
   descendedFromClass(element, className, opt_stopElement) {
-    let isDescendant = element.classList.contains(className);
-    while (!isDescendant && element.parentElement &&
-        (!opt_stopElement || element.parentElement !== opt_stopElement)) {
-      isDescendant = element.classList.contains(className);
-      element = element.parentElement;
-    }
-    return isDescendant;
+    console.warn('DomUtilBehavior is deprecated.' +
+     'Use descendedFromClass from utils directly.');
+    return descendedFromClass(element, className, opt_stopElement);
   },
 };
 
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
index ca31ee8..49f27bc 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
@@ -507,7 +507,8 @@
 
     modifierPressed(e) {
       e = getKeyboardEvent(e);
-      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
+      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
+        !!this._inGoKeyMode();
     },
 
     isModifierPressed(e, modifier) {
@@ -580,11 +581,14 @@
         this._addOwnKeyBindings(key, shortcuts[key]);
       }
 
+      // each component that uses this behaviour must be aware if go key is
+      // pressed or not, since it needs to check it as a modifier
+      this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
+      this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
+
       // If any of the shortcuts utilized GO_KEY, then they are handled
       // directly by this behavior.
       if (this._shortcut_go_table.size > 0) {
-        this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
-        this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
         this._shortcut_go_table.forEach((handler, key) => {
           this.addOwnKeyBinding(key, '_handleGoAction');
         });
@@ -611,12 +615,15 @@
     },
 
     _handleGoKeyDown(e) {
-      if (this.modifierPressed(e)) { return; }
       this._shortcut_go_key_last_pressed = Date.now();
     },
 
     _handleGoKeyUp(e) {
-      this._shortcut_go_key_last_pressed = null;
+      // Set go_key_last_pressed to null `GO_KEY_TIMEOUT_MS` after keyup event
+      // so that users can trigger `g + i` by pressing g and i quickly.
+      setTimeout(() => {
+        this._shortcut_go_key_last_pressed = null;
+      }, GO_KEY_TIMEOUT_MS);
     },
 
     _inGoKeyMode() {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
deleted file mode 100644
index d161423..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-repo-command_html.js';
-
-/** @extends PolymerElement */
-class GrRepoCommand extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-repo-command'; }
-
-  static get properties() {
-    return {
-      title: String,
-      disabled: Boolean,
-      tooltip: String,
-    };
-  }
-
-  /**
-   * Fired when command button is tapped.
-   *
-   * @event command-tap
-   */
-
-  _onCommandTap() {
-    this.dispatchEvent(
-        new CustomEvent('command-tap', {bubbles: true, composed: true}));
-  }
-}
-
-customElements.define(GrRepoCommand.is, GrRepoCommand);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
deleted file mode 100644
index a73f071..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
+++ /dev/null
@@ -1,53 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-repo-command</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo-command></gr-repo-command>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-command.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-repo-command tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('dispatched command-tap on button tap', done => {
-    element.addEventListener('command-tap', () => {
-      done();
-    });
-    MockInteractions.tap(
-        dom(element.root).querySelector('gr-button'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
index 25f656d..4ab1b98 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
@@ -24,7 +24,6 @@
 import '../../shared/gr-overlay/gr-overlay.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-create-change-dialog/gr-create-change-dialog.js';
-import '../gr-repo-command/gr-repo-command.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
index 5ff7dde..4e826aa 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
@@ -45,13 +45,6 @@
       white-space: nowrap;
       width: 100%;
     }
-    .content a {
-      display: block;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-      width: 100%;
-    }
     .comments,
     .reviewers {
       white-space: nowrap;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index bd78ae9..baf9920 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -168,7 +168,6 @@
   /** @override */
   ready() {
     super.ready();
-    this._ensureAttribute('tabindex', 0);
     this.$.restAPI.getConfig().then(config => {
       this._config = config;
     });
@@ -296,6 +295,11 @@
     return idx == selectedIndex;
   }
 
+  _computeTabIndex(sectionIndex, index, selectedIndex) {
+    return this._computeItemSelected(sectionIndex, index, selectedIndex)
+      ? 0 : undefined;
+  }
+
   _computeItemNeedsReview(account, change, showReviewedState) {
     return showReviewedState && !change.reviewed &&
         !change.work_in_progress &&
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
index f7c50e6..2e50cd8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
@@ -138,7 +138,7 @@
             visible-change-table-columns="[[visibleChangeTableColumns]]"
             show-number="[[showNumber]]"
             show-star="[[showStar]]"
-            tabindex="0"
+            tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex)]]"
             label-names="[[labelNames]]"
           ></gr-change-list-item>
         </template>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 9d3b455..b098a93 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -151,7 +151,7 @@
 
       _currentParents: {
         type: Array,
-        computed: '_computeParents(revision)',
+        computed: '_computeParents(change, revision)',
       },
 
       /** @type {?} */
@@ -493,9 +493,11 @@
     return null;
   }
 
-  _computeParents(revision) {
+  _computeParents(change, revision) {
     if (!revision || !revision.commit) {
-      return undefined;
+      if (!change || !change.current_revision) { return []; }
+      revision = change.revisions[change.current_revision];
+      if (!revision || !revision.commit) { return []; }
     }
     return revision.commit.parents;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 81b9bde..8e780d7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -426,20 +426,38 @@
   });
 
   test('_computeParents', () => {
-    const revision = {commit: {parents: [{commit: '123', subject: 'abc'}]}};
-    assert.isUndefined(element._computeParents({}));
-    assert.equal(element._computeParents(revision), revision.commit.parents);
+    const parents = [{commit: '123', subject: 'abc'}];
+    const revision = {commit: {parents}};
+    assert.deepEqual(element._computeParents({}, {}), []);
+    assert.equal(element._computeParents(null, revision), parents);
+    const change = current_revision => {
+      return {current_revision, revisions: {456: revision}};
+    };
+    assert.deepEqual(element._computeParents(change(null), null), []);
+    const change_bad_revision = change('789');
+    assert.deepEqual(element._computeParents(change_bad_revision, {}), []);
+    const change_no_commit = {current_revision: '456', revisions: {456: {}}};
+    assert.deepEqual(element._computeParents(change_no_commit, null), []);
+    const change_good = change('456');
+    assert.equal(element._computeParents(change_good, null), parents);
   });
 
   test('_currentParents', () => {
-    element.revision = {
-      commit: {parents: [{commit: '123', subject: 'abc'}]},
+    const revision = parent => {
+      return {commit: {parents: [{commit: parent, subject: 'abc'}]}};
     };
-    assert.equal(element._currentParents[0].commit, '123');
-    element.revision = {
-      commit: {parents: [{commit: '12345', subject: 'abc'}]},
+    element.change = {
+      current_revision: '456',
+      revisions: {456: revision('111')},
     };
-    assert.equal(element._currentParents[0].commit, '12345');
+    element.revision = revision('222');
+    assert.equal(element._currentParents[0].commit, '222');
+    element.revision = revision('333');
+    assert.equal(element._currentParents[0].commit, '333');
+    element.revision = null;
+    assert.equal(element._currentParents[0].commit, '111');
+    element.change = {current_revision: null};
+    assert.deepEqual(element._currentParents, []);
   });
 
   test('_computeParentsLabel', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
index 78627a7..d2d6037 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
@@ -104,7 +104,7 @@
   }
 
   _computeRequirementIcon(requirementStatus) {
-    return requirementStatus ? 'gr-icons:check' : 'gr-icons:hourglass';
+    return requirementStatus ? 'gr-icons:check' : 'gr-icons:schedule';
   }
 
   _computeLabels(labelsRecord) {
@@ -132,7 +132,7 @@
   _computeLabelIcon(labelInfo) {
     if (labelInfo.approved) { return 'gr-icons:check'; }
     if (labelInfo.rejected) { return 'gr-icons:close'; }
-    return 'gr-icons:hourglass';
+    return 'gr-icons:schedule';
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
index e100f91..276e821 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
@@ -51,13 +51,13 @@
 
     assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
     assert.equal(element._computeRequirementIcon(false),
-        'gr-icons:hourglass');
+        'gr-icons:schedule');
   });
 
   test('label computed fields', () => {
     assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
     assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
-    assert.equal(element._computeLabelIcon({}), 'gr-icons:hourglass');
+    assert.equal(element._computeLabelIcon({}), 'gr-icons:schedule');
 
     assert.equal(element._computeLabelClass({approved: []}), 'approved');
     assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
@@ -90,7 +90,7 @@
     assert.equal(element._requiredLabels.length, 1);
 
     assert.equal(element._optionalLabels[0].label, 'opt_test');
-    assert.equal(element._optionalLabels[0].icon, 'gr-icons:hourglass');
+    assert.equal(element._optionalLabels[0].icon, 'gr-icons:schedule');
     assert.equal(element._optionalLabels[0].style, '');
     assert.ok(element._optionalLabels[0].labelInfo);
   });
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index dc207dc..799adde 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -57,7 +57,7 @@
 import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 import {GrEditConstants} from '../../edit/gr-edit-constants.js';
 import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {util} from '../../../scripts/util.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
@@ -2003,7 +2003,7 @@
   _computeShowRelatedToggle() {
     // Make sure the max height has been applied, since there is now content
     // to populate.
-    if (!util.getComputedStyleValue('--relation-chain-max-height', this)) {
+    if (!getComputedStyleValue('--relation-chain-max-height', this)) {
       this._updateRelatedChangeMaxHeight();
     }
     // Prevents showMore from showing when click on related change, since the
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
index 1362fe3..faa81b6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
@@ -24,7 +24,7 @@
 import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 import {GrEditConstants} from '../../edit/gr-edit-constants.js';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {util} from '../../../scripts/util.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
@@ -320,7 +320,7 @@
   });
 
   const getCustomCssValue =
-      cssParam => util.getComputedStyleValue(cssParam, element);
+      cssParam => getComputedStyleValue(cssParam, element);
 
   test('_handleMessageAnchorTap', () => {
     element._changeNum = '1';
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 894f0cc..fbae39f 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -1279,6 +1279,14 @@
   _renderInOrder(files, diffElements, initialCount) {
     let iter = 0;
 
+    for (const file of files) {
+      const path = file.path;
+      const diffElem = this._findDiffByPath(path, diffElements);
+      if (diffElem) {
+        diffElem.prefetchDiff();
+      }
+    }
+
     return (new Promise(resolve => {
       this.dispatchEvent(new CustomEvent('reload-drafts', {
         detail: {resolve},
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
index df7cb00..a2714d9 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
@@ -329,6 +329,12 @@
           as="headerEndpoint"
         >
           <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
+            <gr-endpoint-param
+              name="change"
+              value="[[change]]"
+            ></gr-endpoint-param>
+            <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+            </gr-endpoint-param>
           </gr-endpoint-decorator>
         </template>
       </template>
@@ -381,6 +387,8 @@
               as="contentEndpoint"
             >
               <gr-endpoint-decorator name="[[contentEndpoint]]" role="gridcell">
+                <gr-endpoint-param name="change" value="[[change]]">
+                </gr-endpoint-param>
                 <gr-endpoint-param name="changeNum" value="[[changeNum]]">
                 </gr-endpoint-param>
                 <gr-endpoint-param name="patchRange" value="[[patchRange]]">
@@ -439,7 +447,7 @@
             <div class="comments desktop">
               <span class="drafts"
                 ><!-- This comments ensure that span is empty when the function
-                returns empty string.                
+                returns empty string.
               -->[[_computeDraftsString(changeComments, patchRange,
                 file.__path)]]<!-- This comments ensure that span is empty when
                 the function returns empty string.
@@ -458,7 +466,7 @@
               Without this span, screen readers don't navigate correctly inside
               table, because empty div doesn't rendered. For example, VoiceOver
               jumps back to the whole table.
-              We can use &nbsp instead, but it sounds worse.                            
+              We can use &nbsp instead, but it sounds worse.
               -->
                 No comments
               </span>
@@ -553,6 +561,8 @@
             >
               <div class$="[[_computeClass('', file.__path)]]" role="gridcell">
                 <gr-endpoint-decorator name="[[contentEndpoint]]">
+                  <gr-endpoint-param name="change" value="[[change]]">
+                  </gr-endpoint-param>
                   <gr-endpoint-param name="changeNum" value="[[changeNum]]">
                   </gr-endpoint-param>
                   <gr-endpoint-param name="patchRange" value="[[patchRange]]">
@@ -575,7 +585,7 @@
             >
             <!-- Do not use input type="checkbox" with hidden input and
                   visible label here. Screen readers don't read/interract
-                  correctly with such input. 
+                  correctly with such input.
               -->
             <span
               class="reviewedSwitch"
@@ -611,7 +621,7 @@
           <div class="show-hide" role="gridcell">
             <!-- Do not use input type="checkbox" with hidden input and
                 visible label here. Screen readers don't read/interract
-                correctly with such input. 
+                correctly with such input.
             -->
             <span
               class="show-hide"
@@ -683,6 +693,12 @@
         as="summaryEndpoint"
       >
         <gr-endpoint-decorator name="[[summaryEndpoint]]">
+          <gr-endpoint-param
+            name="change"
+            value="[[change]]"
+          ></gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+          </gr-endpoint-param>
         </gr-endpoint-decorator>
       </template>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index f93cfe1..ad0d1cf 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -95,6 +95,7 @@
       });
       stub('gr-diff-host', {
         reload() { return Promise.resolve(); },
+        prefetchDiff() {},
       });
 
       // Element must be wrapped in an element with direct access to the
@@ -1106,6 +1107,7 @@
         reload() {
           done();
         },
+        prefetchDiff() {},
         cancel() {},
         getCursorStops() { return []; },
         addEventListener(eventName, callback) {
@@ -1163,6 +1165,7 @@
       const diffs = [{
         path: 'p0',
         style: {},
+        prefetchDiff() {},
         reload() {
           assert.equal(callCount++, 2);
           return Promise.resolve();
@@ -1170,6 +1173,7 @@
       }, {
         path: 'p1',
         style: {},
+        prefetchDiff() {},
         reload() {
           assert.equal(callCount++, 1);
           return Promise.resolve();
@@ -1177,6 +1181,7 @@
       }, {
         path: 'p2',
         style: {},
+        prefetchDiff() {},
         reload() {
           assert.equal(callCount++, 0);
           return Promise.resolve();
@@ -1199,6 +1204,7 @@
       const diffs = [{
         path: 'p0',
         style: {},
+        prefetchDiff() {},
         reload() {
           assert.equal(reviewStub.callCount, 2);
           assert.equal(callCount++, 2);
@@ -1207,6 +1213,7 @@
       }, {
         path: 'p1',
         style: {},
+        prefetchDiff() {},
         reload() {
           assert.equal(reviewStub.callCount, 1);
           assert.equal(callCount++, 1);
@@ -1215,6 +1222,7 @@
       }, {
         path: 'p2',
         style: {},
+        prefetchDiff() {},
         reload() {
           assert.equal(reviewStub.callCount, 0);
           assert.equal(callCount++, 0);
@@ -1237,6 +1245,7 @@
       const diffs = [{
         path: 'p',
         style: {},
+        prefetchDiff() {},
         reload() { return Promise.resolve(); },
       }];
 
@@ -1566,6 +1575,7 @@
       });
       stub('gr-diff-host', {
         reload() { return Promise.resolve(); },
+        prefetchDiff() {},
       });
 
       // Element must be wrapped in an element with direct access to the
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index 903552a..23a4c55 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -90,7 +90,9 @@
       return undefined;
     }
 
-    const links = [{name: 'Settings', url: '/settings/'}];
+    const links = [];
+    links.push({name: 'Settings', url: '/settings/'});
+    links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
     if (switchAccountUrl) {
       const replacements = {path};
       const url = this._interpolateUrl(switchAccountUrl, replacements);
@@ -107,6 +109,11 @@
     ];
   }
 
+  _handleShortcutsTap(e) {
+    this.dispatchEvent(new CustomEvent('show-keyboard-shortcuts',
+        {bubbles: true, composed: true}));
+  }
+
   _handleLocationChange() {
     this._path =
         window.location.pathname +
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
index b47894e..5db7923 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
@@ -37,6 +37,7 @@
     link=""
     items="[[links]]"
     top-content="[[topContent]]"
+    on-tap-item-shortcuts="_handleShortcutsTap"
     horizontal-align="right"
   >
     <span hidden$="[[_hasAvatars]]" hidden="">[[_accountName(account)]]</span>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index 6c8ed68..a366d09 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -84,12 +84,12 @@
     assert.isUndefined(element._getLinks(null));
 
     // No switch account link.
-    assert.equal(element._getLinks(null, '').length, 2);
+    assert.equal(element._getLinks(null, '').length, 3);
 
     // Unparameterized switch account link.
     let links = element._getLinks('/switch-account', '');
-    assert.equal(links.length, 3);
-    assert.deepEqual(links[1], {
+    assert.equal(links.length, 4);
+    assert.deepEqual(links[2], {
       name: 'Switch account',
       url: '/switch-account',
       external: true,
@@ -97,8 +97,8 @@
 
     // Parameterized switch account link.
     links = element._getLinks('/switch-account${path}', '/c/123');
-    assert.equal(links.length, 3);
-    assert.deepEqual(links[1], {
+    assert.equal(links.length, 4);
+    assert.deepEqual(links[2], {
       name: 'Switch account',
       url: '/switch-account/c/123',
       external: true,
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 9f6d050..6cd2491 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -28,7 +28,6 @@
 import {htmlTemplate} from './gr-error-manager_html.js';
 import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {authService} from '../../shared/gr-rest-api-interface/gr-auth.js';
-import {gerritEventEmitter} from '../../shared/gr-event-emitter/gr-event-emitter.js';
 import {appContext} from '../../../services/app-context.js';
 
 const HIDE_ALERT_TIMEOUT_MS = 5000;
@@ -93,6 +92,7 @@
     this._authErrorHandlerDeregistrationHook;
 
     this.reporting = appContext.reportingService;
+    this.eventEmitter = appContext.eventEmitter;
   }
 
   /** @override */
@@ -106,7 +106,7 @@
     this.listen(document, 'show-auth-required', '_handleAuthRequired');
 
     this._authErrorHandlerDeregistrationHook =
-      gerritEventEmitter.on('auth-error',
+      this.eventEmitter.on('auth-error',
           event => {
             this._handleAuthError(event.message, event.action);
           });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index 655a4eb..1cabd82 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -315,15 +315,20 @@
   }
 
   if (line.type === GrDiffLine.Type.BOTH || line.type === type) {
-    const button = this._createElement('button');
-    button.tabIndex = -1;
-    td.appendChild(button);
-
     // Both td and button need a number of classes/attributes for various
     // selectors to work.
     this._decorateLineEl(td, number, side);
     td.classList.add('lineNum');
+
+    if (this._prefs.show_file_comment_button === false && number === 'FILE') {
+      return td;
+    }
+
+    const button = this._createElement('button');
+    td.appendChild(button);
+    button.tabIndex = -1;
     this._decorateLineEl(button, number, side);
+
     button.classList.add('lineNumButton');
 
     button.textContent = number === 'FILE' ? 'File' : number;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index 229ae43..665e4a6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -119,6 +119,30 @@
     return null;
   }
 
+  _toggleRangeElHighlight(threadEl, highlightRange = false) {
+    // We don't want to re-create the line just for highlighting the range which
+    // is creating annoying bugs: @see Issue 12934
+    // As gr-ranged-comment-layer now does not notify the layer re-render and
+    // lack of access to the thread or the lineEl from the ranged-comment-layer,
+    // need to update range class for styles here.
+    const currentLine = threadEl.assignedSlot.parentElement.previousSibling;
+    if (currentLine && currentLine.querySelector) {
+      if (highlightRange) {
+        const rangeNode = currentLine.querySelector('.range');
+        if (rangeNode) {
+          rangeNode.classList.add('rangeHighlight');
+          rangeNode.classList.remove('range');
+        }
+      } else {
+        const rangeNode = currentLine.querySelector('.rangeHighlight');
+        if (rangeNode) {
+          rangeNode.classList.remove('rangeHighlight');
+          rangeNode.classList.add('range');
+        }
+      }
+    }
+  }
+
   _handleCommentThreadMouseenter(e) {
     const threadEl = this._getThreadEl(e);
     const index = this._indexForThreadEl(threadEl);
@@ -126,6 +150,8 @@
     if (index !== undefined) {
       this.set(['commentRanges', index, 'hovering'], true);
     }
+
+    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
   }
 
   _handleCommentThreadMouseleave(e) {
@@ -135,6 +161,8 @@
     if (index !== undefined) {
       this.set(['commentRanges', index, 'hovering'], false);
     }
+
+    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
   }
 
   _indexForThreadEl(threadEl) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index ef11247..cb299de 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -218,6 +218,11 @@
         notify: true,
       },
 
+      _fetchDiffPromise: {
+        type: Object,
+        value: null,
+      },
+
       /** @type {?Object} */
       _blame: {
         type: Object,
@@ -535,8 +540,21 @@
         !this.noAutoRender;
   }
 
+  // TODO(milutin): Use rest-api with fetchCacheURL instead of this.
+  prefetchDiff() {
+    if (!!this.changeNum && !!this.patchRange && !!this.path
+        && this._fetchDiffPromise === null) {
+      this._fetchDiffPromise = this._getDiff();
+    }
+  }
+
   /** @return {!Promise<!Object>} */
   _getDiff() {
+    if (this._fetchDiffPromise !== null) {
+      const fetchDiffPromise = this._fetchDiffPromise;
+      this._fetchDiffPromise = null;
+      return fetchDiffPromise;
+    }
     // Wrap the diff request in a new promise so that the error handler
     // rejects the promise, allowing the error to be handled in the .catch.
     return new Promise((resolve, reject) => {
@@ -793,6 +811,7 @@
     }
     threadEl.changeNum = this.changeNum;
     threadEl.patchNum = thread.patchNum;
+    threadEl.showPatchset = false;
     threadEl.lineNum = thread.lineNum;
     const rootIdChangedListener = changeEvent => {
       thread.rootId = changeEvent.detail.value;
@@ -906,6 +925,7 @@
       return;
     }
 
+    this._fetchDiffPromise = null;
     if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
         !noRenderOnPrefsChange) {
       this.reload();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
index b8f26b2..342c7ec 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
@@ -445,6 +445,19 @@
       });
     });
 
+    test('prefetch getDiff', done => {
+      const diffRestApiStub = sandbox.stub(element.$.restAPI, 'getDiff')
+          .returns(Promise.resolve({content: []}));
+      element.changeNum = 123;
+      element.patchRange = {basePatchNum: 1, patchNum: 2};
+      element.path = 'file.txt';
+      element.prefetchDiff();
+      element._getDiff().then(() =>{
+        assert.isTrue(diffRestApiStub.calledOnce);
+        done();
+      });
+    });
+
     test('_getDiff handles null diff responses', done => {
       stub('gr-rest-api-interface', {
         getDiff() { return Promise.resolve(null); },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index 608e898..cfbe481 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -24,7 +24,7 @@
 import {htmlTemplate} from './gr-diff-selection_html.js';
 import {DomUtilBehavior} from '../../../behaviors/dom-util-behavior/dom-util-behavior.js';
 import {GrRangeNormalizer} from '../gr-diff-highlight/gr-range-normalizer.js';
-import {util} from '../../../scripts/util.js';
+import {querySelectorAll} from '../../../utils/dom-util.js';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -203,7 +203,7 @@
   }
 
   _getSelection() {
-    const diffHosts = util.querySelectorAll(document.body, 'gr-diff');
+    const diffHosts = querySelectorAll(document.body, 'gr-diff');
     if (!diffHosts.length) return window.getSelection();
 
     const curDiffHost = diffHosts.find(diffHost => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 0510d3e..0942646 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -433,7 +433,8 @@
     }
 
     return Array.from(
-        dom(this.root).querySelectorAll(':not(.contextControl) > .diff-row'));
+        dom(this.root).querySelectorAll(':not(.contextControl) > .diff-row'))
+        .filter(tr => tr.querySelector('button'));
   }
 
   /** @return {boolean} */
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
index d497b9a..a10db97 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
@@ -21,7 +21,7 @@
 import './gr-diff.js';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
-import {util} from '../../../scripts/util.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import '@polymer/paper-button/paper-button.js';
@@ -82,14 +82,14 @@
     element = basicFixture.instantiate();
     element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: true});
     flushAsynchronousOperations();
-    assert.equal(util.getComputedStyleValue('--line-limit', element), '80ch');
+    assert.equal(getComputedStyleValue('--line-limit', element), '80ch');
   });
 
   test('line limit without line_wrapping', () => {
     element = basicFixture.instantiate();
     element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: false});
     flushAsynchronousOperations();
-    assert.isNotOk(util.getComputedStyleValue('--line-limit', element));
+    assert.isNotOk(getComputedStyleValue('--line-limit', element));
   });
 
   suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index 3bf3697..c00c0fb 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -25,6 +25,7 @@
 import {htmlTemplate} from './gr-patch-range-select_html.js';
 import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
+import {appContext} from '../../../services/app-context.js';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -78,6 +79,11 @@
     ];
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   _getShaForPatch(patch) {
     return patch.sha.substring(0, 10);
   }
@@ -285,8 +291,14 @@
     const target = dom(e).localTarget;
 
     if (target === this.$.patchNumDropdown) {
+      if (detail.patchNum === e.detail.value) return;
+      this.reporting.reportInteraction('right-patchset-changed',
+          {previous: detail.patchNum, current: e.detail.value});
       detail.patchNum = e.detail.value;
     } else {
+      if (detail.basePatchNum === e.detail.value) return;
+      this.reporting.reportInteraction('left-patchset-changed',
+          {previous: detail.basePatchNum, current: e.detail.value});
       detail.basePatchNum = e.detail.value;
     }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index 70aa1e7..9c9997c 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -133,10 +133,11 @@
     if (record.path === 'commentRanges') {
       this._rangesMap = {left: {}, right: {}};
       for (const {side, range, hovering} of record.value) {
-        this._updateRangesMap(
-            side, range, hovering, (forLine, start, end, hovering) => {
-              forLine.push({start, end, hovering});
-            });
+        this._updateRangesMap({
+          side, range, hovering,
+          operation: (forLine, start, end, hovering) => {
+            forLine.push({start, end, hovering});
+          }});
       }
     }
 
@@ -147,12 +148,13 @@
       // not the index, especially in polymer 1.
       const {side, range, hovering} = this.get(match[1]);
 
-      this._updateRangesMap(
-          side, range, hovering, (forLine, start, end, hovering) => {
-            const index = forLine.findIndex(lineRange =>
-              lineRange.start === start && lineRange.end === end);
-            forLine[index].hovering = hovering;
-          });
+      this._updateRangesMap({
+        side, range, hovering, skipLayerUpdate: true,
+        operation: (forLine, start, end, hovering) => {
+          const index = forLine.findIndex(lineRange =>
+            lineRange.start === start && lineRange.end === end);
+          forLine[index].hovering = hovering;
+        }});
     }
 
     // If comments were spliced in or out.
@@ -160,26 +162,40 @@
       for (const indexSplice of record.value.indexSplices) {
         const removed = indexSplice.removed;
         for (const {side, range, hovering} of removed) {
-          this._updateRangesMap(
-              side, range, hovering, (forLine, start, end) => {
-                const index = forLine.findIndex(lineRange =>
-                  lineRange.start === start && lineRange.end === end);
-                forLine.splice(index, 1);
-              });
+          this._updateRangesMap({
+            side, range, hovering, operation: (forLine, start, end) => {
+              const index = forLine.findIndex(lineRange =>
+                lineRange.start === start && lineRange.end === end);
+              forLine.splice(index, 1);
+            }});
         }
         const added = indexSplice.object.slice(
             indexSplice.index, indexSplice.index + indexSplice.addedCount);
         for (const {side, range, hovering} of added) {
-          this._updateRangesMap(
-              side, range, hovering, (forLine, start, end, hovering) => {
-                forLine.push({start, end, hovering});
-              });
+          this._updateRangesMap({
+            side, range, hovering,
+            operation: (forLine, start, end, hovering) => {
+              forLine.push({start, end, hovering});
+            }});
         }
       }
     }
   }
 
-  _updateRangesMap(side, range, hovering, operation) {
+  /**
+   * @param {!Object} options
+   * @property {!string} options.side
+   * @property {boolean} options.hovering
+   * @property {boolean} options.skipLayerUpdate
+   * @property {!Function} options.operation
+   * @property {!{
+   *  start_character: number,
+   *  start_line: number,
+   *  end_line: number,
+   *  end_character: number}} options.range
+   */
+  _updateRangesMap(options) {
+    const {side, range, hovering, operation, skipLayerUpdate} = options;
     const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
     for (let line = range.start_line; line <= range.end_line; line++) {
       const forLine = forSide[line] || (forSide[line] = []);
@@ -187,7 +203,9 @@
       const end = line === range.end_line ? range.end_character : -1;
       operation(forLine, start, end, hovering);
     }
-    this._notifyUpdateRange(range.start_line, range.end_line, side);
+    if (!skipLayerUpdate) {
+      this._notifyUpdateRange(range.start_line, range.end_line, side);
+    }
   }
 
   _getRangesForLine(line, side) {
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
index ff1f4a7..37d1707 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -214,11 +214,8 @@
 
     element.set(['commentRanges', 1, 'hovering'], true);
 
-    assert.isTrue(notifyStub.called);
-    const lastCall = notifyStub.lastCall;
-    assert.equal(lastCall.args[0], 10);
-    assert.equal(lastCall.args[1], 12);
-    assert.equal(lastCall.args[2], 'right');
+    // notify will be skipped for hovering
+    assert.isFalse(notifyStub.called);
 
     assert.isTrue(updateRangesMapSpy.called);
   });
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index ecb40a5..4915eda 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -499,6 +499,10 @@
     }
   }
 
+  handleShowKeyboardShortcuts() {
+    this.$.keyboardShortcuts.open();
+  }
+
   _showKeyboardShortcuts(e) {
     // same shortcut should close the dialog if pressed again
     // when dialog is open
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.js b/polygerrit-ui/app/elements/gr-app-element_html.js
index 47139ce..75295bc 100644
--- a/polygerrit-ui/app/elements/gr-app-element_html.js
+++ b/polygerrit-ui/app/elements/gr-app-element_html.js
@@ -104,6 +104,7 @@
       id="mainHeader"
       search-query="{{params.query}}"
       on-mobile-search="_mobileSearchToggle"
+      on-show-keyboard-shortcuts="handleShowKeyboardShortcuts"
       login-url="[[_loginUrl]]"
     >
     </gr-main-header>
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.js b/polygerrit-ui/app/elements/gr-app-global-var-init.js
index 2c166f5..8c1161c 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.js
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.js
@@ -50,7 +50,7 @@
 import {util} from '../scripts/util.js';
 import page from 'page/page.mjs';
 import {Auth} from './shared/gr-rest-api-interface/gr-auth.js';
-import {EventEmitter} from './shared/gr-event-interface/gr-event-interface.js';
+import {appContext} from '../services/app-context.js';
 import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api.js';
 import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context.js';
 import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api.js';
@@ -104,7 +104,7 @@
   window.util = util;
   window.page = page;
   window.Auth = Auth;
-  window.EventEmitter = EventEmitter;
+  window.EventEmitter = appContext.eventEmitter;
   window.GrAdminApi = GrAdminApi;
   window.GrAnnotationActionsContext = GrAnnotationActionsContext;
   window.GrAnnotationActionsInterface = GrAnnotationActionsInterface;
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 410539c..1300955 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -38,6 +38,7 @@
 import {htmlTemplate} from './gr-app_html.js';
 import {SafeTypes} from '../behaviors/safe-types-behavior/safe-types-behavior.js';
 import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit.js';
+import {appContext} from '../services/app-context.js';
 
 security.polymer_resin.install({
   allowedIdentifierPrefixes: [''],
@@ -57,4 +58,4 @@
 customElements.define(GrApp.is, GrApp);
 
 initGlobalVariables();
-initGerritPluginApi();
+initGerritPluginApi(appContext);
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
index 752570f..1a2cd28 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
@@ -14,20 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../admin/gr-repo-command/gr-repo-command.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-Polymer({
-  _template: html`
-    <gr-repo-command title="[[title]]">
-    </gr-repo-command>
-`,
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-plugin-repo-command_html.js';
 
-  is: 'gr-plugin-repo-command',
+class GrPluginRepoCommand extends PolymerElement {
+  static get is() {
+    return 'gr-plugin-repo-command';
+  }
 
-  properties: {
-    title: String,
-    repoName: String,
-    config: Object,
-  },
-});
+  static get properties() {
+    return {
+      title: String,
+      repoName: String,
+      config: Object,
+    };
+  }
+
+  static get template() {
+    return htmlTemplate;
+  }
+
+  _handleClick() {
+    this.dispatchEvent(
+        new CustomEvent('command-tap', {composed: true, bubbles: true})
+    );
+  }
+}
+
+customElements.define(GrPluginRepoCommand.is, GrPluginRepoCommand);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.js
similarity index 83%
rename from polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
rename to polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.js
index 50aca6d..1e5088b3 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.js
@@ -23,12 +23,6 @@
       margin-bottom: var(--spacing-xxl);
     }
   </style>
-  <h3 class="heading-3">[[title]]</h3>
-  <gr-button
-    title$="[[tooltip]]"
-    disabled$="[[disabled]]"
-    on-click="_onCommandTap"
-  >
-    [[title]]
-  </gr-button>
+  <h3>[[title]]</h3>
+  <gr-button on-click="_handleClick">[[title]]</gr-button>
 `;
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
index 32ae959..1af91fd 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
@@ -73,13 +73,12 @@
       const pluginCommand = element.shadowRoot
           .querySelector('gr-plugin-repo-command');
       assert.isOk(pluginCommand);
-      const command = pluginCommand.shadowRoot
-          .querySelector('gr-repo-command');
-      assert.isOk(command);
-      assert.equal(command.title, 'foo');
+      const btn = pluginCommand.shadowRoot
+          .querySelector('gr-button');
+      assert.isOk(btn);
+      assert.equal(btn.textContent, 'foo');
       assert.isFalse(tapStub.called);
-      MockInteractions.tap(command.shadowRoot
-          .querySelector('gr-button'));
+      MockInteractions.tap(btn);
       assert.isTrue(tapStub.called);
       done();
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index bdac50f..346d84e 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -23,7 +23,7 @@
 import {htmlTemplate} from './gr-button_html.js';
 import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
 import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {util} from '../../../scripts/util.js';
+import {getEventPath} from '../../../utils/dom-util.js';
 import {appContext} from '../../../services/app-context.js';
 
 /**
@@ -113,7 +113,7 @@
     }
 
     this.reporting.reportInteraction('button-click',
-        {path: util.getEventPath(e)});
+        {path: getEventPath(e)});
   }
 
   _disabledChanged(disabled) {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
index 7d5d170..fa686da 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
@@ -159,6 +159,10 @@
         type: Boolean,
         value: true,
       },
+      showPatchset: {
+        type: Boolean,
+        value: true,
+      },
     };
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
index 2bb5b66ea..837a58f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
@@ -116,6 +116,7 @@
         patch-num="[[patchNum]]"
         draft="[[_isDraft(comment)]]"
         show-actions="[[_showActions]]"
+        show-patchset="[[showPatchset]]"
         comment-side="[[comment.__commentSide]]"
         side="[[comment.side]]"
         project-config="[[_projectConfig]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
index 6697880..02fec5a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -213,6 +213,10 @@
         type: Boolean,
         value: false,
       },
+      showPatchset: {
+        type: Boolean,
+        value: true,
+      },
       _respectfulReviewTip: String,
       _respectfulTipDismissed: {
         type: Boolean,
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
index aece8ad..1d04969 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
@@ -231,6 +231,10 @@
     .pointer {
       cursor: pointer;
     }
+    .patchset-text {
+      color: var(--deemphasized-text-color);
+      margin-left: var(--spacing-s);
+    }
   </style>
   <div id="container" class="container">
     <div class="header" id="header" on-click="_handleToggleCollapsed">
@@ -269,7 +273,9 @@
       >
         <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
       </gr-button>
-      <span> Patchset [[patchNum]]</span>
+      <template is="dom-if" if="[[showPatchset]]">
+        <span class="patchset-text"> Patchset [[patchNum]]</span>
+      </template>
       <span class="separator"></span>
       <span class="date" tabindex="0" on-click="_handleAnchorClick">
         <gr-date-formatter
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index 8e7ea87..717275d 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -292,7 +292,8 @@
     const item = this.items.find(item => item.id === id);
     if (id && !this.disabledIds.includes(id)) {
       if (item) {
-        this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
+        this.dispatchEvent(new CustomEvent('tap-item',
+            {detail: item, bubbles: true, composed: true}));
       }
       this.dispatchEvent(new CustomEvent('tap-item-' + id));
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js b/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js
deleted file mode 100644
index cfe4c4f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {EventEmitter} from '../gr-event-interface/gr-event-interface.js';
-
-// TODO(dmfilippov): move to appContext
-export const gerritEventEmitter = new EventEmitter();
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
index 85caa61..5fd7547 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
@@ -55,6 +55,8 @@
       <g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="info-outline"><path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
       <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#mode_comment-->
@@ -102,6 +104,8 @@
       <g id="pets"><circle cx="4.5" cy="9.5" r="2.5"/><circle cx="9" cy="5.5" r="2.5"/><circle cx="15" cy="5.5" r="2.5"/><circle cx="19.5" cy="9.5" r="2.5"/><path d="M17.34 14.86c-.87-1.02-1.6-1.89-2.48-2.91-.46-.54-1.05-1.08-1.75-1.32-.11-.04-.22-.07-.33-.09-.25-.04-.52-.04-.78-.04s-.53 0-.79.05c-.11.02-.22.05-.33.09-.7.24-1.28.78-1.75 1.32-.87 1.02-1.6 1.89-2.48 2.91-1.31 1.31-2.92 2.76-2.62 4.79.29 1.02 1.02 2.03 2.33 2.32.73.15 3.06-.44 5.54-.44h.18c2.48 0 4.81.58 5.54.44 1.31-.29 2.04-1.31 2.33-2.32.31-2.04-1.3-3.49-2.61-4.8z"/><path d="M0 0h24v24H0z" fill="none"/></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#visibility-->
       <g id="ready"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons -->
+      <g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
index ef57ae9..ce65755 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
@@ -21,8 +21,8 @@
  */
 
 import {pluginLoader} from './gr-plugin-loader.js';
-import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
 import {getRestAPI, send} from './gr-api-utils.js';
+import {appContext} from '../../../services/app-context.js';
 
 /**
  * Trigger the preinstalls for bundled plugins.
@@ -146,9 +146,11 @@
     return pluginLoader.isPluginLoaded(pathOrUrl);
   };
 
+  const eventEmitter = appContext.eventEmitter;
+
   // TODO(taoalpha): List all internal supported event names.
   // Also convert this to inherited class once we move Gerrit to class.
-  globalGerritObj._eventEmitter = gerritEventEmitter;
+  globalGerritObj._eventEmitter = eventEmitter;
   ['addListener',
     'dispatch',
     'emit',
@@ -180,7 +182,7 @@
      *   });
      * });
      */
-    globalGerritObj[method] = gerritEventEmitter[method]
-        .bind(gerritEventEmitter);
+    globalGerritObj[method] = eventEmitter[method]
+        .bind(eventEmitter);
   });
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
index 6663f07..50a837d 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
+import {appContext} from '../../../services/app-context.js';
 
 const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
 const MAX_GET_TOKEN_RETRIES = 2;
@@ -34,6 +34,7 @@
     this._status = Auth.STATUS.UNDETERMINED;
     this._authCheckPromise = null;
     this._last_auth_check_time = Date.now();
+    this.eventEmitter = appContext.eventEmitter;
   }
 
   get baseUrl() {
@@ -83,7 +84,7 @@
     if (this._status === status) return;
 
     if (this._status === Auth.STATUS.AUTHED) {
-      gerritEventEmitter.emit('auth-error', {
+      this.eventEmitter.emit('auth-error', {
         message: Auth.CREDS_EXPIRED_MSG, action: 'Refresh credentials',
       });
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
index f919cbb..f3abdb0 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
@@ -28,7 +28,7 @@
 import '../../../test/common-test-setup.js';
 import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {Auth, authService} from './gr-auth.js';
-import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
+import {appContext} from '../../../services/app-context.js';
 
 suite('gr-auth', () => {
   let auth;
@@ -161,7 +161,7 @@
         clock.tick(1000 * 10000);
         fakeFetch.returns(Promise.resolve({status: 403}));
         const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
+        appContext.eventEmitter.emit = emitStub;
         auth.authCheck().then(authed2 => {
           assert.isFalse(authed2);
           assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
@@ -179,7 +179,7 @@
         clock.tick(1000 * 10000);
         fakeFetch.returns(Promise.reject(new Error('random error')));
         const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
+        appContext.eventEmitter.emit = emitStub;
         auth.authCheck().then(authed2 => {
           assert.isFalse(authed2);
           assert.isTrue(emitStub.called);
@@ -197,7 +197,7 @@
         clock.tick(1000 * 10000);
         fakeFetch.returns(Promise.resolve({status: 204}));
         const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
+        appContext.eventEmitter.emit = emitStub;
         auth.authCheck().then(authed2 => {
           assert.isTrue(authed2);
           assert.isFalse(emitStub.called);
@@ -215,7 +215,7 @@
         clock.tick(1000 * 10000);
         fakeFetch.returns(Promise.reject(new Error('random error')));
         const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
+        appContext.eventEmitter.emit = emitStub;
         auth.authCheck().then(authed2 => {
           assert.isFalse(authed2);
           assert.isFalse(emitStub.called);
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.js b/polygerrit-ui/app/embed/gr-diff-app-context-init.js
index 8a7fb66..90110f0 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.js
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.js
@@ -24,7 +24,7 @@
   }
 
   /**
-   * @returns {string[]} array of all enabled experiments.
+   * @returns {!Array<string>} array of all enabled experiments.
    */
   get enabledExperiments() {
     return [];
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index 0103bf2..e4be858 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -15,17 +15,6 @@
  * limitations under the License.
  */
 
-function getPathFromNode(el) {
-  if (!el.tagName || el.tagName === 'GR-APP'
-      || el instanceof DocumentFragment
-      || el instanceof HTMLSlotElement) {
-    return '';
-  }
-  let path = el.tagName.toLowerCase();
-  if (el.id) path += `#${el.id}`;
-  if (el.className) path += `.${el.className.replace(/ /g, '.')}`;
-  return path;
-}
 // TODO (dmfilippov): Each function must be exported separately. According to
 // the code style guide, a namespacing is not allowed.
 export const util = {
@@ -76,133 +65,4 @@
     };
     return wrappedPromise;
   },
-
-  /**
-   * Get computed style value.
-   *
-   * If ShadyCSS is provided, use ShadyCSS api.
-   * If `getComputedStyleValue` is provided on the element, use it.
-   * Otherwise fallback to native method (in polymer 2).
-   *
-   */
-  getComputedStyleValue: (name, el) => {
-    let style;
-    if (window.ShadyCSS) {
-      style = ShadyCSS.getComputedStyleValue(el, name);
-    } else if (el.getComputedStyleValue) {
-      style = el.getComputedStyleValue(name);
-    } else {
-      style = getComputedStyle(el).getPropertyValue(name);
-    }
-    return style;
-  },
-
-  /**
-   * Query selector on a dom element.
-   *
-   * This is shadow DOM compatible, but only works when selector is within
-   * one shadow host, won't work if your selector is crossing
-   * multiple shadow hosts.
-   *
-   */
-  querySelector: (el, selector) => {
-    let nodes = [el];
-    let result = null;
-    while (nodes.length) {
-      const node = nodes.pop();
-
-      // Skip if it's an invalid node.
-      if (!node || !node.querySelector) continue;
-
-      // Try find it with native querySelector directly
-      result = node.querySelector(selector);
-
-      if (result) {
-        break;
-      }
-
-      // Add all nodes with shadowRoot and loop through
-      const allShadowNodes = [...node.querySelectorAll('*')]
-          .filter(child => !!child.shadowRoot)
-          .map(child => child.shadowRoot);
-      nodes = nodes.concat(allShadowNodes);
-
-      // Add shadowRoot of current node if has one
-      // as its not included in node.querySelectorAll('*')
-      if (node.shadowRoot) {
-        nodes.push(node.shadowRoot);
-      }
-    }
-    return result;
-  },
-
-  /**
-   * Query selector all dom elements matching with certain selector.
-   *
-   * This is shadow DOM compatible, but only works when selector is within
-   * one shadow host, won't work if your selector is crossing
-   * multiple shadow hosts.
-   *
-   * Note: this can be very expensive, only use when have to.
-   */
-  querySelectorAll: (el, selector) => {
-    let nodes = [el];
-    const results = new Set();
-    while (nodes.length) {
-      const node = nodes.pop();
-
-      if (!node || !node.querySelectorAll) continue;
-
-      // Try find all from regular children
-      [...node.querySelectorAll(selector)]
-          .forEach(el => results.add(el));
-
-      // Add all nodes with shadowRoot and loop through
-      const allShadowNodes = [...node.querySelectorAll('*')]
-          .filter(child => !!child.shadowRoot)
-          .map(child => child.shadowRoot);
-      nodes = nodes.concat(allShadowNodes);
-
-      // Add shadowRoot of current node if has one
-      // as its not included in node.querySelectorAll('*')
-      if (node.shadowRoot) {
-        nodes.push(node.shadowRoot);
-      }
-    }
-    return [...results];
-  },
-
-  /**
-   * Retrieves the dom path of the current event.
-   *
-   * If the event object contains a `path` property, then use it,
-   * otherwise, construct the dom path based on the event target.
-   *
-   * @param {!Event} e
-   * @return {string}
-   * @example
-   *
-   * domNode.onclick = e => {
-   *  getEventPath(e); // eg: div.class1>p#pid.class2
-   * }
-   */
-  getEventPath: e => {
-    if (!e) return '';
-
-    let path = e.path;
-    if (!path || !path.length) {
-      path = [];
-      let el = e.target;
-      while (el) {
-        path.push(el);
-        el = el.parentNode || el.host;
-      }
-    }
-
-    return path.reduce((domPath, curEl) => {
-      const pathForEl = getPathFromNode(curEl);
-      if (!pathForEl) return domPath;
-      return domPath ? `${pathForEl}>${domPath}` : pathForEl;
-    }, '');
-  },
 };
diff --git a/polygerrit-ui/app/scripts/util_test.html b/polygerrit-ui/app/scripts/util_test.html
deleted file mode 100644
index a3893d2..0000000
--- a/polygerrit-ui/app/scripts/util_test.html
+++ /dev/null
@@ -1,89 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2020 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <div id="test" class="a b c">
-      <a class="testBtn"></a>
-    </div>
-  </template>
-</test-fixture>
-
-<script type="module">
-  import '../test/common-test-setup.js';
-  import {util} from './util.js';
-  suite('util tests', () => {
-    suite('getEventPath', () => {
-      test('empty event', () => {
-        assert.equal(util.getEventPath(), '');
-        assert.equal(util.getEventPath(null), '');
-        assert.equal(util.getEventPath(undefined), '');
-        assert.equal(util.getEventPath({}), '');
-      });
-
-      test('event with fake path', () => {
-        assert.equal(util.getEventPath({path: []}), '');
-        assert.equal(util.getEventPath({path: [
-          {tagName: 'dd'},
-        ]}), 'dd');
-      });
-
-      test('event with fake complicated path', () => {
-        assert.equal(util.getEventPath({path: [
-          {tagName: 'dd', id: 'test', className: 'a b'},
-          {tagName: 'DIV', id: 'test2', className: 'a b c'},
-        ]}), 'div#test2.a.b.c>dd#test.a.b');
-      });
-
-      test('event with fake target', () => {
-        const fakeTargetParent2 = {
-          tagName: 'DIV', id: 'test2', className: 'a b c',
-        };
-        const fakeTargetParent1 = {
-          parentNode: fakeTargetParent2,
-          tagName: 'dd',
-          id: 'test',
-          className: 'a b',
-        };
-        const fakeTarget = {tagName: 'SPAN', parentNode: fakeTargetParent1};
-        assert.equal(
-            util.getEventPath({target: fakeTarget}),
-            'div#test2.a.b.c>dd#test.a.b>span'
-        );
-      });
-
-      test('event with real click', () => {
-        const element = fixture('basic');
-        const aLink = element.querySelector('a');
-        let path;
-        aLink.onclick = e => path = util.getEventPath(e);
-        MockInteractions.click(aLink);
-        assert.equal(
-            path,
-            'html>body>test-fixture#basic>div#test.a.b.c>a.testBtn'
-        );
-      });
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/services/app-context-init.js b/polygerrit-ui/app/services/app-context-init.js
index 983314e..fa6a44bf 100644
--- a/polygerrit-ui/app/services/app-context-init.js
+++ b/polygerrit-ui/app/services/app-context-init.js
@@ -17,6 +17,7 @@
 import {appContext} from './app-context.js';
 import {FlagsService} from './flags.js';
 import {GrReporting} from './gr-reporting/gr-reporting.js';
+import {EventEmitter} from './gr-event-interface/gr-event-interface.js';
 
 const initializedServices = new Map();
 
@@ -46,6 +47,6 @@
   addService('flagsService', () => new FlagsService());
   addService('reportingService',
       () => new GrReporting(appContext.flagsService));
-
+  addService('eventEmitter', () => new EventEmitter());
   Object.defineProperties(appContext, registeredServices);
 }
diff --git a/polygerrit-ui/app/services/app-context.js b/polygerrit-ui/app/services/app-context.js
index 62b396d..d82750e 100644
--- a/polygerrit-ui/app/services/app-context.js
+++ b/polygerrit-ui/app/services/app-context.js
@@ -24,4 +24,5 @@
 export const appContext = {
   flagsService: null,
   reportingService: null,
+  eventEmitter: null,
 };
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface.js b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.js
similarity index 100%
rename from polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface.js
rename to polygerrit-ui/app/services/gr-event-interface/gr-event-interface.js
diff --git a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.html
similarity index 94%
rename from polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
rename to polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.html
index 74936ad..ef2c539 100644
--- a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.html
@@ -30,10 +30,10 @@
 </test-fixture>
 
 <script type="module">
-import '../../../test/common-test-setup.js';
-import '../gr-js-api-interface/gr-js-api-interface.js';
+import '../../test/common-test-setup.js';
+import '../../elements/shared/gr-js-api-interface/gr-js-api-interface.js';
 import {EventEmitter} from './gr-event-interface.js';
-import {_testOnly_initGerritPluginApi} from '../gr-js-api-interface/gr-gerrit.js';
+import {_testOnly_initGerritPluginApi} from '../../elements/shared/gr-js-api-interface/gr-gerrit.js';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
index db0d279..5131b9d 100644
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -91,16 +91,6 @@
   assert.equal(cleanups.length, 0);
 
   _testOnly_resetPluginLoader();
-
-  initAppContext();
-  function setMock(serviceName, setupMock) {
-    Object.defineProperty(appContext, serviceName, {
-      get() {
-        return setupMock;
-      },
-    });
-  }
-  setMock('reportingService', grReportingMock);
 });
 
 if (isKarmaTest() || window.stub) {
@@ -125,6 +115,16 @@
   throw new Error('window.stub must be set after wct sets it');
 }
 
+initAppContext();
+function setMock(serviceName, setupMock) {
+  Object.defineProperty(appContext, serviceName, {
+    get() {
+      return setupMock;
+    },
+  });
+}
+setMock('reportingService', grReportingMock);
+
 teardown(() => {
   // WCT incorrectly uses teardown method in the 'fixture' and 'stub'
   // implementations. This leads to slowdown WCT tests after each tests.
diff --git a/polygerrit-ui/app/test/tests.js b/polygerrit-ui/app/test/tests.js
index ad54fc3..2d795ec 100644
--- a/polygerrit-ui/app/test/tests.js
+++ b/polygerrit-ui/app/test/tests.js
@@ -41,7 +41,6 @@
   'admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html',
   'admin/gr-plugin-list/gr-plugin-list_test.html',
   'admin/gr-repo-access/gr-repo-access_test.html',
-  'admin/gr-repo-command/gr-repo-command_test.html',
   'admin/gr-repo-commands/gr-repo-commands_test.html',
   'admin/gr-repo-dashboards/gr-repo-dashboards_test.html',
   'admin/gr-repo-detail-list/gr-repo-detail-list_test.html',
@@ -147,7 +146,6 @@
   'settings/gr-settings-view/gr-settings-view_test.html',
   'settings/gr-ssh-editor/gr-ssh-editor_test.html',
   'settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html',
-  'shared/gr-event-interface/gr-event-interface_test.html',
   'shared/gr-account-entry/gr-account-entry_test.html',
   'shared/gr-account-label/gr-account-label_test.html',
   'shared/gr-account-list/gr-account-list_test.html',
@@ -245,7 +243,6 @@
   'gr-group-suggestions-provider/gr-group-suggestions-provider_test.html',
   'gr-display-name-utils/gr-display-name-utils_test.html',
   'gr-email-suggestions-provider/gr-email-suggestions-provider_test.html',
-  'util_test.html',
 ];
 /* eslint-enable max-len */
 for (let file of scripts) {
@@ -258,6 +255,7 @@
   'flags_test.html',
   'gr-reporting/gr-reporting_test.html',
   'gr-reporting/gr-reporting_mock_test.html',
+  'gr-event-interface/gr-event-interface_test.html',
 ];
 for (let file of services) {
   file = servicesPath + file;
diff --git a/polygerrit-ui/app/utils/dom-util.js b/polygerrit-ui/app/utils/dom-util.js
new file mode 100644
index 0000000..a9f080f
--- /dev/null
+++ b/polygerrit-ui/app/utils/dom-util.js
@@ -0,0 +1,178 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+function getPathFromNode(el) {
+  if (!el.tagName || el.tagName === 'GR-APP'
+        || el instanceof DocumentFragment
+        || el instanceof HTMLSlotElement) {
+    return '';
+  }
+  let path = el.tagName.toLowerCase();
+  if (el.id) path += `#${el.id}`;
+  if (el.className) path += `.${el.className.replace(/ /g, '.')}`;
+  return path;
+}
+
+/**
+ * Get computed style value.
+ *
+ * If ShadyCSS is provided, use ShadyCSS api.
+ * If `getComputedStyleValue` is provided on the element, use it.
+ * Otherwise fallback to native method (in polymer 2).
+ *
+ */
+export function getComputedStyleValue(name, el) {
+  let style;
+  if (window.ShadyCSS) {
+    style = ShadyCSS.getComputedStyleValue(el, name);
+  } else if (el.getComputedStyleValue) {
+    style = el.getComputedStyleValue(name);
+  } else {
+    style = getComputedStyle(el).getPropertyValue(name);
+  }
+  return style;
+}
+
+/**
+ * Query selector on a dom element.
+ *
+ * This is shadow DOM compatible, but only works when selector is within
+ * one shadow host, won't work if your selector is crossing
+ * multiple shadow hosts.
+ *
+ */
+export function querySelector(el, selector) {
+  let nodes = [el];
+  let result = null;
+  while (nodes.length) {
+    const node = nodes.pop();
+
+    // Skip if it's an invalid node.
+    if (!node || !node.querySelector) continue;
+
+    // Try find it with native querySelector directly
+    result = node.querySelector(selector);
+
+    if (result) {
+      break;
+    }
+
+    // Add all nodes with shadowRoot and loop through
+    const allShadowNodes = [...node.querySelectorAll('*')]
+        .filter(child => !!child.shadowRoot)
+        .map(child => child.shadowRoot);
+    nodes = nodes.concat(allShadowNodes);
+
+    // Add shadowRoot of current node if has one
+    // as its not included in node.querySelectorAll('*')
+    if (node.shadowRoot) {
+      nodes.push(node.shadowRoot);
+    }
+  }
+  return result;
+}
+
+/**
+ * Query selector all dom elements matching with certain selector.
+ *
+ * This is shadow DOM compatible, but only works when selector is within
+ * one shadow host, won't work if your selector is crossing
+ * multiple shadow hosts.
+ *
+ * Note: this can be very expensive, only use when have to.
+ */
+export function querySelectorAll(el, selector) {
+  let nodes = [el];
+  const results = new Set();
+  while (nodes.length) {
+    const node = nodes.pop();
+
+    if (!node || !node.querySelectorAll) continue;
+
+    // Try find all from regular children
+    [...node.querySelectorAll(selector)]
+        .forEach(el => results.add(el));
+
+    // Add all nodes with shadowRoot and loop through
+    const allShadowNodes = [...node.querySelectorAll('*')]
+        .filter(child => !!child.shadowRoot)
+        .map(child => child.shadowRoot);
+    nodes = nodes.concat(allShadowNodes);
+
+    // Add shadowRoot of current node if has one
+    // as its not included in node.querySelectorAll('*')
+    if (node.shadowRoot) {
+      nodes.push(node.shadowRoot);
+    }
+  }
+  return [...results];
+}
+
+/**
+ * Retrieves the dom path of the current event.
+ *
+ * If the event object contains a `path` property, then use it,
+ * otherwise, construct the dom path based on the event target.
+ *
+ * @param {!Event} e
+ * @return {string}
+ * @example
+ *
+ * domNode.onclick = e => {
+ *  getEventPath(e); // eg: div.class1>p#pid.class2
+ * }
+ */
+export function getEventPath(e) {
+  if (!e) return '';
+
+  let path = e.path;
+  if (!path || !path.length) {
+    path = [];
+    let el = e.target;
+    while (el) {
+      path.push(el);
+      el = el.parentNode || el.host;
+    }
+  }
+
+  return path.reduce((domPath, curEl) => {
+    const pathForEl = getPathFromNode(curEl);
+    if (!pathForEl) return domPath;
+    return domPath ? `${pathForEl}>${domPath}` : pathForEl;
+  }, '');
+}
+
+/**
+ * Are any ancestors of the element (or the element itself) members of the
+ * given class.
+ *
+ * @param {!Element} element
+ * @param {string} className
+ * @param {Element=} opt_stopElement If provided, stop traversing the
+ *     ancestry when the stop element is reached. The stop element's class
+ *     is not checked.
+ * @return {boolean}
+ */
+export function descendedFromClass(element, className, opt_stopElement) {
+  let isDescendant = element.classList.contains(className);
+  while (!isDescendant && element.parentElement &&
+        (!opt_stopElement || element.parentElement !== opt_stopElement)) {
+    isDescendant = element.classList.contains(className);
+    element = element.parentElement;
+  }
+  return isDescendant;
+}
\ No newline at end of file
diff --git a/polygerrit-ui/app/utils/dom-util_test.js b/polygerrit-ui/app/utils/dom-util_test.js
new file mode 100644
index 0000000..c317578
--- /dev/null
+++ b/polygerrit-ui/app/utils/dom-util_test.js
@@ -0,0 +1,139 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../test/common-test-setup-karma.js';
+import {getComputedStyleValue, querySelector, querySelectorAll, descendedFromClass, getEventPath} from './dom-util.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+class TestEle extends PolymerElement {
+  static get is() {
+    return 'dom-util-test-element';
+  }
+
+  static get template() {
+    return html`
+    <div>
+      <div class="a">
+        <div class="b">
+          <div class="c"></div>
+        </div>
+        <span class="ss"></span>
+      </div>
+      <span class="ss"></span>
+    </div>
+    `;
+  }
+}
+
+customElements.define(TestEle.is, TestEle);
+
+const basicFixture = fixtureFromTemplate(html`
+  <div id="test" class="a b c">
+    <a class="testBtn" style="color:red;"></a>
+    <dom-util-test-element></dom-util-test-element>
+    <span class="ss"></span>
+  </div>
+`);
+
+suite('dom-util tests', () => {
+  suite('getEventPath', () => {
+    test('empty event', () => {
+      assert.equal(getEventPath(), '');
+      assert.equal(getEventPath(null), '');
+      assert.equal(getEventPath(undefined), '');
+      assert.equal(getEventPath({}), '');
+    });
+
+    test('event with fake path', () => {
+      assert.equal(getEventPath({path: []}), '');
+      assert.equal(getEventPath({path: [
+        {tagName: 'dd'},
+      ]}), 'dd');
+    });
+
+    test('event with fake complicated path', () => {
+      assert.equal(getEventPath({path: [
+        {tagName: 'dd', id: 'test', className: 'a b'},
+        {tagName: 'DIV', id: 'test2', className: 'a b c'},
+      ]}), 'div#test2.a.b.c>dd#test.a.b');
+    });
+
+    test('event with fake target', () => {
+      const fakeTargetParent2 = {
+        tagName: 'DIV', id: 'test2', className: 'a b c',
+      };
+      const fakeTargetParent1 = {
+        parentNode: fakeTargetParent2,
+        tagName: 'dd',
+        id: 'test',
+        className: 'a b',
+      };
+      const fakeTarget = {tagName: 'SPAN', parentNode: fakeTargetParent1};
+      assert.equal(
+          getEventPath({target: fakeTarget}),
+          'div#test2.a.b.c>dd#test.a.b>span'
+      );
+    });
+
+    test('event with real click', () => {
+      const element = basicFixture.instantiate();
+      const aLink = element.querySelector('a');
+      let path;
+      aLink.onclick = e => path = getEventPath(e);
+      MockInteractions.click(aLink);
+      assert.equal(
+          path,
+          `html>body>test-fixture#${basicFixture.fixtureId}>` +
+          'div#test.a.b.c>a.testBtn'
+      );
+    });
+  });
+
+  suite('querySelector and querySelectorAll', () => {
+    test('query cross shadow dom', () => {
+      const element = basicFixture.instantiate();
+      const theFirstEl = querySelector(element, '.ss');
+      const allEls = querySelectorAll(element, '.ss');
+      assert.equal(allEls.length, 3);
+      assert.equal(theFirstEl, allEls[0]);
+    });
+  });
+
+  suite('getComputedStyleValue', () => {
+    test('color style', () => {
+      const element = basicFixture.instantiate();
+      const testBtn = querySelector(element, '.testBtn');
+      assert.equal(
+          getComputedStyleValue('color', testBtn), 'rgb(255, 0, 0)'
+      );
+    });
+  });
+
+  suite('descendedFromClass', () => {
+    test('basic tests', () => {
+      const element = basicFixture.instantiate();
+      const testEl = querySelector(element, 'dom-util-test-element');
+      // .c is a child of .a and not vice versa.
+      assert.isTrue(descendedFromClass(querySelector(testEl, '.c'), 'a'));
+      assert.isFalse(descendedFromClass(querySelector(testEl, '.a'), 'c'));
+
+      // Stops at stop element.
+      assert.isFalse(descendedFromClass(querySelector(testEl, '.c'), 'a',
+          querySelector(testEl, '.b')));
+    });
+  });
+});
\ No newline at end of file
diff --git a/resources/com/google/gerrit/httpd/raw/HostPage.html b/resources/com/google/gerrit/httpd/raw/HostPage.html
deleted file mode 100644
index c0d8446..0000000
--- a/resources/com/google/gerrit/httpd/raw/HostPage.html
+++ /dev/null
@@ -1,26 +0,0 @@
-<html>
-  <head>
-    <title>Gerrit Code Review</title>
-    <meta name="gwt:property" content="locale=en_US" />
-    <script id="gerrit_hostpagedata"></script>
-    <style id="gerrit_sitecss" type="text/css"></style>
-    <link rel="shortcut icon" type="image/x-icon" href="favicon.ico" />
-  </head>
-  <body>
-    <div id="gerrit_topmenu"></div>
-    <div id="gerrit_header"></div>
-    <div id="gerrit_startinggerrit" style="margin-left: 10px;">
-      <p>Loading <a href="https://www.gerritcodereview.com/" target="_blank">Gerrit Code Review</a> ...</p>
-      <noscript>
-        <p>Gerrit requires a JavaScript enabled browser.</p>
-      </noscript>
-    </div>
-    <div id="gerrit_body"></div>
-    <div style="clear: both">
-      <div id="gerrit_footer"></div>
-      <div id="gerrit_btmmenu"></div>
-    </div>
-    <iframe src="javascript:''" id="__gwt_historyFrame" tabIndex='-1' style="position:absolute;width:0;height:0;border:0"></iframe>
-    <script id="gerrit_module"></script>
-  </body>
-</html>
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
index 2f5447b..9908ee8 100644
--- a/tools/bzl/maven_jar.bzl
+++ b/tools/bzl/maven_jar.bzl
@@ -189,7 +189,6 @@
         "repository": attr.string(default = MAVEN_CENTRAL),
         "sha1": attr.string(),
         "src_sha1": attr.string(),
-        "unsign": attr.bool(default = False),
         "exports": attr.string_list(),
         "deps": attr.string_list(),
         "_download_script": attr.label(default = Label("//tools:download_file.py")),
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index d49e700..ce5d62d 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -2,6 +2,8 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//:version.bzl", "GERRIT_VERSION")
 
+IN_TREE_BUILD_MODE = True
+
 PLUGIN_DEPS = ["//plugins:plugin-lib"]
 
 PLUGIN_DEPS_NEVERLINK = ["//plugins:plugin-lib-neverlink"]
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 8748250..14c726e 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -29,9 +29,6 @@
       <name>Ben Rohlfs</name>
     </developer>
     <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
       <name>David Ostrovsky</name>
     </developer>
     <developer>
@@ -53,6 +50,9 @@
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Matthias Sohn</name>
+    </developer>
+    <developer>
       <name>Ole Rehmsen</name>
     </developer>
     <developer>
@@ -61,6 +61,9 @@
     <developer>
       <name>Saša Živkov</name>
     </developer>
+    <developer>
+      <name>Sven Selberg</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index ae31ac9..bd323ba 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -29,9 +29,6 @@
       <name>Ben Rohlfs</name>
     </developer>
     <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
       <name>David Ostrovsky</name>
     </developer>
     <developer>
@@ -53,6 +50,9 @@
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Matthias Sohn</name>
+    </developer>
+    <developer>
       <name>Ole Rehmsen</name>
     </developer>
     <developer>
@@ -61,6 +61,9 @@
     <developer>
       <name>Saša Živkov</name>
     </developer>
+    <developer>
+      <name>Sven Selberg</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index dc25c80..3b059e5 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -29,9 +29,6 @@
       <name>Ben Rohlfs</name>
     </developer>
     <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
       <name>David Ostrovsky</name>
     </developer>
     <developer>
@@ -53,6 +50,9 @@
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Matthias Sohn</name>
+    </developer>
+    <developer>
       <name>Ole Rehmsen</name>
     </developer>
     <developer>
@@ -61,6 +61,9 @@
     <developer>
       <name>Saša Živkov</name>
     </developer>
+    <developer>
+      <name>Sven Selberg</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index d21f88c..b8fa132 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -29,9 +29,6 @@
       <name>Ben Rohlfs</name>
     </developer>
     <developer>
-      <name>Dave Borowitz</name>
-    </developer>
-    <developer>
       <name>David Ostrovsky</name>
     </developer>
     <developer>
@@ -53,6 +50,9 @@
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Matthias Sohn</name>
+    </developer>
+    <developer>
       <name>Ole Rehmsen</name>
     </developer>
     <developer>
@@ -61,6 +61,9 @@
     <developer>
       <name>Saša Živkov</name>
     </developer>
+    <developer>
+      <name>Sven Selberg</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 1da5004..26dcd31 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -96,8 +96,8 @@
     # and httpasyncclient as necessary.
     maven_jar(
         name = "elasticsearch-rest-client",
-        artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.7.1",
-        sha1 = "6d44a8e35c11df6883747200bcf46f476a1782b8",
+        artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.8.0",
+        sha1 = "ab28f6110bdc7d2ec886e1d6ff29a6c8ee30b883",
     )
 
     maven_jar(