Merge "Fix failing tests for gr-diff-host"
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index d8f3d38..3278b5a 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -253,6 +253,11 @@
 responsibilities and live up to the promise of answering incoming
 requests in a timely manner.
 
+Community members may submit new items under the
+link:https://bugs.chromium.org/p/gerrit/issues/list?q=component:ESC[ESC component]
+in the issue tracker, or add that component to existing items, to raise them to
+the attention of ESC members.
+
 link:#maintainer[Maintainers] can become steering committee member by
 election, or by being appointed by Google (only for the seats that
 belong to Google).
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 197a6a3..fa9299a 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -903,11 +903,11 @@
   }
 
   protected void approve(String id) throws Exception {
-    gApi.changes().id(id).revision("current").review(ReviewInput.approve());
+    gApi.changes().id(id).current().review(ReviewInput.approve());
   }
 
   protected void recommend(String id) throws Exception {
-    gApi.changes().id(id).revision("current").review(ReviewInput.recommend());
+    gApi.changes().id(id).current().review(ReviewInput.recommend());
   }
 
   protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index f62ccfb..088de23 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -432,7 +432,7 @@
               .reviewer(reviewerByEmail)
               .reviewer(ccer.email(), ReviewerState.CC, false)
               .reviewer(ccerByEmail, ReviewerState.CC, false);
-      ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+      ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
       supportReviewersByEmail = true;
       if (result.reviewers.values().stream().anyMatch(v -> v.error != null)) {
         supportReviewersByEmail = false;
@@ -440,7 +440,7 @@
             ReviewInput.noScore()
                 .reviewer(reviewer.email())
                 .reviewer(ccer.email(), ReviewerState.CC, false);
-        result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+        result = gApi.changes().id(r.getChangeId()).current().review(in);
       }
       Truth.assertThat(result.reviewers.values().stream().allMatch(v -> v.error == null)).isTrue();
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index ea63d73..4279764 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -522,7 +522,9 @@
     cfg.setInt("sshd", null, "commandStartThreads", 1);
     cfg.setInt("receive", null, "threadPoolSize", 1);
     cfg.setInt("index", null, "threads", 1);
-    cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
+    if (cfg.getString("index", null, "reindexAfterRefUpdate") == null) {
+      cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
+    }
   }
 
   private static Injector createTestInjector(Daemon daemon) throws Exception {
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 3d70996..c6d9dee 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -201,6 +201,9 @@
    */
   void index(boolean indexChildren) throws RestApiException;
 
+  /** Reindexes all changes of the project. */
+  void indexChanges() throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -370,5 +373,10 @@
     public void index(boolean indexChildren) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void indexChanges() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java b/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
index 8dd64ed..64bc022 100644
--- a/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
+++ b/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
@@ -20,6 +20,21 @@
 @ExtensionPoint
 public interface ChangeIndexedListener {
   /**
+   * Invoked when a change is scheduled for indexing.
+   *
+   * @param projectName project containing the change
+   * @param id id of the change that was scheduled for indexing
+   */
+  default void onChangeScheduledForIndexing(String projectName, int id) {}
+
+  /**
+   * Invoked when a change is scheduled for deletion from indexing.
+   *
+   * @param id id of the change that was scheduled for deletion from indexing
+   */
+  default void onChangeScheduledForDeletionFromIndex(int id) {}
+
+  /**
    * Invoked when a change is indexed.
    *
    * @param projectName project containing the change
diff --git a/java/com/google/gerrit/extensions/restapi/Response.java b/java/com/google/gerrit/extensions/restapi/Response.java
index f5b9145..5504cfd 100644
--- a/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/java/com/google/gerrit/extensions/restapi/Response.java
@@ -74,7 +74,7 @@
   }
 
   @SuppressWarnings({"unchecked", "rawtypes"})
-  public static <T> T unwrap(T obj) {
+  public static <T> T unwrap(T obj) throws Exception {
     while (obj instanceof Response) {
       obj = (T) ((Response) obj).value();
     }
@@ -96,7 +96,7 @@
 
   public abstract int statusCode();
 
-  public abstract T value();
+  public abstract T value() throws Exception;
 
   public abstract CacheControl caching();
 
@@ -306,8 +306,8 @@
     }
 
     @Override
-    public T value() {
-      throw new UnsupportedOperationException();
+    public T value() throws Exception {
+      throw cause();
     }
 
     @Override
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index d45a86d..f17dd80 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -12,6 +12,7 @@
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/api",
         "//lib:guava",
         "//lib/bouncycastle:bcpg-neverlink",
         "//lib/bouncycastle:bcprov-neverlink",
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 62f1d18..652afea 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.gpg.api;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.api.accounts.GpgKeysInput;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -68,6 +70,8 @@
       return gpgKeys.get().list().apply(account).value();
     } catch (PGPException | IOException e) {
       throw new GpgException(e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list GPG keys", e);
     }
   }
 
@@ -82,6 +86,8 @@
       return postGpgKeys.get().apply(account, in).value();
     } catch (PGPException | IOException | ConfigInvalidException e) {
       throw new GpgException(e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put GPG keys", e);
     }
   }
 
diff --git a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index 311e00a..0ff12e8 100644
--- a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.gpg.api;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -47,8 +49,8 @@
   public GpgKeyInfo get() throws RestApiException {
     try {
       return get.apply(rsrc).value();
-    } catch (IOException e) {
-      throw new RestApiException("Cannot get GPG key", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get GPG key", e);
     }
   }
 
@@ -57,7 +59,7 @@
     try {
       delete.apply(rsrc, new Input());
     } catch (PGPException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot delete GPG key", e);
+      throw asRestApiException("Cannot delete GPG key", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 1d86e50..7fa9767 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -248,8 +248,12 @@
 
   @Override
   public boolean getActive() throws RestApiException {
-    Response<String> result = getActive.apply(account);
-    return result.statusCode() == SC_OK && result.value().equals("ok");
+    try {
+      Response<String> result = getActive.apply(account);
+      return result.statusCode() == SC_OK && result.value().equals("ok");
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get active", e);
+    }
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index b852c4d..a04be30 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -401,7 +401,11 @@
 
   @Override
   public String topic() throws RestApiException {
-    return getTopic.apply(change).value();
+    try {
+      return getTopic.apply(change).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get topic", e);
+    }
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index f1bd690..85758c1 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -624,7 +624,11 @@
 
   @Override
   public String description() throws RestApiException {
-    return getDescription.apply(revision).value();
+    try {
+      return getDescription.apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get description", e);
+    }
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/api/config/ServerImpl.java b/java/com/google/gerrit/server/api/config/ServerImpl.java
index 4ca842b..ab40ec8 100644
--- a/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -156,7 +156,11 @@
   }
 
   @Override
-  public List<TopMenu.MenuEntry> topMenus() {
-    return listTopMenus.apply(new ConfigResource()).value();
+  public List<TopMenu.MenuEntry> topMenus() throws RestApiException {
+    try {
+      return listTopMenus.apply(new ConfigResource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get top menus", e);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index 5e58d49..bb04ab4 100644
--- a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -136,7 +136,11 @@
 
   @Override
   public String name() throws RestApiException {
-    return getName.apply(rsrc).value();
+    try {
+      return getName.apply(rsrc).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get group name", e);
+    }
   }
 
   @Override
@@ -172,7 +176,11 @@
 
   @Override
   public String description() throws RestApiException {
-    return getDescription.apply(rsrc).value();
+    try {
+      return getDescription.apply(rsrc).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get group description", e);
+    }
   }
 
   @Override
@@ -188,7 +196,11 @@
 
   @Override
   public GroupOptionsInfo options() throws RestApiException {
-    return getOptions.apply(rsrc).value();
+    try {
+      return getOptions.apply(rsrc).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get group options", e);
+    }
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
index 95912e4..3932177 100644
--- a/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
+++ b/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.plugins;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.plugins.PluginApi;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
@@ -53,7 +55,11 @@
 
   @Override
   public PluginInfo get() throws RestApiException {
-    return getStatus.apply(resource).value();
+    try {
+      return getStatus.apply(resource).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get status", e);
+    }
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/api/plugins/PluginsImpl.java b/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
index e45b3e6..c275093 100644
--- a/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
+++ b/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.plugins;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.api.plugins.PluginApi;
 import com.google.gerrit.extensions.api.plugins.Plugins;
@@ -27,7 +29,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.SortedMap;
 
 @Singleton
@@ -59,7 +60,11 @@
     return new ListRequest() {
       @Override
       public SortedMap<String, PluginInfo> getAsMap() throws RestApiException {
-        return listProvider.get().request(this).apply(TopLevelResource.INSTANCE).value();
+        try {
+          return listProvider.get().request(this).apply(TopLevelResource.INSTANCE).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list plugins", e);
+        }
       }
     };
   }
@@ -87,8 +92,8 @@
       Response<PluginInfo> created =
           installProvider.get().setName(name).apply(TopLevelResource.INSTANCE, input);
       return pluginApi.create(plugins.parse(created.value().id));
-    } catch (IOException e) {
-      throw new RestApiException("could not install plugin", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot install plugin", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 7def99e..c7cca6f 100644
--- a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -119,8 +119,8 @@
   public List<ReflogEntryInfo> reflog() throws RestApiException {
     try {
       return getReflog.apply(resource()).value();
-    } catch (IOException | PermissionBackendException e) {
-      throw new RestApiException("Cannot retrieve reflog", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve reflog", e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
index 22bb076..1f950bd 100644
--- a/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.projects;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -43,7 +45,11 @@
 
   @Override
   public ProjectInfo get(boolean recursive) throws RestApiException {
-    getChildProject.setRecursive(recursive);
-    return getChildProject.apply(rsrc).value();
+    try {
+      getChildProject.setRecursive(recursive);
+      return getChildProject.apply(rsrc).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot child project", e);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java b/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
index 786ab95..61736f6 100644
--- a/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
@@ -68,7 +68,7 @@
   public DashboardInfo get(boolean inherited) throws RestApiException {
     try {
       return get.get().setInherited(inherited).apply(resource()).value();
-    } catch (IOException | PermissionBackendException | ConfigInvalidException e) {
+    } catch (Exception e) {
       throw asRestApiException("Cannot read dashboard", e);
     }
   }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 207f4bc..1ac905d 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -69,6 +70,7 @@
 import com.google.gerrit.server.restapi.project.GetHead;
 import com.google.gerrit.server.restapi.project.GetParent;
 import com.google.gerrit.server.restapi.project.Index;
+import com.google.gerrit.server.restapi.project.IndexChanges;
 import com.google.gerrit.server.restapi.project.ListBranches;
 import com.google.gerrit.server.restapi.project.ListDashboards;
 import com.google.gerrit.server.restapi.project.ListTags;
@@ -124,6 +126,7 @@
   private final GetParent getParent;
   private final SetParent setParent;
   private final Index index;
+  private final IndexChanges indexChanges;
 
   @AssistedInject
   ProjectApiImpl(
@@ -158,6 +161,7 @@
       GetParent getParent,
       SetParent setParent,
       Index index,
+      IndexChanges indexChanges,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -192,6 +196,7 @@
         getParent,
         setParent,
         index,
+        indexChanges,
         null);
   }
 
@@ -228,6 +233,7 @@
       GetParent getParent,
       SetParent setParent,
       Index index,
+      IndexChanges indexChanges,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -262,6 +268,7 @@
         getParent,
         setParent,
         index,
+        indexChanges,
         name);
   }
 
@@ -298,6 +305,7 @@
       GetParent getParent,
       SetParent setParent,
       Index index,
+      IndexChanges indexChanges,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -332,6 +340,7 @@
     this.setParent = setParent;
     this.name = name;
     this.index = index;
+    this.indexChanges = indexChanges;
   }
 
   @Override
@@ -368,7 +377,11 @@
 
   @Override
   public String description() throws RestApiException {
-    return getDescription.apply(checkExists()).value();
+    try {
+      return getDescription.apply(checkExists()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get description", e);
+    }
   }
 
   @Override
@@ -427,7 +440,11 @@
 
   @Override
   public ConfigInfo config() throws RestApiException {
-    return getConfig.apply(checkExists()).value();
+    try {
+      return getConfig.apply(checkExists()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get config", e);
+    }
   }
 
   @Override
@@ -640,6 +657,15 @@
     }
   }
 
+  @Override
+  public void indexChanges() throws RestApiException {
+    try {
+      indexChanges.apply(checkExists(), new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index changes", e);
+    }
+  }
+
   private ProjectResource checkExists() throws ResourceNotFoundException {
     if (project == null) {
       throw new ResourceNotFoundException(name);
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 07bd963..4a1180c 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -31,7 +31,9 @@
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -67,6 +69,7 @@
   @Nullable private final ChangeIndexCollection indexes;
   @Nullable private final ChangeIndex index;
   private final ChangeData.Factory changeDataFactory;
+  private final ChangeNotes.Factory notesFactory;
   private final ThreadLocalRequestContext context;
   private final ListeningExecutorService batchExecutor;
   private final ListeningExecutorService executor;
@@ -83,6 +86,7 @@
   ChangeIndexer(
       @GerritServerConfig Config cfg,
       ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory,
       ThreadLocalRequestContext context,
       PluginSetContext<ChangeIndexedListener> indexedListeners,
       StalenessChecker stalenessChecker,
@@ -91,6 +95,7 @@
       @Assisted ChangeIndex index) {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
+    this.notesFactory = notesFactory;
     this.context = context;
     this.indexedListeners = indexedListeners;
     this.stalenessChecker = stalenessChecker;
@@ -104,6 +109,7 @@
   ChangeIndexer(
       @GerritServerConfig Config cfg,
       ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory notesFactory,
       ThreadLocalRequestContext context,
       PluginSetContext<ChangeIndexedListener> indexedListeners,
       StalenessChecker stalenessChecker,
@@ -112,6 +118,7 @@
       @Assisted ChangeIndexCollection indexes) {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
+    this.notesFactory = notesFactory;
     this.context = context;
     this.indexedListeners = indexedListeners;
     this.stalenessChecker = stalenessChecker;
@@ -134,6 +141,7 @@
   public ListenableFuture<?> indexAsync(Project.NameKey project, Change.Id id) {
     IndexTask task = new IndexTask(project, id);
     if (queuedIndexTasks.add(task)) {
+      fireChangeScheduledForIndexingEvent(project.get(), id.get());
       return submit(task);
     }
     return Futures.immediateFuture(null);
@@ -159,6 +167,11 @@
    * @param cd change to index.
    */
   public void index(ChangeData cd) {
+    fireChangeScheduledForIndexingEvent(cd.project().get(), cd.getId().get());
+    doIndex(cd);
+  }
+
+  private void doIndex(ChangeData cd) {
     indexImpl(cd);
 
     // Always double-check whether the change might be stale immediately after
@@ -199,10 +212,18 @@
     fireChangeIndexedEvent(cd.project().get(), cd.getId().get());
   }
 
+  private void fireChangeScheduledForIndexingEvent(String projectName, int id) {
+    indexedListeners.runEach(l -> l.onChangeScheduledForIndexing(projectName, id));
+  }
+
   private void fireChangeIndexedEvent(String projectName, int id) {
     indexedListeners.runEach(l -> l.onChangeIndexed(projectName, id));
   }
 
+  private void fireChangeScheduledForDeletionFromIndexEvent(int id) {
+    indexedListeners.runEach(l -> l.onChangeScheduledForDeletionFromIndex(id));
+  }
+
   private void fireChangeDeletedFromIndexEvent(int id) {
     indexedListeners.runEach(l -> l.onChangeDeleted(id));
   }
@@ -233,6 +254,7 @@
    * @return future for the deleting task.
    */
   public ListenableFuture<?> deleteAsync(Change.Id id) {
+    fireChangeScheduledForDeletionFromIndexEvent(id.get());
     return submit(new DeleteTask(id));
   }
 
@@ -242,6 +264,11 @@
    * @param id change ID to delete.
    */
   public void delete(Change.Id id) {
+    fireChangeScheduledForDeletionFromIndexEvent(id.get());
+    doDelete(id);
+  }
+
+  private void doDelete(Change.Id id) {
     new DeleteTask(id).call();
   }
 
@@ -332,8 +359,12 @@
     @Override
     public Void callImpl() throws Exception {
       remove();
-      ChangeData cd = changeDataFactory.create(project, id);
-      index(cd);
+      try {
+        ChangeNotes changeNotes = notesFactory.createChecked(project, id);
+        doIndex(changeDataFactory.create(changeNotes));
+      } catch (NoSuchChangeException e) {
+        doDelete(id);
+      }
       return null;
     }
 
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 21579d2..c429158 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -17,7 +17,6 @@
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 
-import com.google.common.base.Objects;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
@@ -34,20 +33,14 @@
 import com.google.gerrit.server.git.QueueProvider.QueueType;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.Collections;
 import java.util.List;
-import java.util.Set;
 import java.util.concurrent.Callable;
-import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Config;
 
@@ -58,15 +51,12 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeIndexer.Factory indexerFactory;
   private final ChangeIndexCollection indexes;
-  private final ChangeNotes.Factory notesFactory;
   private final AllUsersName allUsersName;
   private final AccountCache accountCache;
   private final Provider<AccountIndexer> indexer;
   private final ListeningExecutorService executor;
   private final boolean enabled;
 
-  private final Set<Index> queuedIndexTasks = Collections.newSetFromMap(new ConcurrentHashMap<>());
-
   @Inject
   ReindexAfterRefUpdate(
       @GerritServerConfig Config cfg,
@@ -74,7 +64,6 @@
       Provider<InternalChangeQuery> queryProvider,
       ChangeIndexer.Factory indexerFactory,
       ChangeIndexCollection indexes,
-      ChangeNotes.Factory notesFactory,
       AllUsersName allUsersName,
       AccountCache accountCache,
       Provider<AccountIndexer> indexer,
@@ -83,7 +72,6 @@
     this.queryProvider = queryProvider;
     this.indexerFactory = indexerFactory;
     this.indexes = indexes;
-    this.notesFactory = notesFactory;
     this.allUsersName = allUsersName;
     this.accountCache = accountCache;
     this.indexer = indexer;
@@ -113,12 +101,9 @@
           @Override
           public void onSuccess(List<Change> changes) {
             for (Change c : changes) {
-              Index task = new Index(event, c.getId());
-              if (queuedIndexTasks.add(task)) {
-                // Don't retry indefinitely; if this fails changes may be stale.
-                @SuppressWarnings("unused")
-                Future<?> possiblyIgnoredError = executor.submit(task);
-              }
+              @SuppressWarnings("unused")
+              Future<?> possiblyIgnoredError =
+                  indexerFactory.create(executor, indexes).indexAsync(c.getProject(), c.getId());
             }
           }
 
@@ -178,51 +163,4 @@
     @Override
     protected void remove() {}
   }
-
-  private class Index extends Task<Void> {
-    private final Change.Id id;
-
-    Index(Event event, Change.Id id) {
-      super(event);
-      this.id = id;
-    }
-
-    @Override
-    protected Void impl(RequestContext ctx) throws IOException {
-      // Reload change, as some time may have passed since GetChanges.
-      remove();
-      try {
-        Change c =
-            notesFactory.createChecked(Project.nameKey(event.getProjectName()), id).getChange();
-        indexerFactory.create(executor, indexes).index(c);
-      } catch (NoSuchChangeException e) {
-        indexerFactory.create(executor, indexes).delete(id);
-      }
-      return null;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(Index.class, id.get());
-    }
-
-    @Override
-    public boolean equals(Object obj) {
-      if (!(obj instanceof Index)) {
-        return false;
-      }
-      Index other = (Index) obj;
-      return id.get() == other.id.get();
-    }
-
-    @Override
-    public String toString() {
-      return "Index change " + id.get() + " of project " + event.getProjectName();
-    }
-
-    @Override
-    protected void remove() {
-      queuedIndexTasks.remove(this);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/access/ListAccess.java b/java/com/google/gerrit/server/restapi/access/ListAccess.java
index 437f04c..740a0a4 100644
--- a/java/com/google/gerrit/server/restapi/access/ListAccess.java
+++ b/java/com/google/gerrit/server/restapi/access/ListAccess.java
@@ -15,16 +15,12 @@
 package com.google.gerrit.server.restapi.access;
 
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.project.GetAccess;
 import com.google.inject.Inject;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -49,8 +45,7 @@
 
   @Override
   public Response<Map<String, ProjectAccessInfo>> apply(TopLevelResource resource)
-      throws ResourceNotFoundException, ResourceConflictException, IOException,
-          PermissionBackendException {
+      throws Exception {
     Map<String, ProjectAccessInfo> access = new TreeMap<>();
     for (String p : projects) {
       access.put(p, getAccess.apply(Project.nameKey(p)));
diff --git a/java/com/google/gerrit/server/restapi/account/Stars.java b/java/com/google/gerrit/server/restapi/account/Stars.java
index bbbfa27..cdaa99d 100644
--- a/java/com/google/gerrit/server/restapi/account/Stars.java
+++ b/java/com/google/gerrit/server/restapi/account/Stars.java
@@ -98,8 +98,7 @@
 
     @Override
     @SuppressWarnings("unchecked")
-    public Response<List<ChangeInfo>> apply(AccountResource rsrc)
-        throws BadRequestException, AuthException, PermissionBackendException {
+    public Response<List<ChangeInfo>> apply(AccountResource rsrc) throws Exception {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to list stars of another account");
       }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeMessages.java b/java/com/google/gerrit/server/restapi/change/ChangeMessages.java
index 595d570..fae9180 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeMessages.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeMessages.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.List;
@@ -50,8 +49,7 @@
   }
 
   @Override
-  public ChangeMessageResource parse(ChangeResource parent, IdString id)
-      throws ResourceNotFoundException, PermissionBackendException {
+  public ChangeMessageResource parse(ChangeResource parent, IdString id) throws Exception {
     String uuid = id.get();
 
     List<ChangeMessageInfo> changeMessages = listChangeMessages.apply(parent).value();
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index bd3742b..1eccab1 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -275,7 +275,7 @@
     @Override
     protected Response<ChangeInfo> applyImpl(
         BatchUpdate.Factory updateFactory, ChangeResource rsrc, RebaseInput input)
-        throws UpdateException, RestApiException, IOException, PermissionBackendException {
+        throws Exception {
       PatchSet ps = psUtil.current(rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index b62e475..9216eec 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -454,9 +454,7 @@
     }
 
     @Override
-    public Response<ChangeInfo> apply(ChangeResource rsrc, SubmitInput input)
-        throws RestApiException, RepositoryNotFoundException, IOException,
-            PermissionBackendException, UpdateException, ConfigInvalidException {
+    public Response<ChangeInfo> apply(ChangeResource rsrc, SubmitInput input) throws Exception {
       PatchSet ps = psUtil.current(rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
diff --git a/java/com/google/gerrit/server/restapi/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
index 1ab1f38..6efca52 100644
--- a/java/com/google/gerrit/server/restapi/group/AddMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
@@ -223,8 +222,7 @@
 
     @Override
     public Response<AccountInfo> apply(GroupResource resource, IdString id, Input input)
-        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, IOException,
-            ConfigInvalidException, PermissionBackendException {
+        throws Exception {
       AddMembers.Input in = new AddMembers.Input();
       in._oneMember = id.get();
       try {
diff --git a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
index 3a3b9f4..3a07ae0 100644
--- a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
@@ -144,8 +143,7 @@
 
     @Override
     public Response<GroupInfo> apply(GroupResource resource, IdString id, Input input)
-        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, IOException,
-            ConfigInvalidException, PermissionBackendException {
+        throws Exception {
       AddSubgroups.Input in = new AddSubgroups.Input();
       in.groups = ImmutableList.of(id.get());
       try {
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index 37ea55c..8132457 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -248,8 +248,7 @@
   }
 
   @Override
-  public Response<SortedMap<String, GroupInfo>> apply(TopLevelResource resource)
-      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+  public Response<SortedMap<String, GroupInfo>> apply(TopLevelResource resource) throws Exception {
     SortedMap<String, GroupInfo> output = new TreeMap<>();
     for (GroupInfo info : get()) {
       output.put(MoreObjects.firstNonNull(info.name, "Group " + Url.decode(info.id)), info);
@@ -258,8 +257,7 @@
     return Response.ok(output);
   }
 
-  public List<GroupInfo> get()
-      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+  public List<GroupInfo> get() throws Exception {
     if (!Strings.isNullOrEmpty(suggest)) {
       return suggestGroups();
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
index 9904b1f..314df73 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateDashboard.java
@@ -19,15 +19,12 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.DashboardResource;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import org.kohsuke.args4j.Option;
 
 @Singleton
@@ -45,7 +42,7 @@
 
   @Override
   public Response<DashboardInfo> apply(ProjectResource parent, IdString id, SetDashboardInput input)
-      throws RestApiException, IOException, PermissionBackendException {
+      throws Exception {
     parent.getProjectState().checkStatePermitsWrite();
     if (!DashboardsCollection.isDefaultDashboard(id)) {
       throw new ResourceNotFoundException(id);
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java b/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
index 2702d58..9d9e5f5 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteDashboard.java
@@ -18,14 +18,11 @@
 import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.DashboardResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 
 @Singleton
 public class DeleteDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
@@ -38,7 +35,7 @@
 
   @Override
   public Response<DashboardInfo> apply(DashboardResource resource, SetDashboardInput input)
-      throws RestApiException, IOException, PermissionBackendException {
+      throws Exception {
     if (resource.isProjectDefault()) {
       SetDashboardInput in = new SetDashboardInput();
       in.commitMessage = input != null ? input.commitMessage : null;
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index 51374ab..31f1254 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -117,9 +117,7 @@
     this.projectConfigFactory = projectConfigFactory;
   }
 
-  public ProjectAccessInfo apply(Project.NameKey nameKey)
-      throws ResourceNotFoundException, ResourceConflictException, IOException,
-          PermissionBackendException {
+  public ProjectAccessInfo apply(Project.NameKey nameKey) throws Exception {
     ProjectState state = projectCache.checkedGet(nameKey);
     if (state == null) {
       throw new ResourceNotFoundException(nameKey.get());
diff --git a/java/com/google/gerrit/server/restapi/project/Index.java b/java/com/google/gerrit/server/restapi/project/Index.java
index bc4f668..b83946d1 100644
--- a/java/com/google/gerrit/server/restapi/project/Index.java
+++ b/java/com/google/gerrit/server/restapi/project/Index.java
@@ -22,17 +22,14 @@
 import com.google.gerrit.extensions.api.projects.IndexProjectInput;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.concurrent.Future;
 
 @RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
@@ -53,8 +50,7 @@
   }
 
   @Override
-  public Response.Accepted apply(ProjectResource rsrc, IndexProjectInput input)
-      throws IOException, PermissionBackendException, RestApiException {
+  public Response.Accepted apply(ProjectResource rsrc, IndexProjectInput input) throws Exception {
     String response = "Project " + rsrc.getName() + " submitted for reindexing";
 
     reindex(rsrc.getNameKey(), input.async);
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index c6919d4..7ddbe6c 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -20,13 +20,10 @@
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.IdentifiedUser;
@@ -34,7 +31,6 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -42,7 +38,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -82,8 +77,7 @@
 
   @Override
   public Response<ProjectAccessInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
-      throws ResourceNotFoundException, ResourceConflictException, IOException, AuthException,
-          BadRequestException, UnprocessableEntityException, PermissionBackendException {
+      throws Exception {
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
 
     ProjectConfig config;
diff --git a/java/com/google/gerrit/server/restapi/project/SetDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDashboard.java
index 2804b7c..e8e0c0d 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDashboard.java
@@ -18,14 +18,11 @@
 import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.DashboardResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 
 @Singleton
 public class SetDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
@@ -38,7 +35,7 @@
 
   @Override
   public Response<DashboardInfo> apply(DashboardResource resource, SetDashboardInput input)
-      throws RestApiException, IOException, PermissionBackendException {
+      throws Exception {
     if (resource.isProjectDefault()) {
       return defaultSetter.get().apply(resource, input);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index 1ea3efd..3ec3be5 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -23,12 +23,10 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.DashboardResource;
 import com.google.gerrit.server.project.ProjectCache;
@@ -36,7 +34,6 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Option;
@@ -70,7 +67,7 @@
 
   @Override
   public Response<DashboardInfo> apply(DashboardResource rsrc, SetDashboardInput input)
-      throws RestApiException, IOException, PermissionBackendException {
+      throws Exception {
     if (input == null) {
       input = new SetDashboardInput(); // Delete would set input to null.
     }
diff --git a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 913365b..2edafb9 100644
--- a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -114,11 +114,12 @@
       }
     } catch (RestApiException e) {
       throw die(e);
+    } catch (Exception e) {
+      throw new Failure(1, "unavailable", e);
     }
   }
 
-  private GroupResource createGroup()
-      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+  private GroupResource createGroup() throws Exception {
     GroupInput input = new GroupInput();
     input.description = groupDescription;
     input.visibleToAll = visibleToAll;
diff --git a/java/com/google/gerrit/sshd/commands/FlushCaches.java b/java/com/google/gerrit/sshd/commands/FlushCaches.java
index 98562b0..0c9bbb5 100644
--- a/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.config.ListCaches;
 import com.google.gerrit.server.restapi.config.ListCaches.OutputFormat;
 import com.google.gerrit.server.restapi.config.PostCaches;
@@ -81,13 +80,13 @@
       }
     } catch (RestApiException e) {
       throw die(e.getMessage());
-    } catch (PermissionBackendException e) {
+    } catch (Exception e) {
       throw new Failure(1, "unavailable", e);
     }
   }
 
   @SuppressWarnings("unchecked")
-  private void doList() {
+  private void doList() throws Exception {
     for (String name :
         (List<String>)
             listCaches.setFormat(OutputFormat.LIST).apply(new ConfigResource()).value()) {
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 7509ac9..23f9a24 100644
--- a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -211,8 +211,7 @@
     }
   }
 
-  private void setAccount()
-      throws IOException, UnloggedFailure, ConfigInvalidException, PermissionBackendException {
+  private void setAccount() throws Failure {
     user = genericUserFactory.create(id);
     rsrc = new AccountResource(user.asIdentifiedUser());
     try {
@@ -267,6 +266,8 @@
       }
     } catch (RestApiException e) {
       throw die(e.getMessage());
+    } catch (Exception e) {
+      throw new Failure(1, "unavailable", e);
     }
   }
 
@@ -279,9 +280,7 @@
     }
   }
 
-  private void deleteSshKeys(List<String> sshKeys)
-      throws RestApiException, RepositoryNotFoundException, IOException, ConfigInvalidException,
-          PermissionBackendException {
+  private void deleteSshKeys(List<String> sshKeys) throws Exception {
     List<SshKeyInfo> infos = getSshKeys.apply(rsrc).value();
     if (sshKeys.contains("ALL")) {
       for (SshKeyInfo i : infos) {
@@ -318,8 +317,7 @@
     }
   }
 
-  private void deleteEmail(String email)
-      throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException {
+  private void deleteEmail(String email) throws Exception {
     if (email.equals("ALL")) {
       List<EmailInfo> emails = getEmails.apply(rsrc).value();
       for (EmailInfo e : emails) {
@@ -330,8 +328,7 @@
     }
   }
 
-  private void putPreferred(String email)
-      throws RestApiException, IOException, PermissionBackendException, ConfigInvalidException {
+  private void putPreferred(String email) throws Exception {
     for (EmailInfo e : getEmails.apply(rsrc).value()) {
       if (e.email.equals(email)) {
         putPreferred.apply(new AccountResource.Email(user.asIdentifiedUser(), email), null);
diff --git a/java/com/google/gerrit/sshd/commands/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
index 449d419..c689321 100644
--- a/java/com/google/gerrit/sshd/commands/SetParentCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -16,14 +16,12 @@
 
 import static java.util.stream.Collectors.toList;
 
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -112,7 +110,7 @@
         childProjects.addAll(getChildrenForReparenting(oldParent));
       } catch (PermissionBackendException e) {
         throw new Failure(1, "permissions unavailable", e);
-      } catch (StorageException | RestApiException e) {
+      } catch (Exception e) {
         throw new Failure(1, "failure in request", e);
       }
     }
@@ -148,8 +146,7 @@
    * list of child projects does not contain projects that were specified to be excluded from
    * reparenting.
    */
-  private List<Project.NameKey> getChildrenForReparenting(ProjectState parent)
-      throws PermissionBackendException, RestApiException {
+  private List<Project.NameKey> getChildrenForReparenting(ProjectState parent) throws Exception {
     final List<Project.NameKey> childProjects = new ArrayList<>();
     final List<Project.NameKey> excluded = new ArrayList<>(excludedChildren.size());
     for (ProjectState excludedChild : excludedChildren) {
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 3c617b0..cee06e1 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -110,7 +110,7 @@
   }
 
   @Override
-  protected void run() throws UnloggedFailure {
+  protected void run() throws Failure {
     nw = columns - 50;
     Date now = new Date();
     stdout.format(
@@ -161,38 +161,42 @@
     }
     stdout.print("+---------------------+---------+---------+\n");
 
-    Collection<CacheInfo> caches = getCaches();
-    printMemoryCoreCaches(caches);
-    printMemoryPluginCaches(caches);
-    printDiskCaches(caches);
-    stdout.print('\n');
-
-    boolean showJvm;
     try {
-      permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
-      showJvm = true;
-    } catch (AuthException | PermissionBackendException e) {
-      // Silently ignore and do not display detailed JVM information.
-      showJvm = false;
-    }
-    if (showJvm) {
-      sshSummary();
+      Collection<CacheInfo> caches = getCaches();
+      printMemoryCoreCaches(caches);
+      printMemoryPluginCaches(caches);
+      printDiskCaches(caches);
+      stdout.print('\n');
 
-      SummaryInfo summary =
-          getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource()).value();
-      taskSummary(summary.taskSummary);
-      memSummary(summary.memSummary);
-      threadSummary(summary.threadSummary);
-
-      if (showJVM && summary.jvmSummary != null) {
-        jvmSummary(summary.jvmSummary);
+      boolean showJvm;
+      try {
+        permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
+        showJvm = true;
+      } catch (AuthException | PermissionBackendException e) {
+        // Silently ignore and do not display detailed JVM information.
+        showJvm = false;
       }
+      if (showJvm) {
+        sshSummary();
+
+        SummaryInfo summary =
+            getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource()).value();
+        taskSummary(summary.taskSummary);
+        memSummary(summary.memSummary);
+        threadSummary(summary.threadSummary);
+
+        if (showJVM && summary.jvmSummary != null) {
+          jvmSummary(summary.jvmSummary);
+        }
+      }
+    } catch (Exception e) {
+      throw new Failure(1, "unavailable", e);
     }
 
     stdout.flush();
   }
 
-  private Collection<CacheInfo> getCaches() {
+  private Collection<CacheInfo> getCaches() throws Exception {
     @SuppressWarnings("unchecked")
     Map<String, CacheInfo> caches =
         (Map<String, CacheInfo>) listCaches.apply(new ConfigResource()).value();
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 57562a7..a6ed629 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -99,6 +99,8 @@
       throw die(e);
     } catch (PermissionBackendException e) {
       throw new Failure(1, "permission backend unavailable", e);
+    } catch (Exception e) {
+      throw new Failure(1, "unavailable", e);
     }
 
     boolean viewAll = permissionBackend.user(currentUser).testOrFalse(GlobalPermission.VIEW_QUEUE);
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index 2b30fe9..c080c76 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -15,6 +15,10 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.only;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
@@ -35,7 +39,6 @@
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
-import org.easymock.EasyMock;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -219,10 +222,7 @@
     Repository repo2 = repoManager.createRepository(project2);
     Ref metaConfig = createRef(repo2, RefNames.REFS_CONFIG);
 
-    ProjectCache projectCache = EasyMock.createNiceMock(ProjectCache.class);
-    projectCache.evict(project2);
-    EasyMock.expectLastCall();
-    EasyMock.replay(projectCache);
+    ProjectCache projectCache = mock(ProjectCache.class);
 
     Ref nonMetaConfig = createRef("refs/heads/master");
 
@@ -233,7 +233,7 @@
       updateRef(repo2, metaConfig);
     }
 
-    EasyMock.verify(projectCache);
+    verify(projectCache, only()).evict(project2);
   }
 
   @Test
@@ -241,10 +241,7 @@
     Project.NameKey project2 = Project.nameKey("bar");
     Repository repo2 = repoManager.createRepository(project2);
 
-    ProjectCache projectCache = EasyMock.createNiceMock(ProjectCache.class);
-    projectCache.evict(project2);
-    EasyMock.expectLastCall();
-    EasyMock.replay(projectCache);
+    ProjectCache projectCache = mock(ProjectCache.class);
 
     try (ProjectResetter resetProject =
         builder(null, null, null, null, null, null, projectCache)
@@ -253,7 +250,7 @@
       createRef(repo2, RefNames.REFS_CONFIG);
     }
 
-    EasyMock.verify(projectCache);
+    verify(projectCache, only()).evict(project2);
   }
 
   @Test
@@ -263,15 +260,8 @@
     Repository allUsersRepo = repoManager.createRepository(allUsers);
     Ref userBranch = createRef(allUsersRepo, RefNames.refsUsers(accountId));
 
-    AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
-    accountCache.evict(accountId);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountCache);
-
-    AccountIndexer accountIndexer = EasyMock.createNiceMock(AccountIndexer.class);
-    accountIndexer.index(accountId);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountIndexer);
+    AccountCache accountCache = mock(AccountCache.class);
+    AccountIndexer accountIndexer = mock(AccountIndexer.class);
 
     // Non-user branch because it's not in All-Users.
     Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(2)));
@@ -283,7 +273,8 @@
       updateRef(allUsersRepo, userBranch);
     }
 
-    EasyMock.verify(accountCache, accountIndexer);
+    verify(accountCache, only()).evict(accountId);
+    verify(accountIndexer, only()).index(accountId);
   }
 
   @Test
@@ -292,15 +283,8 @@
     Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
 
-    AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
-    accountCache.evict(accountId);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountCache);
-
-    AccountIndexer accountIndexer = EasyMock.createNiceMock(AccountIndexer.class);
-    accountIndexer.index(accountId);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountIndexer);
+    AccountCache accountCache = mock(AccountCache.class);
+    AccountIndexer accountIndexer = mock(AccountIndexer.class);
 
     try (ProjectResetter resetProject =
         builder(null, accountCache, accountIndexer, null, null, null, null)
@@ -311,7 +295,8 @@
       createRef(allUsersRepo, RefNames.refsUsers(accountId));
     }
 
-    EasyMock.verify(accountCache, accountIndexer);
+    verify(accountCache, only()).evict(accountId);
+    verify(accountIndexer, only()).index(accountId);
   }
 
   @Test
@@ -324,19 +309,8 @@
 
     Account.Id accountId2 = Account.id(2);
 
-    AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
-    accountCache.evict(accountId);
-    EasyMock.expectLastCall();
-    accountCache.evict(accountId2);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountCache);
-
-    AccountIndexer accountIndexer = EasyMock.createNiceMock(AccountIndexer.class);
-    accountIndexer.index(accountId);
-    EasyMock.expectLastCall();
-    accountIndexer.index(accountId2);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountIndexer);
+    AccountCache accountCache = mock(AccountCache.class);
+    AccountIndexer accountIndexer = mock(AccountIndexer.class);
 
     // Non-user branch because it's not in All-Users.
     Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(3)));
@@ -349,7 +323,11 @@
       createRef(allUsersRepo, RefNames.refsUsers(accountId2));
     }
 
-    EasyMock.verify(accountCache, accountIndexer);
+    verify(accountCache).evict(accountId);
+    verify(accountCache).evict(accountId2);
+    verify(accountIndexer).index(accountId);
+    verify(accountIndexer).index(accountId2);
+    verifyNoMoreInteractions(accountCache, accountIndexer);
   }
 
   @Test
@@ -361,19 +339,8 @@
 
     Account.Id accountId2 = Account.id(2);
 
-    AccountCache accountCache = EasyMock.createNiceMock(AccountCache.class);
-    accountCache.evict(accountId);
-    EasyMock.expectLastCall();
-    accountCache.evict(accountId2);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountCache);
-
-    AccountIndexer accountIndexer = EasyMock.createNiceMock(AccountIndexer.class);
-    accountIndexer.index(accountId);
-    EasyMock.expectLastCall();
-    accountIndexer.index(accountId2);
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountIndexer);
+    AccountCache accountCache = mock(AccountCache.class);
+    AccountIndexer accountIndexer = mock(AccountIndexer.class);
 
     // Non-user branch because it's not in All-Users.
     Ref nonUserBranch = createRef(RefNames.refsUsers(Account.id(3)));
@@ -386,7 +353,11 @@
       createRef(allUsersRepo, RefNames.refsUsers(accountId2));
     }
 
-    EasyMock.verify(accountCache, accountIndexer);
+    verify(accountCache).evict(accountId);
+    verify(accountCache).evict(accountId2);
+    verify(accountIndexer).index(accountId);
+    verify(accountIndexer).index(accountId2);
+    verifyNoMoreInteractions(accountCache, accountIndexer);
   }
 
   @Test
@@ -395,10 +366,7 @@
     Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
 
-    AccountCreator accountCreator = EasyMock.createNiceMock(AccountCreator.class);
-    accountCreator.evict(ImmutableSet.of(accountId));
-    EasyMock.expectLastCall();
-    EasyMock.replay(accountCreator);
+    AccountCreator accountCreator = mock(AccountCreator.class);
 
     try (ProjectResetter resetProject =
         builder(accountCreator, null, null, null, null, null, null)
@@ -406,7 +374,7 @@
       createRef(allUsersRepo, RefNames.refsUsers(accountId));
     }
 
-    EasyMock.verify(accountCreator);
+    verify(accountCreator, only()).evict(ImmutableSet.of(accountId));
   }
 
   @Test
@@ -417,18 +385,9 @@
     Project.NameKey allUsers = Project.nameKey(AllUsersNameProvider.DEFAULT);
     Repository allUsersRepo = repoManager.createRepository(allUsers);
 
-    GroupCache cache = EasyMock.createNiceMock(GroupCache.class);
-    GroupIndexer indexer = EasyMock.createNiceMock(GroupIndexer.class);
-    GroupIncludeCache includeCache = EasyMock.createNiceMock(GroupIncludeCache.class);
-    cache.evict(uuid2);
-    indexer.index(uuid2);
-    includeCache.evictParentGroupsOf(uuid2);
-    cache.evict(uuid3);
-    indexer.index(uuid3);
-    includeCache.evictParentGroupsOf(uuid3);
-    EasyMock.expectLastCall();
-
-    EasyMock.replay(cache, indexer);
+    GroupCache cache = mock(GroupCache.class);
+    GroupIndexer indexer = mock(GroupIndexer.class);
+    GroupIncludeCache includeCache = mock(GroupIncludeCache.class);
 
     createRef(allUsersRepo, RefNames.refsGroups(uuid1));
     Ref ref2 = createRef(allUsersRepo, RefNames.refsGroups(uuid2));
@@ -439,7 +398,13 @@
       createRef(allUsersRepo, RefNames.refsGroups(uuid3));
     }
 
-    EasyMock.verify(cache, indexer);
+    verify(cache).evict(uuid2);
+    verify(indexer).index(uuid2);
+    verify(includeCache).evictParentGroupsOf(uuid2);
+    verify(cache).evict(uuid3);
+    verify(indexer).index(uuid3);
+    verify(includeCache).evictParentGroupsOf(uuid3);
+    verifyNoMoreInteractions(cache, indexer, includeCache);
   }
 
   private Ref createRef(String ref) throws IOException {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 9dc79f6..cf72874 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -502,7 +502,7 @@
             .reviewer("byemail2@example.com")
             .reviewer("byemail3@example.com", CC, false)
             .reviewer("byemail4@example.com", CC, false);
-    ReviewResult result = gApi.changes().id(changeId).revision("current").review(in);
+    ReviewResult result = gApi.changes().id(changeId).current().review(in);
     assertThat(result.reviewers).isNotEmpty();
     ChangeInfo info = gApi.changes().id(changeId).get();
     Function<Collection<AccountInfo>, Collection<String>> toEmails =
@@ -528,7 +528,7 @@
 
     // "Undo" a removal.
     in = ReviewInput.noScore().reviewer(email1);
-    gApi.changes().id(changeId).revision("current").review(in);
+    gApi.changes().id(changeId).current().review(in);
     info = gApi.changes().id(changeId).get();
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
         .containsExactly(email1, email2, "byemail2@example.com");
@@ -626,7 +626,7 @@
     assertThat(r.getChange().change().isWorkInProgress()).isTrue();
 
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(false);
-    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
     assertThat(result.ready).isTrue();
 
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
@@ -639,7 +639,7 @@
     assertThat(r.getChange().change().isWorkInProgress()).isFalse();
 
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
     assertThat(result.ready).isNull();
 
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
@@ -656,7 +656,7 @@
             .reviewer(user.email())
             .label("Code-Review", 1)
             .setWorkInProgress(true);
-    gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    gApi.changes().id(r.getChangeId()).current().review(in);
 
     ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
     assertThat(info.workInProgress).isTrue();
@@ -671,7 +671,7 @@
     ReviewInput in = ReviewInput.noScore();
     in.ready = true;
     in.workInProgress = true;
-    ReviewResult result = gApi.changes().id(r.getChangeId()).revision("current").review(in);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
     assertThat(result.error).isEqualTo(PostReview.ERROR_WIP_READY_MUTUALLY_EXCLUSIVE);
   }
 
@@ -1396,10 +1396,10 @@
     Optional<ChangeData> result =
         idx.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of()));
 
-    assertThat(result.isPresent()).isTrue();
+    assertThat(result).isPresent();
     gApi.changes().id(changeId).delete();
     result = idx.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of()));
-    assertThat(result.isPresent()).isFalse();
+    assertThat(result).isEmpty();
   }
 
   @Test
@@ -2003,7 +2003,7 @@
     // Added reviewers not notified by default.
     PushOneCommit.Result r = createWorkInProgressChange();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
-    assertThat(sender.getMessages()).hasSize(0);
+    assertThat(sender.getMessages()).isEmpty();
 
     // Default notification handling can be overridden.
     r = createWorkInProgressChange();
@@ -2016,14 +2016,14 @@
     // In this case, the child ReviewerInput has a notify=OWNER_REVIEWERS
     // that should be ignored.
     r = createWorkInProgressChange();
-    gApi.changes().id(r.getChangeId()).revision("current").review(batchIn);
-    assertThat(sender.getMessages()).hasSize(0);
+    gApi.changes().id(r.getChangeId()).current().review(batchIn);
+    assertThat(sender.getMessages()).isEmpty();
 
     // Top-level notify property can force notifications when adding reviewer
     // via PostReview.
     r = createWorkInProgressChange();
     batchIn.notify = NotifyHandling.OWNER_REVIEWERS;
-    gApi.changes().id(r.getChangeId()).revision("current").review(batchIn);
+    gApi.changes().id(r.getChangeId()).current().review(batchIn);
     assertThat(sender.getMessages()).hasSize(1);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 73feda7..a4157da 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -581,7 +581,7 @@
     CherryPickInput in = new CherryPickInput();
     in.destination = "master";
     in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
-    ChangeInfo c = gApi.changes().id(changeId).revision("current").cherryPick(in).get();
+    ChangeInfo c = gApi.changes().id(changeId).current().cherryPick(in).get();
     return c.changeId;
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index a9c7d99..e3796c4 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -24,6 +24,9 @@
 import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_PARENT;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -47,6 +50,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
@@ -54,6 +58,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -79,6 +84,7 @@
   private static final String JIRA_MATCH = "(jira\\\\s+#?)(\\\\d+)";
 
   @Inject private DynamicSet<ProjectIndexedListener> projectIndexedListeners;
+  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
@@ -473,6 +479,26 @@
   }
 
   @Test
+  public void reindexChangesOfProject() throws Exception {
+    Change.Id changeId1 = createChange().getChange().getId();
+    Change.Id changeId2 = createChange().getChange().getId();
+
+    ChangeIndexedListener changeIndexedListener = mock(ChangeIndexedListener.class);
+    RegistrationHandle registrationHandle =
+        changeIndexedListeners.add("gerrit", changeIndexedListener);
+    try {
+      gApi.projects().name(project.get()).indexChanges();
+
+      verify(changeIndexedListener, times(1))
+          .onChangeScheduledForIndexing(project.get(), changeId1.get());
+      verify(changeIndexedListener, times(1))
+          .onChangeScheduledForIndexing(project.get(), changeId2.get());
+    } finally {
+      registrationHandle.remove();
+    }
+  }
+
+  @Test
   public void maxObjectSizeIsNotSetByDefault() throws Exception {
     ConfigInfo info = getConfig();
     assertThat(info.maxObjectSizeLimit.value).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index 5892536..49fd781 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
@@ -61,7 +62,7 @@
     Optional<FieldBundle> result =
         i.getRaw(project, QueryOptions.create(indexConfig, 0, 1, FIELDS));
 
-    assertThat(result.isPresent()).isTrue();
+    assertThat(result).isPresent();
     Iterable<byte[]> refState = result.get().getValue(ProjectField.REF_STATE);
     assertThat(refState).isNotEmpty();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 49d9f55..22b8051 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -772,7 +772,7 @@
     input.notify = NotifyHandling.NONE;
     sender.clear();
     gApi.changes().id(changeId).current().cherryPick(input);
-    assertThat(sender.getMessages()).hasSize(0);
+    assertThat(sender.getMessages()).isEmpty();
 
     // Disable the notification. The user provided in the 'notifyDetails' should still be notified.
     TestAccount userToNotify = accountCreator.user2();
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index d15c6ce..57b93f6 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -30,7 +30,7 @@
         "pgm",
         "no_windows",
     ],
-    vm_args = ["-Xmx512m"],
+    vm_args = ["-Xmx1024m"],
     deps = [
         ":util",
         "//java/com/google/gerrit/elasticsearch",
diff --git a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
index a573e35..aba0684 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -43,9 +43,9 @@
       QueryOptions opts =
           QueryOptions.create(IndexConfig.createDefault(), 0, 1, ImmutableSet.of("name"));
       Optional<ProjectData> allProjectsData = projectIndex.getSearchIndex().get(allProjects, opts);
-      assertThat(allProjectsData.isPresent()).isTrue();
+      assertThat(allProjectsData).isPresent();
       Optional<ProjectData> allUsersData = projectIndex.getSearchIndex().get(allUsers, opts);
-      assertThat(allUsersData.isPresent()).isTrue();
+      assertThat(allUsersData).isPresent();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index c2df9ca..a756c97 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -32,6 +32,10 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -39,6 +43,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -60,6 +65,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -125,6 +131,7 @@
 
   @Inject private ApprovalsUtil approvalsUtil;
   @Inject private DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
+  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
   @Inject private IdentifiedUser.GenericFactory userFactory;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -1210,6 +1217,63 @@
     }
   }
 
+  @Test
+  @GerritConfig(name = "index.reindexAfterRefUpdate", value = "true")
+  public void submitSchedulesOpenChangesOfSameBranchForReindexing() throws Throwable {
+    // Create a merged change.
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Merged Change", "foo.txt", "foo");
+    PushOneCommit.Result mergedChange = push.to("refs/for/master");
+    mergedChange.assertOkStatus();
+    approve(mergedChange.getChangeId());
+    submit(mergedChange.getChangeId());
+
+    // Create some open changes.
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+    PushOneCommit.Result change3 = createChange();
+
+    // Create a branch with one open change.
+    BranchInput in = new BranchInput();
+    in.revision = projectOperations.project(project).getHead("master").name();
+    gApi.projects().name(project.get()).branch("dev").create(in);
+    PushOneCommit.Result changeOtherBranch = createChange("refs/for/dev");
+
+    ChangeIndexedListener changeIndexedListener = mock(ChangeIndexedListener.class);
+    RegistrationHandle registrationHandle =
+        changeIndexedListeners.add("gerrit", changeIndexedListener);
+    try {
+      // submit a change, this should trigger asynchronous reindexing of the open changes on the
+      // same branch
+      approve(change1.getChangeId());
+      submit(change1.getChangeId());
+      assertThat(gApi.changes().id(change1.getChangeId()).get().status)
+          .isEqualTo(ChangeStatus.MERGED);
+
+      // on submit the change that is submitted gets reindexed synchronously
+      verify(changeIndexedListener, atLeast(1))
+          .onChangeScheduledForIndexing(project.get(), change1.getChange().getId().get());
+      verify(changeIndexedListener, atLeast(1))
+          .onChangeIndexed(project.get(), change1.getChange().getId().get());
+
+      // the open changes on the same branch get reindexed asynchronously
+      verify(changeIndexedListener, times(1))
+          .onChangeScheduledForIndexing(project.get(), change2.getChange().getId().get());
+      verify(changeIndexedListener, times(1))
+          .onChangeScheduledForIndexing(project.get(), change3.getChange().getId().get());
+
+      // merged changes don't get reindexed
+      verify(changeIndexedListener, times(0))
+          .onChangeScheduledForIndexing(project.get(), mergedChange.getChange().getId().get());
+
+      // open changes on other branches don't get reindexed
+      verify(changeIndexedListener, times(0))
+          .onChangeScheduledForIndexing(project.get(), changeOtherBranch.getChange().getId().get());
+    } finally {
+      registrationHandle.remove();
+    }
+  }
+
   private void assertSubmitter(PushOneCommit.Result change) throws Throwable {
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index 8ddfa45..a55f17e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -15,6 +15,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
@@ -362,8 +363,8 @@
             parseCommitMessageRange(commitBefore);
         Optional<ChangeNoteUtil.CommitMessageRange> rangeAfter =
             parseCommitMessageRange(commitAfter);
-        assertThat(rangeBefore.isPresent()).isTrue();
-        assertThat(rangeAfter.isPresent()).isTrue();
+        assertThat(rangeBefore).isPresent();
+        assertThat(rangeAfter).isPresent();
 
         String subjectBefore =
             decode(
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 78ad120..dbd7aaa 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -909,7 +909,7 @@
     // PS2 has comments [c6, c9].
     assertThat(getRevisionComments(changeId, ps2)).hasSize(2);
     // PS3 has no comment.
-    assertThat(getRevisionComments(changeId, ps3)).hasSize(0);
+    assertThat(getRevisionComments(changeId, ps3)).isEmpty();
     // PS4 has comments [c7, c8].
     assertThat(getRevisionComments(changeId, ps4)).hasSize(2);
 
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 804462e..8648ee8 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -602,7 +602,7 @@
       if (notify != null) {
         in.notify = notify;
       }
-      gApi.changes().id(changeId).revision("current").review(in);
+      gApi.changes().id(changeId).current().review(in);
     };
   }
 
@@ -861,7 +861,7 @@
   public void noCommentAndSetWorkInProgress() throws Exception {
     StagedChange sc = stageReviewableChange();
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
+    gApi.changes().id(sc.changeId).current().review(in);
     assertThat(sender).didNotSend();
   }
 
@@ -869,7 +869,7 @@
   public void commentAndSetWorkInProgress() throws Exception {
     StagedChange sc = stageReviewableChange();
     ReviewInput in = ReviewInput.noScore().message("ok").setWorkInProgress(true);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
+    gApi.changes().id(sc.changeId).current().review(in);
     assertThat(sender)
         .sent("comment", sc)
         .cc(sc.reviewer, sc.ccer)
@@ -884,7 +884,7 @@
   public void commentOnWipChangeAndStartReview() throws Exception {
     StagedChange sc = stageWipChange();
     ReviewInput in = ReviewInput.noScore().message("ok").setWorkInProgress(false);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
+    gApi.changes().id(sc.changeId).current().review(in);
     assertThat(sender)
         .sent("comment", sc)
         .cc(sc.reviewer, sc.ccer)
@@ -899,7 +899,7 @@
   public void addReviewerOnWipChangeAndStartReview() throws Exception {
     StagedChange sc = stageWipChange();
     ReviewInput in = ReviewInput.noScore().reviewer(other.email()).setWorkInProgress(false);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
+    gApi.changes().id(sc.changeId).current().review(in);
     assertThat(sender)
         .sent("comment", sc)
         .cc(sc.reviewer, sc.ccer, other)
@@ -923,7 +923,7 @@
     StagedChange sc = stageWipChange();
     ReviewInput in =
         ReviewInput.noScore().message(PostReview.START_REVIEW_MESSAGE).setWorkInProgress(false);
-    gApi.changes().id(sc.changeId).revision("current").review(in);
+    gApi.changes().id(sc.changeId).current().review(in);
     Truth.assertThat(sender.getMessages()).isNotEmpty();
     String body = sender.getMessages().get(0).body();
     int idx = body.indexOf(PostReview.START_REVIEW_MESSAGE);
@@ -953,7 +953,7 @@
     ReviewInput in = ReviewInput.recommend();
     in.notify = notify;
     in.tag = tag;
-    gApi.changes().id(changeId).revision("current").review(in);
+    gApi.changes().id(changeId).current().review(in);
   }
 
   /*
@@ -1256,7 +1256,7 @@
 
   private void recommend(StagedChange sc, TestAccount by) throws Exception {
     requestScopeOperations.setApiUser(by.id());
-    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.recommend());
+    gApi.changes().id(sc.changeId).current().review(ReviewInput.recommend());
   }
 
   private interface Stager {
@@ -1270,7 +1270,7 @@
             .reviewer(extraReviewer.email())
             .reviewer(extraCcer.email(), ReviewerState.CC, false);
     requestScopeOperations.setApiUser(extraReviewer.id());
-    gApi.changes().id(sc.changeId).revision("current").review(in);
+    gApi.changes().id(sc.changeId).current().review(in);
     sender.clear();
     return sc;
   }
@@ -1630,7 +1630,7 @@
       throws Exception {
     setEmailStrategy(by, emailStrategy);
     requestScopeOperations.setApiUser(by.id());
-    gApi.changes().id(changeId).revision("current").submit();
+    gApi.changes().id(changeId).current().submit();
   }
 
   private void merge(String changeId, TestAccount by, NotifyHandling notify) throws Exception {
@@ -1644,13 +1644,13 @@
     requestScopeOperations.setApiUser(by.id());
     SubmitInput in = new SubmitInput();
     in.notify = notify;
-    gApi.changes().id(changeId).revision("current").submit(in);
+    gApi.changes().id(changeId).current().submit(in);
   }
 
   private StagedChange stageChangeReadyForMerge() throws Exception {
     StagedChange sc = stageReviewableChange();
     requestScopeOperations.setApiUser(sc.reviewer.id());
-    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.approve());
+    gApi.changes().id(sc.changeId).current().review(ReviewInput.approve());
     sender.clear();
     return sc;
   }
@@ -2043,7 +2043,7 @@
       StagedChange sc, TestAccount by, @Nullable NotifyHandling notify, EmailStrategy emailStrategy)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    CommitInfo commit = gApi.changes().id(sc.changeId).revision("current").commit(false);
+    CommitInfo commit = gApi.changes().id(sc.changeId).current().commit(false);
     CommitMessageInput in = new CommitMessageInput();
     in.message = "update\n" + commit.message;
     in.notify = notify;
@@ -2258,8 +2258,8 @@
   private StagedChange stageChange() throws Exception {
     StagedChange sc = stageReviewableChange();
     requestScopeOperations.setApiUser(admin.id());
-    gApi.changes().id(sc.changeId).revision("current").review(ReviewInput.approve());
-    gApi.changes().id(sc.changeId).revision("current").submit();
+    gApi.changes().id(sc.changeId).current().review(ReviewInput.approve());
+    gApi.changes().id(sc.changeId).current().submit();
     sender.clear();
     return sc;
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index 6e14635..5531709 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -15,6 +15,10 @@
 package com.google.gerrit.acceptance.server.mail;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -37,16 +41,17 @@
 import java.time.ZonedDateTime;
 import java.util.Collection;
 import java.util.List;
-import org.easymock.EasyMock;
 import org.junit.Before;
+import org.junit.BeforeClass;
 import org.junit.Test;
 
 public class MailProcessorIT extends AbstractMailIT {
   @Inject private MailProcessor mailProcessor;
   @Inject private AccountOperations accountOperations;
-  @Inject private CommentValidator mockCommentValidator;
   @Inject private TestCommentHelper testCommentHelper;
 
+  private static final CommentValidator mockCommentValidator = mock(CommentValidator.class);
+
   private static final String COMMENT_TEXT = "The comment text";
 
   @Override
@@ -54,7 +59,6 @@
     return new FactoryModule() {
       @Override
       public void configure() {
-        CommentValidator mockCommentValidator = EasyMock.createMock(CommentValidator.class);
         bind(CommentValidator.class)
             .annotatedWith(Exports.named(mockCommentValidator.getClass()))
             .toInstance(mockCommentValidator);
@@ -63,13 +67,15 @@
     };
   }
 
+  @BeforeClass
+  public static void setUpMock() {
+    // Let the mock comment validator accept all comments during test setup.
+    when(mockCommentValidator.validateComments(any())).thenReturn(ImmutableList.of());
+  }
+
   @Before
   public void setUp() {
-    // Let the mock comment validator accept all comments during test setup.
-    EasyMock.reset(mockCommentValidator);
-    EasyMock.expect(mockCommentValidator.validateComments(EasyMock.anyObject()))
-        .andReturn(ImmutableList.of());
-    EasyMock.replay(mockCommentValidator);
+    clearInvocations(mockCommentValidator);
   }
 
   @Test
@@ -268,16 +274,7 @@
         MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
-    EasyMock.reset(mockCommentValidator);
-    CommentForValidation commentForValidation =
-        CommentForValidation.create(CommentForValidation.CommentType.CHANGE_MESSAGE, COMMENT_TEXT);
-    EasyMock.expect(
-            mockCommentValidator.validateComments(
-                ImmutableList.of(
-                    CommentForValidation.create(
-                        CommentForValidation.CommentType.CHANGE_MESSAGE, COMMENT_TEXT))))
-        .andReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
-    EasyMock.replay(mockCommentValidator);
+    setupFailValidation(CommentForValidation.CommentType.CHANGE_MESSAGE);
 
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", COMMENT_TEXT, null, null, null);
@@ -290,7 +287,6 @@
     assertNotifyTo(user);
     Message message = sender.nextMessage();
     assertThat(message.body()).contains("rejected one or more comments");
-    EasyMock.verify(mockCommentValidator);
   }
 
   @Test
@@ -302,16 +298,7 @@
         MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
-    EasyMock.reset(mockCommentValidator);
-    CommentForValidation commentForValidation =
-        CommentForValidation.create(CommentForValidation.CommentType.CHANGE_MESSAGE, COMMENT_TEXT);
-    EasyMock.expect(
-            mockCommentValidator.validateComments(
-                ImmutableList.of(
-                    CommentForValidation.create(
-                        CommentForValidation.CommentType.INLINE_COMMENT, COMMENT_TEXT))))
-        .andReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
-    EasyMock.replay(mockCommentValidator);
+    setupFailValidation(CommentForValidation.CommentType.INLINE_COMMENT);
 
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, COMMENT_TEXT, null, null);
@@ -324,7 +311,6 @@
     assertNotifyTo(user);
     Message message = sender.nextMessage();
     assertThat(message.body()).contains("rejected one or more comments");
-    EasyMock.verify(mockCommentValidator);
   }
 
   @Test
@@ -336,16 +322,7 @@
         MailProcessingUtil.rfcDateformatter.format(
             ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
 
-    EasyMock.reset(mockCommentValidator);
-    CommentForValidation commentForValidation =
-        CommentForValidation.create(CommentForValidation.CommentType.CHANGE_MESSAGE, COMMENT_TEXT);
-    EasyMock.expect(
-            mockCommentValidator.validateComments(
-                ImmutableList.of(
-                    CommentForValidation.create(
-                        CommentForValidation.CommentType.FILE_COMMENT, COMMENT_TEXT))))
-        .andReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
-    EasyMock.replay(mockCommentValidator);
+    setupFailValidation(CommentForValidation.CommentType.FILE_COMMENT);
 
     MailMessage.Builder b = messageBuilderWithDefaultFields();
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, null, COMMENT_TEXT, null);
@@ -358,10 +335,17 @@
     assertNotifyTo(user);
     Message message = sender.nextMessage();
     assertThat(message.body()).contains("rejected one or more comments");
-    EasyMock.verify(mockCommentValidator);
   }
 
   private String getChangeUrl(ChangeInfo changeInfo) {
     return canonicalWebUrl.get() + "c/" + changeInfo.project + "/+/" + changeInfo._number;
   }
+
+  private void setupFailValidation(CommentForValidation.CommentType type) {
+    CommentForValidation commentForValidation = CommentForValidation.create(type, COMMENT_TEXT);
+
+    when(mockCommentValidator.validateComments(
+            ImmutableList.of(CommentForValidation.create(type, COMMENT_TEXT))))
+        .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
index 4f47927..0ad2010 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.server.quota;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.easymock.EasyMock.expect;
 import static org.easymock.EasyMock.expectLastCall;
@@ -104,7 +105,7 @@
         assertThrows(
             NullPointerException.class,
             () -> quotaBackend.user(identifiedAdmin).requestToken("testGroup"));
-    assertThat(exception).isEqualTo(thrown);
+    assertThat(thrown).isEqualTo(exception);
 
     verify(quotaEnforcerA);
   }
@@ -136,7 +137,7 @@
 
     OptionalLong tokens =
         quotaBackend.user(identifiedAdmin).availableTokens("testGroup").availableTokens();
-    assertThat(tokens.isPresent()).isTrue();
+    assertThat(tokens).isPresent();
     assertThat(tokens.getAsLong()).isEqualTo(10L);
   }
 
@@ -151,7 +152,7 @@
 
     OptionalLong tokens =
         quotaBackend.user(identifiedAdmin).availableTokens("testGroup").availableTokens();
-    assertThat(tokens.isPresent()).isTrue();
+    assertThat(tokens).isPresent();
     assertThat(tokens.getAsLong()).isEqualTo(20L);
   }
 }
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index 7a1cf51..ee75153 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -15,6 +15,9 @@
 package com.google.gerrit.server.extensions.webui;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.only;
+import static org.mockito.Mockito.verify;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -35,7 +38,6 @@
 import java.util.Collection;
 import java.util.Map;
 import java.util.Set;
-import org.easymock.EasyMock;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
@@ -131,9 +133,7 @@
 
     // Set up the Mock to expect a call of bulkEvaluateTest to only contain cond{1,2} since cond3
     // needs to be identified as duplicate and not called out explicitly.
-    PermissionBackend permissionBackendMock = EasyMock.createMock(PermissionBackend.class);
-    permissionBackendMock.bulkEvaluateTest(ImmutableSet.of(cond1, cond2));
-    EasyMock.replay(permissionBackendMock);
+    PermissionBackend permissionBackendMock = mock(PermissionBackend.class);
 
     UiActions.evaluatePermissionBackendConditions(
         permissionBackendMock, ImmutableList.of(cond1, cond2, cond3));
@@ -142,6 +142,8 @@
     // the value of cond1 and issues no additional call to PermissionBackend.
     forProject.disallowValueQueries();
 
+    verify(permissionBackendMock, only()).bulkEvaluateTest(ImmutableSet.of(cond1, cond2));
+
     // Assert the values of all conditions
     assertThat(cond1.value()).isFalse();
     assertThat(cond2.value()).isTrue();
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index 060079f..a5b755e 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -53,7 +53,7 @@
   @Test
   public void createGroupAsServerIdent() throws Exception {
     InternalGroup group = createGroup(1, "test-group", serverIdent, null);
-    assertThat(auditLogReader.getMembersAudit(allUsersRepo, group.getGroupUUID())).hasSize(0);
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, group.getGroupUUID())).isEmpty();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/project/GroupListTest.java b/javatests/com/google/gerrit/server/project/GroupListTest.java
index 5ccefa0..7d6a2c3 100644
--- a/javatests/com/google/gerrit/server/project/GroupListTest.java
+++ b/javatests/com/google/gerrit/server/project/GroupListTest.java
@@ -14,16 +14,14 @@
 
 package com.google.gerrit.server.project;
 
-import static org.easymock.EasyMock.anyObject;
-import static org.easymock.EasyMock.createMock;
-import static org.easymock.EasyMock.createNiceMock;
-import static org.easymock.EasyMock.expectLastCall;
-import static org.easymock.EasyMock.replay;
-import static org.easymock.EasyMock.verify;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -48,8 +46,7 @@
 
   @Before
   public void setup() throws IOException {
-    ValidationError.Sink sink = createNiceMock(ValidationError.Sink.class);
-    replay(sink);
+    ValidationError.Sink sink = mock(ValidationError.Sink.class);
     groupList = GroupList.parse(PROJECT, TEXT, sink);
   }
 
@@ -97,12 +94,9 @@
 
   @Test
   public void validationError() throws Exception {
-    ValidationError.Sink sink = createMock(ValidationError.Sink.class);
-    sink.error(anyObject(ValidationError.class));
-    expectLastCall().times(2);
-    replay(sink);
+    ValidationError.Sink sink = mock(ValidationError.Sink.class);
     groupList = GroupList.parse(PROJECT, TEXT.replace("\t", "    "), sink);
-    verify(sink);
+    verify(sink, times(2)).error(any(ValidationError.class));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 41230f1..20f3bb9 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -565,7 +565,7 @@
             .reviewer(user2.toString(), ReviewerState.CC, false)
             .reviewer(email1)
             .reviewer(email2, ReviewerState.CC, false);
-    gApi.changes().id(change1.getId().get()).revision("current").review(in);
+    gApi.changes().id(change1.getId().get()).current().review(in);
 
     List<ChangeInfo> changeInfos =
         assertQuery(newQuery("is:wip").withOption(DETAILED_LABELS), change1);
@@ -2112,8 +2112,14 @@
     assertQuery("conflicts:" + change2.getId().get(), change1);
     assertQuery("is:mergeable", change2, change1);
 
-    gApi.changes().id(change1.getChangeId()).revision("current").review(ReviewInput.approve());
-    gApi.changes().id(change1.getChangeId()).revision("current").submit();
+    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(change1.getChangeId()).current().submit();
+
+    // If a change gets submitted, the remaining open changes get reindexed asynchronously to update
+    // their mergeability information. If the further assertions in this test are done before the
+    // asynchronous reindex completed they fail because the mergeability information in the index
+    // was not updated yet. To avoid this flakiness we index change2 synchronously here.
+    gApi.changes().id(change2.getChangeId()).index();
 
     assertQuery("status:open conflicts:" + change2.getId().get());
     assertQuery("status:open is:mergeable");
@@ -2182,7 +2188,7 @@
 
     assertQuery("is:reviewer");
     assertQuery("reviewer:self");
-    gApi.changes().id(change3.getChangeId()).revision("current").review(ReviewInput.recommend());
+    gApi.changes().id(change3.getChangeId()).current().review(ReviewInput.recommend());
     assertQuery("is:reviewer", change3);
     assertQuery("reviewer:self", change3);
 
@@ -3073,8 +3079,8 @@
 
     assertQuery("query:query1", change2, change1);
     assertQuery("query:query2", change2, change1);
-    gApi.changes().id(change1.getChangeId()).revision("current").review(ReviewInput.approve());
-    gApi.changes().id(change1.getChangeId()).revision("current").submit();
+    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(change1.getChangeId()).current().submit();
     assertQuery("query:query2", change2);
     assertQuery("query:query3", change2);
     assertQuery("query:query4");
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 62d9a79..2545431 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -15,6 +15,7 @@
         "//lib:guava",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/mockito",
         "//lib/prolog:runtime",
         "//lib/truth",
         "//prolog:gerrit-prolog-common",
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
index be0b8e7..9d7afbc 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
@@ -16,7 +16,8 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static org.easymock.EasyMock.expect;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.server.project.testing.TestLabels;
@@ -31,7 +32,6 @@
 import java.io.PushbackReader;
 import java.io.StringReader;
 import java.util.Arrays;
-import org.easymock.EasyMock;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 import org.junit.Test;
@@ -60,9 +60,8 @@
   protected void setUpEnvironment(PrologEnvironment env) throws Exception {
     LabelTypes labelTypes =
         new LabelTypes(Arrays.asList(TestLabels.codeReview(), TestLabels.verified()));
-    ChangeData cd = EasyMock.createMock(ChangeData.class);
-    expect(cd.getLabelTypes()).andStubReturn(labelTypes);
-    EasyMock.replay(cd);
+    ChangeData cd = mock(ChangeData.class);
+    when(cd.getLabelTypes()).thenReturn(labelTypes);
     env.set(StoredValues.CHANGE_DATA, cd);
   }
 
diff --git a/plugins/delete-project b/plugins/delete-project
index 3a4b095..757afad 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 3a4b0955529588dbc071bc43164c468b29d79129
+Subproject commit 757afad54a1fdfd52b10dce8a98ecde3794afe03
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index 69343c6..7dad316 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit 69343c65a66d752c3a41788c191a38fc64cc2a32
+Subproject commit 7dad3163ae91b1f28cdbeec87edca90441fa21b1
diff --git a/plugins/replication b/plugins/replication
index c65db46..5a3519e 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit c65db46ef4114df13accacd20fea61788eec01f1
+Subproject commit 5a3519e6e1733e2515900866b8db9ca98ba9da7e
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 6962121..a019388 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
@@ -83,16 +83,11 @@
   };
 
   GrDiffBuilder.ContextButtonType = {
-    STEP: 'step',
+    ABOVE: 'above',
+    BELOW: 'below',
     ALL: 'all',
   };
 
-  const ContextPlacement = {
-    LAST: 'last',
-    FIRST: 'first',
-    MIDDLE: 'middle',
-  };
-
   const PARTIAL_CONTEXT_AMOUNT = 10;
 
   /**
@@ -236,53 +231,40 @@
         group => { return group.element; });
   };
 
-  /**
-   * @param {!Array<!Object>} contextGroups (!Array<!GrDiffGroup>)
-   */
-  GrDiffBuilder.prototype._getContextControlPlacementFor = function(
-      contextGroups) {
-    const firstContextGroup = contextGroups[0];
-    const lastContextGroup = contextGroups[contextGroups.length - 1];
-    const firstLines = firstContextGroup.lines;
-    const lastLines = lastContextGroup.lines;
-    const firstContextInFile = firstLines.length && firstLines[0].firstInFile;
-    const lastContextInFile = lastLines.length &&
-        lastLines[lastLines.length - 1].lastInFile;
-    if (firstContextInFile && !lastContextInFile) {
-      return ContextPlacement.FIRST;
-    } else if (lastContextInFile && !firstContextInFile) {
-      return ContextPlacement.LAST;
-    }
-    return ContextPlacement.MIDDLE;
-  };
-
   GrDiffBuilder.prototype._createContextControl = function(section, line) {
-    const contextGroups = line.contextGroups;
-    if (!contextGroups) return null;
-    const numLines = contextGroups[contextGroups.length - 1].lineRange.left.end
-                    - contextGroups[0].lineRange.left.start + 1;
+    if (!line.contextGroups) return null;
+
+    const numLines =
+        line.contextGroups[line.contextGroups.length - 1].lineRange.left.end -
+        line.contextGroups[0].lineRange.left.start + 1;
+
     if (numLines === 0) return null;
 
-    const contextPlacement = this._getContextControlPlacementFor(contextGroups);
-    const isStepBidirectional = (contextPlacement === ContextPlacement.MIDDLE);
-    const minimalForStepExpansion = isStepBidirectional ?
-        PARTIAL_CONTEXT_AMOUNT * 2 : PARTIAL_CONTEXT_AMOUNT;
-    const showStepExpansion = numLines > minimalForStepExpansion;
-
     const td = this._createElement('td');
-    if (showStepExpansion) {
+    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
+
+    if (showPartialLinks) {
       td.appendChild(this._createContextButton(
-          GrDiffBuilder.ContextButtonType.STEP, section, line,
-          numLines, contextPlacement));
+          GrDiffBuilder.ContextButtonType.ABOVE, section, line, numLines));
+      td.appendChild(document.createTextNode(' - '));
     }
+
     td.appendChild(this._createContextButton(
         GrDiffBuilder.ContextButtonType.ALL, section, line, numLines));
 
+    if (showPartialLinks) {
+      td.appendChild(document.createTextNode(' - '));
+      td.appendChild(this._createContextButton(
+          GrDiffBuilder.ContextButtonType.BELOW, section, line, numLines));
+    }
+
     return td;
   };
 
   GrDiffBuilder.prototype._createContextButton = function(type, section, line,
-      numLines, contextPlacement) {
+      numLines) {
+    const context = PARTIAL_CONTEXT_AMOUNT;
+
     const button = this._createElement('gr-button', 'showContext');
     button.setAttribute('link', true);
     button.setAttribute('no-uppercase', true);
@@ -294,14 +276,14 @@
       text = 'Show ' + numLines + ' common line';
       if (numLines > 1) { text += 's'; }
       groups.push(...line.contextGroups);
-    } else if (type === GrDiffBuilder.ContextButtonType.STEP) {
-      const linesToShowAbove = contextPlacement === ContextPlacement.FIRST ?
-          0 : PARTIAL_CONTEXT_AMOUNT;
-      const linesToShowBelow = contextPlacement === ContextPlacement.LAST ?
-          0 : PARTIAL_CONTEXT_AMOUNT;
-      text = '+' + PARTIAL_CONTEXT_AMOUNT + ' Lines';
-      groups = GrDiffGroup.hideInContextControl(
-          line.contextGroups, linesToShowAbove, numLines - linesToShowBelow);
+    } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
+      text = '+' + context + '↑';
+      groups = GrDiffGroup.hideInContextControl(line.contextGroups,
+          context, numLines);
+    } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
+      text = '+' + context + '↓';
+      groups = GrDiffGroup.hideInContextControl(line.contextGroups,
+          0, numLines - context);
     }
 
     Polymer.dom(button).textContent = text;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index 49fdc06..b917845 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -89,92 +89,44 @@
       assert.isTrue(node.classList.contains('classes'));
     });
 
-    function assertContextControl(buttons, expectedButtons) {
-      const actualButtonLabels = buttons.map(
-          b => Polymer.dom(b).textContent);
-      assert.deepEqual(actualButtonLabels, expectedButtons);
-    }
-
-    function createContextControl({numLines,
-                    firstInFile, lastInFile,
-                    expectStepExpansion}) {
+    test('context control buttons', () => {
+      // Create 10 lines.
       const lines = [];
-      for (let i = 0; i < numLines; i++) {
+      for (let i = 0; i < 10; i++) {
         const line = new GrDiffLine(GrDiffLine.Type.BOTH);
         line.beforeNumber = i + 1;
         line.afterNumber = i + 1;
         line.text = 'lorem upsum';
         lines.push(line);
       }
-      lines[0].firstInFile = !!firstInFile;
-      lines[lines.length - 1].lastInFile = !!lastInFile;
+
       const contextLine = {
         contextGroups: [new GrDiffGroup(GrDiffGroup.Type.BOTH, lines)],
       };
+
       const section = {};
-      const td = builder._createContextControl(section, contextLine);
-      return [...td.querySelectorAll('gr-button.showContext')];
-    }
-
-    function getGroupsAfterExpanding(button) {
-      let newGroups;
-      button.addEventListener('tap', e => { newGroups = e.detail.groups; });
-      MockInteractions.tap(button);
-      return newGroups;
-    }
-
-    test('context control buttons in the middle of the file', () => {
-      // Does not include +10 buttons when there are fewer than 21 lines.
-      let buttons = createContextControl({numLines: 20});
-      assertContextControl(buttons, ['Show 20 common lines']);
-
-      // Includes +10 buttons when there are at least 21 lines.
-      buttons = createContextControl({numLines: 21});
-      assertContextControl(buttons, ['+10 Lines', 'Show 21 common lines']);
-
-      // When clicked with 22 Lines expands in both directions with remaining context in the middle.
-
-      buttons = createContextControl({numLines: 22});
-      assertContextControl(buttons, ['+10 Lines', 'Show 22 common lines']);
-      const newGroupTypes = getGroupsAfterExpanding(buttons[0]).map(g => g.type);
-      assert.deepEqual(newGroupTypes,
-          [GrDiffLine.Type.BOTH, GrDiffLine.Type.CONTEXT_CONTROL,
-            GrDiffLine.Type.BOTH]);
-    });
-  
-    test('context control buttons in the beginning of the file', () => {
       // Does not include +10 buttons when there are fewer than 11 lines.
-      let buttons = createContextControl({numLines: 10, firstInFile: true});
-      assertContextControl(buttons, ['Show 10 common lines']);
+      let td = builder._createContextControl(section, contextLine);
+      let buttons = td.querySelectorAll('gr-button.showContext');
 
-      // Includes +10 button when there are at least 11 lines.
-      buttons = createContextControl({numLines: 11, firstInFile: true});
-      assertContextControl(buttons, ['+10 Lines', 'Show 11 common lines']);
+      assert.equal(buttons.length, 1);
+      assert.equal(Polymer.dom(buttons[0]).textContent, 'Show 10 common lines');
 
-      // When clicked with 12 Lines expands only up and remaining context in the beginning of the file.
-      buttons = createContextControl({numLines: 12, firstInFile: true});
-      assertContextControl(buttons, ['+10 Lines', 'Show 12 common lines']);
-      const newGroupTypes = getGroupsAfterExpanding(buttons[0]).map(g => g.type);
-      assert.deepEqual(newGroupTypes,
-          [GrDiffLine.Type.CONTEXT_CONTROL, GrDiffLine.Type.BOTH]);
-    });
+      // Add another line.
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.text = 'lorem upsum';
+      line.beforeNumber = 11;
+      line.afterNumber = 11;
+      contextLine.contextGroups[0].addLine(line);
 
-    test('context control buttons in the end of the file', () => {
-      // Does not include +10 buttons when there are fewer than 11 lines.
-      let buttons = createContextControl({numLines: 10, lastInFile: true});
-      assertContextControl(buttons, ['Show 10 common lines']);
+      // Includes +10 buttons when there are at least 11 lines.
+      td = builder._createContextControl(section, contextLine);
+      buttons = td.querySelectorAll('gr-button.showContext');
 
-      // Includes +10 button when there are at least 11 lines.
-      buttons = createContextControl({numLines: 11, lastInFile: true});
-      assertContextControl(buttons, ['+10 Lines', 'Show 11 common lines']);
-
-      // When clicked with 12 Lines expands only up and remaining context in the beginning of the file.
-      buttons = createContextControl({numLines: 12, lastInFile: true});
-      assertContextControl(buttons, ['+10 Lines', 'Show 12 common lines']);
-      // When clicked with 12 Lines expands only down and remaining context in the end of the file.
-      const newGroupTypes = getGroupsAfterExpanding(buttons[0]).map(g => g.type);
-      assert.deepEqual(newGroupTypes, [GrDiffLine.Type.BOTH,
-        GrDiffLine.Type.CONTEXT_CONTROL]);
+      assert.equal(buttons.length, 3);
+      assert.equal(Polymer.dom(buttons[0]).textContent, '+10↑');
+      assert.equal(Polymer.dom(buttons[1]).textContent, 'Show 11 common lines');
+      assert.equal(Polymer.dom(buttons[2]).textContent, '+10↓');
     });
 
     test('newlines 1', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index d1c58b2..d4b4e2b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -66,6 +66,7 @@
     ADDED: 'edit_b',
     REMOVED: 'edit_a',
   };
+
   /**
    * The maximum size for an addition or removal chunk before it is broken down
    * into a series of chunks that are this size at most.
@@ -268,8 +269,7 @@
             right: this._linesRight(chunk).length,
           },
           groups: [this._chunkToGroup(
-              chunk, state.lineNums.left + 1, state.lineNums.right + 1,
-              /* firstInFile */ false, /* lastInFile */ false)],
+              chunk, state.lineNums.left + 1, state.lineNums.right + 1)],
           newChunkIndex: state.chunkIndex + 1,
         };
       }
@@ -321,13 +321,10 @@
       const lineCount = collapsibleChunks.reduce(
           (sum, chunk) => sum + this._commonChunkLength(chunk), 0);
 
-      const firstChunk = state.chunkIndex === 0;
-      const lastChunk = state.chunkIndex === chunks.length - 1;
       let groups = this._chunksToGroups(
           collapsibleChunks,
           state.lineNums.left + 1,
-          state.lineNums.right + 1,
-          firstChunk, lastChunk);
+          state.lineNums.right + 1);
 
       if (this.context !== WHOLE_FILE) {
         const hiddenStart = state.chunkIndex === 0 ? 0 : this.context;
@@ -360,17 +357,11 @@
      * @param {!Array<!Object>} chunks
      * @param {number} offsetLeft
      * @param {number} offsetRight
-     * @param {boolean} firstProcessed
-     * @param {boolean} lastProcessed
      * @return {!Array<!Object>} (GrDiffGroup)
      */
-    _chunksToGroups(chunks, offsetLeft, offsetRight, firstProcessed,
-        lastProcessed) {
-      return chunks.map((chunk, index) => {
-        const firstInFile = firstProcessed && index === 0;
-        const lastInFile = lastProcessed && index === chunks.length - 1;
-        const group = this._chunkToGroup(chunk, offsetLeft,
-            offsetRight, firstInFile, lastInFile);
+    _chunksToGroups(chunks, offsetLeft, offsetRight) {
+      return chunks.map(chunk => {
+        const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
         const chunkLength = this._commonChunkLength(chunk);
         offsetLeft += chunkLength;
         offsetRight += chunkLength;
@@ -384,10 +375,9 @@
      * @param {number} offsetRight
      * @return {!Object} (GrDiffGroup)
      */
-    _chunkToGroup(chunk, offsetLeft, offsetRight, firstInFile, lastInFile) {
+    _chunkToGroup(chunk, offsetLeft, offsetRight) {
       const type = chunk.ab ? GrDiffGroup.Type.BOTH : GrDiffGroup.Type.DELTA;
-      const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight,
-          firstInFile, lastInFile);
+      const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
       const group = new GrDiffGroup(type, lines);
       group.keyLocation = chunk.keyLocation;
       group.dueToRebase = chunk.due_to_rebase;
@@ -395,30 +385,25 @@
       return group;
     },
 
-    _linesFromChunk(chunk, offsetLeft, offsetRight, firstInFile, lastInFile) {
-      let lines = [];
+    _linesFromChunk(chunk, offsetLeft, offsetRight) {
       if (chunk.ab) {
-        lines = chunk.ab.map((row, i) => this._lineFromRow(
+        return chunk.ab.map((row, i) => this._lineFromRow(
             GrDiffLine.Type.BOTH, offsetLeft, offsetRight, row, i));
-      } else {
-        if (chunk.a) {
-          // Avoiding a.push(...b) because that causes callstack overflows for
-          // large b, which can occur when large files are added removed.
-          lines = lines.concat(this._linesFromRows(
-              GrDiffLine.Type.REMOVE, chunk.a, offsetLeft,
-              chunk[DiffHighlights.REMOVED]));
-        }
-        if (chunk.b) {
-          // Avoiding a.push(...b) because that causes callstack overflows for
-          // large b, which can occur when large files are added removed.
-          lines = lines.concat(this._linesFromRows(
-              GrDiffLine.Type.ADD, chunk.b, offsetRight,
-              chunk[DiffHighlights.ADDED]));
-        }
       }
-      if (lines.length > 0) {
-        lines[0].firstInFile = firstInFile;
-        lines[lines.length - 1].lastInFile = lastInFile;
+      let lines = [];
+      if (chunk.a) {
+        // Avoiding a.push(...b) because that causes callstack overflows for
+        // large b, which can occur when large files are added removed.
+        lines = lines.concat(this._linesFromRows(
+            GrDiffLine.Type.REMOVE, chunk.a, offsetLeft,
+            chunk[DiffHighlights.REMOVED]));
+      }
+      if (chunk.b) {
+        // Avoiding a.push(...b) because that causes callstack overflows for
+        // large b, which can occur when large files are added removed.
+        lines = lines.concat(this._linesFromRows(
+            GrDiffLine.Type.ADD, chunk.b, offsetRight,
+            chunk[DiffHighlights.ADDED]));
       }
       return lines;
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
index 49d583f..b64385d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
@@ -44,12 +44,6 @@
     this.contextGroups = null;
 
     this.text = '';
-
-    /** @type {boolean} */
-    this.firstInFile = false;
-
-    /** @type {boolean} */
-    this.lastInFile = false;
   }
 
   GrDiffLine.Type = {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index a9755ce..bc8af9d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -187,8 +187,6 @@
       }
       .contextControl gr-button {
         display: inline-block;
-        margin-left: 1em;
-        margin-right: 1em;
         text-decoration: none;
         --gr-button: {
           color: var(--diff-context-control-color);