Merge "Always use new author timestamp for super repo commit"
diff --git a/Documentation/cmd-index-project.txt b/Documentation/cmd-index-changes-in-project.txt
similarity index 60%
rename from Documentation/cmd-index-project.txt
rename to Documentation/cmd-index-changes-in-project.txt
index 2196a26..ec1626f 100644
--- a/Documentation/cmd-index-project.txt
+++ b/Documentation/cmd-index-changes-in-project.txt
@@ -1,12 +1,12 @@
-= gerrit index project
+= gerrit index changes in project
 
 == NAME
-gerrit index project - Index all the changes in one or more projects.
+gerrit index changes in project - Index all the changes in one or more projects.
 
 == SYNOPSIS
 [verse]
 --
-_ssh_ -p <port> <host> _gerrit index project_ <PROJECT> [<PROJECT> ...]
+_ssh_ -p <port> <host> _gerrit index changes-in-project_ <PROJECT> [<PROJECT> ...]
 --
 
 == DESCRIPTION
@@ -26,7 +26,7 @@
 Index all changes in projects MyProject and NiceProject.
 
 ----
-    $ ssh -p 29418 user@review.example.com gerrit index project MyProject NiceProject
+    $ ssh -p 29418 user@review.example.com gerrit index changes-in-project MyProject NiceProject
 ----
 
 GERRIT
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index f535281..2362401 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -136,7 +136,7 @@
 link:cmd-index-changes.html[gerrit index changes]::
 	Index one or more changes.
 
-link:cmd-index-project.html[gerrit index project]::
+link:cmd-index-changes-in-project.html[gerrit index changes-in-project]::
 	Index all the changes in one or more projects.
 
 link:cmd-logging-ls-level.html[gerrit logging ls-level]::
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 2737f58..71441d83 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2779,16 +2779,16 @@
 it to 0 disables the dedicated thread pool and indexing will be done in the same
 thread as the operation.
 +
-If not set or set to a negative value, defaults to 1 plus half of the number of
-logical CPUs as returned by the JVM.
+If not set or set to a zero, defaults to the number of logical CPUs as returned
+by the JVM. If set to a negative value, defaults to a direct executor.
 
 [[index.batchThreads]]index.batchThreads::
 +
 Number of threads to use for indexing in background operations, such as
 online schema upgrades.
 +
-If not set or set to a negative value, defaults to the number of logical
-CPUs as returned by the JVM.
+If not set or set to a zero, defaults to the number of logical CPUs as returned
+by the JVM. If set to a negative value, defaults to a direct executor.
 
 [[index.onlineUpgrade]]index.onlineUpgrade::
 +
@@ -3009,41 +3009,13 @@
 +
 Not set by default.
 
-[[elasticsearch.requestCompression]]elasticsearch.requestCompression::
+[[elasticsearch.maxRetryTimeout]]elasticsearch.maxRetryTimeout::
 +
-Enable request compression.
-+
-Defaults to `false`.
-
-[[elasticsearch.connectionTimeout]]elasticsearch.connectionTimeout::
-+
-How long should Gerrit waits for connection.
+Sets the maximum timeout to honor in case of multiple retries of the same request.
 +
 The value is in the usual time-unit format like `1 m`, `5 m`.
 +
-Defaults to `5 m`
-
-[[elasticsearch.maxConnectionIdleTime]]elasticsearch.maxConnectionIdleTime::
-+
-How long connection can stay in idle.
-+
-The value is in the usual time-unit format like `1 m`, `5 m`.
-+
-Defaults to `5 m`
-
-[[elasticsearch.maxTotalConnection]]elasticsearch.maxTotalConnection::
-+
-How many connections can be spawned simultaneously.
-+
-Defaults to `1`
-
-[[elasticsearch.maxReadTimeout]]elasticsearch.maxReadTimeout::
-+
-Timeout for read operations.
-+
-The value is in the usual time-unit format like `1 m`, `5 m`.
-+
-Defaults to `5 m`
+Defaults to `30000 ms`.
 
 ==== Elasticsearch server(s) configuration
 
@@ -3058,7 +3030,7 @@
 [[elasticsearch.name.hostname]]elasticsearch.name.hostname::
 +
 Elasticsearch server hostname.
-
++
 Defaults to `localhost`.
 
 [[elasticsearch.name.port]]elasticsearch.name.port::
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index bc5a3c6..54f8022 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1354,6 +1354,31 @@
 
 
 [[index]]
+=== Index project
+
+Adds or updates the current project (and children, if specified) in the secondary index.
+The indexing task is executed asynchronously in background, so this command
+returns immediately.
+
+As an input, a link:#index-project-input[IndexProjectInput] entity can be provided.
+
+.Request
+----
+  POST /projects/MyProject/index HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "index_children": "true"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 202 Accepted
+  Content-Disposition: attachment
+----
+
+[[index.changes]]
 === Index all changes in a project
 
 Adds or updates all the changes belonging to a project in the secondary index.
@@ -1362,7 +1387,7 @@
 
 .Request
 ----
-  POST /projects/MyProject/index HTTP/1.0
+  POST /projects/MyProject/index.changes HTTP/1.0
 ----
 
 .Response
@@ -3128,6 +3153,17 @@
 omitted.
 |============================
 
+[[index-project-input]]
+=== IndexProjectInput
+The `IndexProjectInput` contains parameters for indexing a project.
+
+[options="header",cols="1,^2,4"]
+|================================
+|Field Name         ||Description
+|`index_children`   ||
+If children should be indexed recursively.
+|================================
+
 [[inherited-boolean-info]]
 === InheritedBooleanInfo
 A boolean value that can also be inherited.
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
index e518d26..1b946cd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AccessSectionEditor.java
@@ -169,7 +169,7 @@
 
   @Override
   public void setValue(AccessSection value) {
-    Collections.sort(value.getPermissions());
+    sortPermissions(value);
 
     this.value = value;
     this.readOnly = !editing || !(projectAccess.isOwnerOf(value) || projectAccess.canUpload());
@@ -204,6 +204,12 @@
     }
   }
 
+  private void sortPermissions(AccessSection accessSection) {
+    List<Permission> permissionList = new ArrayList<>(accessSection.getPermissions());
+    Collections.sort(permissionList);
+    accessSection.setPermissions(permissionList);
+  }
+
   void setEditing(boolean editing) {
     this.editing = editing;
   }
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 6828393..abb28ce 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -397,6 +397,8 @@
       baseConfig.setString("sshd", null, "listenAddress", "off");
     }
 
+    baseConfig.setInt("index", null, "batchThreads", -1);
+
     baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     Module module = createModule();
     if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
@@ -1075,7 +1077,7 @@
       ProjectConfig config = ProjectConfig.read(md);
       AccessSection s = config.getAccessSection(ref, true);
       Permission p = s.getPermission(permission, true);
-      p.getRules().clear();
+      p.clearRules();
       config.commit(md);
       projectCache.evict(config.getProject());
     }
diff --git a/java/com/google/gerrit/common/data/AccessSection.java b/java/com/google/gerrit/common/data/AccessSection.java
index 82dc620..f658066 100644
--- a/java/com/google/gerrit/common/data/AccessSection.java
+++ b/java/com/google/gerrit/common/data/AccessSection.java
@@ -34,11 +34,12 @@
     super(refPattern);
   }
 
+  // TODO(ekempin): Make this method return an ImmutableList once the GWT UI is gone.
   public List<Permission> getPermissions() {
     if (permissions == null) {
-      permissions = new ArrayList<>();
+      return new ArrayList<>();
     }
-    return permissions;
+    return new ArrayList<>(permissions);
   }
 
   public void setPermissions(List<Permission> list) {
@@ -49,7 +50,7 @@
       }
     }
 
-    permissions = list;
+    permissions = new ArrayList<>(list);
   }
 
   @Nullable
@@ -59,13 +60,19 @@
 
   @Nullable
   public Permission getPermission(String name, boolean create) {
-    for (Permission p : getPermissions()) {
-      if (p.getName().equalsIgnoreCase(name)) {
-        return p;
+    if (permissions != null) {
+      for (Permission p : permissions) {
+        if (p.getName().equalsIgnoreCase(name)) {
+          return p;
+        }
       }
     }
 
     if (create) {
+      if (permissions == null) {
+        permissions = new ArrayList<>();
+      }
+
       Permission p = new Permission(name);
       permissions.add(p);
       return p;
@@ -75,7 +82,10 @@
   }
 
   public void addPermission(Permission permission) {
-    List<Permission> permissions = getPermissions();
+    if (permissions == null) {
+      permissions = new ArrayList<>();
+    }
+
     for (Permission p : permissions) {
       if (p.getName().equalsIgnoreCase(permission.getName())) {
         throw new IllegalArgumentException();
@@ -133,4 +143,15 @@
     return new HashSet<>(getPermissions())
         .equals(new HashSet<>(((AccessSection) obj).getPermissions()));
   }
+
+  @Override
+  public int hashCode() {
+    int hashCode = super.hashCode();
+    if (permissions != null) {
+      for (Permission permission : permissions) {
+        hashCode += permission.hashCode();
+      }
+    }
+    return hashCode;
+  }
 }
diff --git a/java/com/google/gerrit/common/data/Permission.java b/java/com/google/gerrit/common/data/Permission.java
index dff30d7..af23e39 100644
--- a/java/com/google/gerrit/common/data/Permission.java
+++ b/java/com/google/gerrit/common/data/Permission.java
@@ -155,13 +155,16 @@
     exclusiveGroup = newExclusiveGroup;
   }
 
+  // TODO(ekempin): Make this method return an ImmutableList once the GWT UI is gone.
   public List<PermissionRule> getRules() {
-    initRules();
-    return rules;
+    if (rules == null) {
+      return new ArrayList<>();
+    }
+    return new ArrayList<>(rules);
   }
 
   public void setRules(List<PermissionRule> list) {
-    rules = list;
+    rules = new ArrayList<>(list);
   }
 
   public void add(PermissionRule rule) {
@@ -181,6 +184,12 @@
     }
   }
 
+  public void clearRules() {
+    if (rules != null) {
+      rules.clear();
+    }
+  }
+
   public PermissionRule getRule(GroupReference group) {
     return getRule(group, false);
   }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
index 4184ec0..0148bf1 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -29,21 +30,18 @@
 
 @Singleton
 class ElasticConfiguration {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private static final String DEFAULT_HOST = "localhost";
   private static final String DEFAULT_PORT = "9200";
   private static final String DEFAULT_PROTOCOL = "http";
 
   private final Config cfg;
+  private final List<HttpHost> hosts;
 
-  final List<HttpHost> urls;
   final String username;
   final String password;
-  final boolean requestCompression;
-  final long connectionTimeout;
-  final long maxConnectionIdleTime;
-  final TimeUnit maxConnectionIdleUnit = TimeUnit.MILLISECONDS;
-  final int maxTotalConnection;
-  final int readTimeout;
+  final int maxRetryTimeout;
   final String prefix;
 
   @Inject
@@ -51,39 +49,39 @@
     this.cfg = cfg;
     this.username = cfg.getString("elasticsearch", null, "username");
     this.password = cfg.getString("elasticsearch", null, "password");
-    this.requestCompression = cfg.getBoolean("elasticsearch", null, "requestCompression", false);
-    this.connectionTimeout =
-        cfg.getTimeUnit("elasticsearch", null, "connectionTimeout", 3000, TimeUnit.MILLISECONDS);
-    this.maxConnectionIdleTime =
-        cfg.getTimeUnit(
-            "elasticsearch", null, "maxConnectionIdleTime", 3000, TimeUnit.MILLISECONDS);
-    this.maxTotalConnection = cfg.getInt("elasticsearch", null, "maxTotalConnection", 1);
-    this.readTimeout =
-        (int) cfg.getTimeUnit("elasticsearch", null, "readTimeout", 3000, TimeUnit.MICROSECONDS);
+    this.maxRetryTimeout =
+        (int)
+            cfg.getTimeUnit("elasticsearch", null, "maxRetryTimeout", 30000, TimeUnit.MILLISECONDS);
     this.prefix = Strings.nullToEmpty(cfg.getString("elasticsearch", null, "prefix"));
 
     Set<String> subsections = cfg.getSubsections("elasticsearch");
     if (subsections.isEmpty()) {
       HttpHost httpHost =
           new HttpHost(DEFAULT_HOST, Integer.valueOf(DEFAULT_PORT), DEFAULT_PROTOCOL);
-      this.urls = Collections.singletonList(httpHost);
+      this.hosts = Collections.singletonList(httpHost);
     } else {
-      this.urls = new ArrayList<>(subsections.size());
+      this.hosts = new ArrayList<>(subsections.size());
       for (String subsection : subsections) {
         String port = getString(cfg, subsection, "port", DEFAULT_PORT);
         String host = getString(cfg, subsection, "hostname", DEFAULT_HOST);
         String protocol = getString(cfg, subsection, "protocol", DEFAULT_PROTOCOL);
 
         HttpHost httpHost = new HttpHost(host, Integer.valueOf(port), protocol);
-        this.urls.add(httpHost);
+        this.hosts.add(httpHost);
       }
     }
+
+    logger.atInfo().log("Elasticsearch hosts: %s", hosts);
   }
 
   Config getConfig() {
     return cfg;
   }
 
+  HttpHost[] getHosts() {
+    return hosts.toArray(new HttpHost[hosts.size()]);
+  }
+
   String getIndexName(String name, int schemaVersion) {
     return String.format("%s%s_%04d", prefix, name, schemaVersion);
   }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
index c2c4548..db59257 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -22,7 +22,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import org.apache.http.HttpHost;
 import org.apache.http.HttpStatus;
 import org.apache.http.StatusLine;
 import org.apache.http.auth.AuthScope;
@@ -38,18 +37,14 @@
 class ElasticRestClientProvider implements Provider<RestClient>, LifecycleListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final HttpHost[] hosts;
-  private final String username;
-  private final String password;
+  private final ElasticConfiguration cfg;
 
   private RestClient client;
   private ElasticQueryAdapter adapter;
 
   @Inject
   ElasticRestClientProvider(ElasticConfiguration cfg) {
-    hosts = cfg.urls.toArray(new HttpHost[cfg.urls.size()]);
-    username = cfg.username;
-    password = cfg.password;
+    this.cfg = cfg;
   }
 
   public static LifecycleModule module() {
@@ -131,12 +126,15 @@
   }
 
   private RestClient build() {
-    RestClientBuilder builder = RestClient.builder(hosts);
+    RestClientBuilder builder = RestClient.builder(cfg.getHosts());
+    builder.setMaxRetryTimeoutMillis(cfg.maxRetryTimeout);
     setConfiguredCredentialsIfAny(builder);
     return builder.build();
   }
 
   private void setConfiguredCredentialsIfAny(RestClientBuilder builder) {
+    String username = cfg.username;
+    String password = cfg.password;
     if (username != null && password != null) {
       CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
       credentialsProvider.setCredentials(
diff --git a/java/com/google/gerrit/extensions/api/projects/IndexProjectInput.java b/java/com/google/gerrit/extensions/api/projects/IndexProjectInput.java
new file mode 100644
index 0000000..a41f227
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/IndexProjectInput.java
@@ -0,0 +1,19 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+public class IndexProjectInput {
+  public Boolean indexChildren;
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index c9f47c2..63d40f0 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -191,6 +191,13 @@
   void parent(String parent) throws RestApiException;
 
   /**
+   * Reindex the project and children in case {@code indexChildren} is specified.
+   *
+   * @param indexChildren decides if children should be indexed recursively
+   */
+  void index(boolean indexChildren) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -344,5 +351,10 @@
     public void parent(String parent) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void index(boolean indexChildren) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java b/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
index 6b5da7c..e1c4b3b 100644
--- a/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
+++ b/java/com/google/gerrit/extensions/restapi/AcceptsDelete.java
@@ -17,17 +17,31 @@
 /**
  * Optional interface for {@link RestCollection}.
  *
- * <p>Collections that implement this interface can accept a {@code DELETE} directly on the
- * collection itself.
+ * <p>This interface is used for 2 purposes:
+ *
+ * <ul>
+ *   <li>to support {@code DELETE} directly on the collection itself
+ *   <li>to support {@code DELETE} on a non-existing member of the collection (in order to create
+ *       that member)
+ * </ul>
+ *
+ * <p>This interface is not supported for root collections.
  */
 public interface AcceptsDelete<P extends RestResource> {
   /**
-   * Handle deletion of a child resource by DELETE on the collection.
+   * Handle
    *
-   * @param parent parent collection handle.
-   * @param id id of the resource being created (optional).
-   * @return a view to perform the deletion.
-   * @throws RestApiException the view cannot be constructed.
+   * <ul>
+   *   <li>{@code DELETE} directly on the collection itself (in this case id is {@code null})
+   *   <li>{@code DELETE} on a non-existing member of the collection (in this case id is not {@code
+   *       null})
+   * </ul>
+   *
+   * @param parent the collection
+   * @param id id of the non-existing collection member for which the {@code DELETE} request is
+   *     done, {@code null} if the {@code DELETE} request is done on the collection itself
+   * @return a view to handle the {@code DELETE} request
+   * @throws RestApiException the view cannot be constructed
    */
   RestModifyView<P, ?> delete(P parent, IdString id) throws RestApiException;
 }
diff --git a/java/com/google/gerrit/gpg/server/GpgKeys.java b/java/com/google/gerrit/gpg/server/GpgKeys.java
index a2d901f..3f090a1 100644
--- a/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -59,8 +59,6 @@
 public class GpgKeys implements ChildCollection<AccountResource, GpgKey> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static final String MIME_TYPE = "application/pgp-keys";
-
   private final DynamicMap<RestView<GpgKey>> views;
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
diff --git a/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 64b5bbb..3244232 100644
--- a/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -34,6 +34,7 @@
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.HtmlDomUtil;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
 import java.io.IOException;
@@ -324,6 +325,7 @@
     }
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static class Weigher implements com.google.common.cache.Weigher<Path, Resource> {
     @Override
     public int weigh(Path p, Resource r) {
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 1196e47..a454c00 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -337,7 +337,7 @@
           }
         }
         if (viewData.view == null) {
-          viewData = view(rsrc, rc, req.getMethod(), path);
+          viewData = view(rc, req.getMethod(), path);
         }
       }
       checkRequiresCapability(viewData);
@@ -392,7 +392,7 @@
           }
         }
         if (viewData.view == null) {
-          viewData = view(rsrc, c, req.getMethod(), path);
+          viewData = view(c, req.getMethod(), path);
         }
         checkRequiresCapability(viewData);
       }
@@ -1109,10 +1109,7 @@
   }
 
   private ViewData view(
-      RestResource rsrc,
-      RestCollection<RestResource, RestResource> rc,
-      String method,
-      List<IdString> path)
+      RestCollection<RestResource, RestResource> rc, String method, List<IdString> path)
       throws AmbiguousViewException, RestApiException {
     DynamicMap<RestView<RestResource>> views = rc.views();
     final IdString projection = path.isEmpty() ? IdString.fromUrl("/") : path.remove(0);
@@ -1136,10 +1133,8 @@
         return new ViewData(p.get(0), view);
       }
       view = views.get(p.get(0), "GET." + viewname);
-      if (view != null && view instanceof AcceptsPost && "POST".equals(method)) {
-        @SuppressWarnings("unchecked")
-        AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) view;
-        return new ViewData(p.get(0), ap.post(rsrc));
+      if (view != null) {
+        return new ViewData(p.get(0), view);
       }
       throw new ResourceNotFoundException(projection);
     }
@@ -1150,10 +1145,8 @@
       return new ViewData(null, core);
     }
     core = views.get("gerrit", "GET." + p.get(0));
-    if (core instanceof AcceptsPost && "POST".equals(method)) {
-      @SuppressWarnings("unchecked")
-      AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) core;
-      return new ViewData(null, ap.post(rsrc));
+    if (core != null) {
+      return new ViewData(null, core);
     }
 
     Map<String, RestView<RestResource>> r = new TreeMap<>();
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 252eb61..c9ca972 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -214,14 +214,13 @@
     return false;
   }
 
-  /** Returns the full commit message for the given project at the given patchset revision */
-  public String getFullCommitMessage(Project.NameKey project, PatchSet patchSet)
-      throws IOException {
+  /** Returns the commit for the given project at the given patchset revision */
+  public RevCommit getRevCommit(Project.NameKey project, PatchSet patchSet) throws IOException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       RevCommit src = rw.parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
       rw.parseBody(src);
-      return src.getFullMessage();
+      return src;
     }
   }
 }
diff --git a/java/com/google/gerrit/server/UsedAt.java b/java/com/google/gerrit/server/UsedAt.java
new file mode 100644
index 0000000..b564157
--- /dev/null
+++ b/java/com/google/gerrit/server/UsedAt.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.common.annotations.GwtCompatible;
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * A marker for a method that is public solely because it is called from inside a project or an
+ * organisation using Gerrit.
+ */
+@BindingAnnotation
+@Target({METHOD, TYPE})
+@Retention(RUNTIME)
+@GwtCompatible
+public @interface UsedAt {
+  /** Enumeration of projects that call a method that would otherwise be private. */
+  enum Project {
+    GOOGLE,
+    PLUGIN_DELETE_PROJECT,
+    PLUGIN_SERVICEUSER,
+    PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
+  }
+
+  /** Reference to the project that uses the method annotated with this annotation. */
+  Project value();
+}
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 8a48167..e91ce49 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -95,6 +95,16 @@
     return builder.build();
   }
 
+  /**
+   * Returns the accounts with the given email.
+   *
+   * <p>This method behaves just like {@link #getAccountFor(String)}, except that accounts are not
+   * looked up by their preferred email. Thus, this method does not rely on the accounts index.
+   */
+  public ImmutableSet<Account.Id> getAccountForExternal(String email) throws IOException {
+    return externalIds.byEmail(email).stream().map(ExternalId::accountId).collect(toImmutableSet());
+  }
+
   private <T> T executeIndexQuery(Action<T> action) throws OrmException {
     try {
       return retryHelper.execute(ActionType.INDEX_QUERY, action, OrmException.class::isInstance);
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index fb42ed0..358a3a8 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -57,7 +57,6 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Abandon;
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
@@ -70,6 +69,7 @@
 import com.google.gerrit.server.restapi.change.GetAssignee;
 import com.google.gerrit.server.restapi.change.GetHashtags;
 import com.google.gerrit.server.restapi.change.GetPastAssignees;
+import com.google.gerrit.server.restapi.change.GetPureRevert;
 import com.google.gerrit.server.restapi.change.GetTopic;
 import com.google.gerrit.server.restapi.change.Ignore;
 import com.google.gerrit.server.restapi.change.Index;
@@ -153,7 +153,7 @@
   private final SetWorkInProgress setWip;
   private final SetReadyForReview setReady;
   private final PutMessage putMessage;
-  private final PureRevert pureRevert;
+  private final Provider<GetPureRevert> getPureRevertProvider;
   private final StarredChangesUtil stars;
 
   @Inject
@@ -200,7 +200,7 @@
       SetWorkInProgress setWip,
       SetReadyForReview setReady,
       PutMessage putMessage,
-      PureRevert pureRevert,
+      Provider<GetPureRevert> getPureRevertProvider,
       StarredChangesUtil stars,
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
@@ -245,7 +245,7 @@
     this.setWip = setWip;
     this.setReady = setReady;
     this.putMessage = putMessage;
-    this.pureRevert = pureRevert;
+    this.getPureRevertProvider = getPureRevertProvider;
     this.stars = stars;
     this.change = change;
   }
@@ -714,7 +714,9 @@
   @Override
   public PureRevertInfo pureRevert(@Nullable String claimedOriginal) throws RestApiException {
     try {
-      return pureRevert.get(change.getNotes(), claimedOriginal);
+      GetPureRevert getPureRevert = getPureRevertProvider.get();
+      getPureRevert.setClaimedOriginal(claimedOriginal);
+      return getPureRevert.apply(change);
     } catch (Exception e) {
       throw asRestApiException("Cannot compute pure revert", e);
     }
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 1065bff..a49f8c4 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.api.projects.HeadInput;
+import com.google.gerrit.extensions.api.projects.IndexProjectInput;
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -64,6 +65,7 @@
 import com.google.gerrit.server.restapi.project.GetDescription;
 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.ListBranches;
 import com.google.gerrit.server.restapi.project.ListChildProjects;
 import com.google.gerrit.server.restapi.project.ListDashboards;
@@ -118,6 +120,7 @@
   private final SetHead setHead;
   private final GetParent getParent;
   private final SetParent setParent;
+  private final Index index;
 
   @AssistedInject
   ProjectApiImpl(
@@ -150,6 +153,7 @@
       SetHead setHead,
       GetParent getParent,
       SetParent setParent,
+      Index index,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -182,6 +186,7 @@
         setHead,
         getParent,
         setParent,
+        index,
         null);
   }
 
@@ -216,6 +221,7 @@
       SetHead setHead,
       GetParent getParent,
       SetParent setParent,
+      Index index,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -248,6 +254,7 @@
         setHead,
         getParent,
         setParent,
+        index,
         name);
   }
 
@@ -282,6 +289,7 @@
       SetHead setHead,
       GetParent getParent,
       SetParent setParent,
+      Index index,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -314,6 +322,7 @@
     this.getParent = getParent;
     this.setParent = setParent;
     this.name = name;
+    this.index = index;
   }
 
   @Override
@@ -595,6 +604,17 @@
     }
   }
 
+  @Override
+  public void index(boolean indexChildren) throws RestApiException {
+    try {
+      IndexProjectInput input = new IndexProjectInput();
+      input.indexChildren = indexChildren;
+      index.apply(checkExists(), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index project", e);
+    }
+  }
+
   private ProjectResource checkExists() throws ResourceNotFoundException {
     if (project == null) {
       throw new ResourceNotFoundException(name);
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index d7baee2..1365cb6 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -48,7 +48,6 @@
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
-import org.h2.jdbc.JdbcSQLException;
 
 /**
  * Hybrid in-memory and database backed cache built on H2.
@@ -341,8 +340,10 @@
               b.put(keyType.get(r, 1));
             }
           }
-        } catch (JdbcSQLException e) {
-          if (e.getCause() instanceof InvalidClassException) {
+        } catch (Exception e) {
+          if (Throwables.getCausalChain(e)
+              .stream()
+              .anyMatch(InvalidClassException.class::isInstance)) {
             // If deserialization failed using default Java serialization, this means we are using
             // the old serialVersionUID-based invalidation strategy. In that case, authors are
             // most likely bumping serialVersionUID rather than using the new versioning in the
diff --git a/java/com/google/gerrit/server/change/ChangeEditResource.java b/java/com/google/gerrit/server/change/ChangeEditResource.java
index 392709e..5419ee9 100644
--- a/java/com/google/gerrit/server/change/ChangeEditResource.java
+++ b/java/com/google/gerrit/server/change/ChangeEditResource.java
@@ -32,6 +32,10 @@
 public class ChangeEditResource implements RestResource {
   public static final TypeLiteral<RestView<ChangeEditResource>> CHANGE_EDIT_KIND =
       new TypeLiteral<RestView<ChangeEditResource>>() {};
+  public static final TypeLiteral<RestView<ChangeEditResource.Publish>> CHANGE_EDIT_PUBLISH_KIND =
+      new TypeLiteral<RestView<ChangeEditResource.Publish>>() {};
+  public static final TypeLiteral<RestView<ChangeEditResource.Rebase>> CHANGE_EDIT_REBASE_KIND =
+      new TypeLiteral<RestView<ChangeEditResource.Rebase>>() {};
 
   private final ChangeResource change;
   private final ChangeEdit edit;
@@ -60,4 +64,16 @@
   public String getPath() {
     return path;
   }
+
+  public static class Publish extends ChangeEditResource {
+    public Publish(ChangeResource change, ChangeEdit edit, String path) {
+      super(change, edit, path);
+    }
+  }
+
+  public static class Rebase extends ChangeEditResource {
+    public Rebase(ChangeResource change, ChangeEdit edit, String path) {
+      super(change, edit, path);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/change/PureRevert.java b/java/com/google/gerrit/server/change/PureRevert.java
index 2c450a7..ddc9661 100644
--- a/java/com/google/gerrit/server/change/PureRevert.java
+++ b/java/com/google/gerrit/server/change/PureRevert.java
@@ -28,6 +28,7 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.List;
@@ -42,6 +43,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+@Singleton
 public class PureRevert {
   private final MergeUtil.Factory mergeUtilFactory;
   private final GitRepositoryManager repoManager;
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index f38572d..b5f09fd 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -36,6 +36,8 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.net.MalformedURLException;
+import java.net.URL;
 import org.eclipse.jgit.lib.Config;
 
 public class GitwebConfig {
@@ -178,7 +180,11 @@
   private final GitwebType type;
 
   @Inject
-  GitwebConfig(GitwebCgiConfig cgiConfig, @GerritServerConfig Config cfg) {
+  GitwebConfig(
+      GitwebCgiConfig cgiConfig,
+      @GerritServerConfig Config cfg,
+      @Nullable @CanonicalWebUrl String gerritUrl)
+      throws MalformedURLException {
     if (isDisabled(cfg)) {
       type = null;
       url = null;
@@ -191,7 +197,14 @@
         // Use an externally managed gitweb instance, and not an internal one.
         url = cfgUrl;
       } else {
-        url = firstNonNull(cfgUrl, "gitweb");
+        String baseGerritUrl;
+        if (gerritUrl != null) {
+          URL u = new URL(gerritUrl);
+          baseGerritUrl = u.getPath();
+        } else {
+          baseGerritUrl = "/";
+        }
+        url = firstNonNull(cfgUrl, baseGerritUrl + "gitweb");
       }
     }
   }
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index d957558..3c9538c 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -104,7 +104,7 @@
 
   public IndexModule(
       ListeningExecutorService interactiveExecutor, ListeningExecutorService batchExecutor) {
-    this.threads = -1;
+    this.threads = 0;
     this.interactiveExecutor = interactiveExecutor;
     this.batchExecutor = batchExecutor;
     this.closeExecutorsOnShutdown = false;
@@ -211,11 +211,12 @@
       return interactiveExecutor;
     }
     int threads = this.threads;
-    if (threads <= 0) {
-      threads = config.getInt("index", null, "threads", 0);
-    }
-    if (threads <= 0) {
-      threads = Runtime.getRuntime().availableProcessors() / 2 + 1;
+    if (threads < 0) {
+      return MoreExecutors.newDirectExecutorService();
+    } else if (threads == 0) {
+      threads =
+          config.getInt(
+              "index", null, "threads", Runtime.getRuntime().availableProcessors() / 2 + 1);
     }
     return MoreExecutors.listeningDecorator(
         workQueue.createQueue(threads, "Index-Interactive", true));
@@ -230,7 +231,9 @@
       return batchExecutor;
     }
     int threads = config.getInt("index", null, "batchThreads", 0);
-    if (threads <= 0) {
+    if (threads < 0) {
+      return MoreExecutors.newDirectExecutorService();
+    } else if (threads == 0) {
       threads = Runtime.getRuntime().availableProcessors();
     }
     return MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "Index-Batch", true));
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index e567d77..642385c 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -103,6 +104,7 @@
         parsePatterns(cd));
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static boolean isStale(
       GitRepositoryManager repoManager,
       Change.Id id,
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 04f4d6c..f49951f 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -49,6 +50,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 
+@UsedAt(UsedAt.Project.PLUGINS_ALL)
 public class EmailArguments {
   final GitRepositoryManager server;
   final ProjectCache projectCache;
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 3653bc7..010c5c0 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -120,9 +120,7 @@
       ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Date when) {
     checkUserType(u);
     if (u instanceof IdentifiedUser) {
-      return noteUtil
-          .getLegacyChangeNoteWrite()
-          .newIdent(u.asIdentifiedUser().getAccount(), when, serverIdent);
+      return noteUtil.newIdent(u.asIdentifiedUser().getAccount(), when, serverIdent);
     } else if (u instanceof InternalUser) {
       return serverIdent;
     }
@@ -177,7 +175,7 @@
   }
 
   protected PersonIdent newIdent(Account.Id authorId, Date when) {
-    return noteUtil.getLegacyChangeNoteWrite().newIdent(authorId, when, serverIdent);
+    return noteUtil.newIdent(authorId, when, serverIdent);
   }
 
   /** Whether no updates have been done. */
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 6b4bea7..169fd2e 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -178,12 +178,7 @@
     for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
       updatedRevs.add(e.getKey());
       ObjectId id = ObjectId.fromString(e.getKey().get());
-      byte[] data =
-          e.getValue()
-              .build(
-                  noteUtil.getChangeNoteJson(),
-                  noteUtil.getLegacyChangeNoteWrite(),
-                  noteUtil.getChangeNoteJson().getWriteJson());
+      byte[] data = e.getValue().build(noteUtil.getChangeNoteJson());
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 0475fe3..483b2e9 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -14,18 +14,14 @@
 
 package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
-import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
-import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class ChangeNoteJson {
   private final Gson gson = newGson();
-  private final boolean writeJson;
 
   static Gson newGson() {
     return new GsonBuilder()
@@ -37,13 +33,4 @@
   public Gson getGson() {
     return gson;
   }
-
-  public boolean getWriteJson() {
-    return writeJson;
-  }
-
-  @Inject
-  ChangeNoteJson(@GerritServerConfig Config config) {
-    this.writeJson = config.getBoolean("notedb", "writeJson", true);
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 070f974..cf86c99 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -14,7 +14,15 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.GerritServerId;
 import com.google.inject.Inject;
+import java.util.Date;
+import java.util.Optional;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.FooterKey;
 
 public class ChangeNoteUtil {
@@ -56,17 +64,21 @@
   static final String TAG = FOOTER_TAG.getName();
 
   private final LegacyChangeNoteRead legacyChangeNoteRead;
-  private final LegacyChangeNoteWrite legacyChangeNoteWrite;
   private final ChangeNoteJson changeNoteJson;
 
+  private final AccountCache accountCache;
+  private final String serverId;
+
   @Inject
   public ChangeNoteUtil(
       ChangeNoteJson changeNoteJson,
       LegacyChangeNoteRead legacyChangeNoteRead,
-      LegacyChangeNoteWrite legacyChangeNoteWrite) {
+      AccountCache accountCache,
+      @GerritServerId String serverId) {
+    this.accountCache = accountCache;
+    this.serverId = serverId;
     this.changeNoteJson = changeNoteJson;
     this.legacyChangeNoteRead = legacyChangeNoteRead;
-    this.legacyChangeNoteWrite = legacyChangeNoteWrite;
   }
 
   public LegacyChangeNoteRead getLegacyChangeNoteRead() {
@@ -77,7 +89,18 @@
     return changeNoteJson;
   }
 
-  public LegacyChangeNoteWrite getLegacyChangeNoteWrite() {
-    return legacyChangeNoteWrite;
+  public PersonIdent newIdent(Account.Id authorId, Date when, PersonIdent serverIdent) {
+    Optional<Account> author = accountCache.get(authorId).map(AccountState::getAccount);
+    return new PersonIdent(
+        author.map(Account::getName).orElseGet(() -> Account.getName(authorId)),
+        authorId.get() + "@" + serverId,
+        when,
+        serverIdent.getTimeZone());
+  }
+
+  @VisibleForTesting
+  public PersonIdent newIdent(Account author, Date when, PersonIdent serverIdent) {
+    return new PersonIdent(
+        author.getName(), author.getId().get() + "@" + serverId, when, serverIdent.getTimeZone());
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index 894e979..66dd5e8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -88,7 +88,7 @@
     return comments;
   }
 
-  private static boolean isJson(byte[] raw, int offset) {
+  static boolean isJson(byte[] raw, int offset) {
     return raw[offset] == '{' || raw[offset] == '[';
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 445f7a0..69fbfe1 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -402,6 +402,7 @@
       if (notes != null) {
         draftUpdate = draftUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when);
       } else {
+        // tests will always take the notes != null path above.
         draftUpdate =
             draftUpdateFactory.create(getChange(), accountId, realAccountId, authorIdent, when);
       }
@@ -526,14 +527,7 @@
     checkComments(rnm.revisionNotes, builders);
 
     for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
-      ObjectId data =
-          inserter.insert(
-              OBJ_BLOB,
-              e.getValue()
-                  .build(
-                      noteUtil.getChangeNoteJson(),
-                      noteUtil.getLegacyChangeNoteWrite(),
-                      noteUtil.getChangeNoteJson().getWriteJson()));
+      ObjectId data = inserter.insert(OBJ_BLOB, e.getValue().build(noteUtil.getChangeNoteJson()));
       rnm.noteMap.set(ObjectId.fromString(e.getKey().get()), data);
     }
 
diff --git a/java/com/google/gerrit/server/notedb/CommentJsonMigrator.java b/java/com/google/gerrit/server/notedb/CommentJsonMigrator.java
new file mode 100644
index 0000000..9a9a211
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/CommentJsonMigrator.java
@@ -0,0 +1,190 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.gerrit.server.notedb.RevisionNote.MAX_NOTE_SZ;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.config.GerritServerIdProvider;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.util.MutableInteger;
+
+@Singleton
+public class CommentJsonMigrator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final LegacyChangeNoteRead legacyChangeNoteRead;
+  private final ChangeNoteJson changeNoteJson;
+
+  @Inject
+  CommentJsonMigrator(
+      ChangeNoteJson changeNoteJson, GerritServerIdProvider gerritServerIdProvider) {
+    this.changeNoteJson = changeNoteJson;
+    this.legacyChangeNoteRead = new LegacyChangeNoteRead(gerritServerIdProvider.get());
+  }
+
+  CommentJsonMigrator(ChangeNoteJson changeNoteJson, String serverId) {
+    this.changeNoteJson = changeNoteJson;
+    this.legacyChangeNoteRead = new LegacyChangeNoteRead(serverId);
+  }
+
+  public boolean migrateChanges(
+      Project.NameKey project, Repository repo, RevWalk rw, ObjectInserter ins, BatchRefUpdate bru)
+      throws IOException {
+    boolean ok = true;
+    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
+      Change.Id changeId = Change.Id.fromRef(ref.getName());
+      if (changeId == null || !ref.getName().equals(RefNames.changeMetaRef(changeId))) {
+        continue;
+      }
+      ok &= migrateOne(project, rw, ins, bru, Status.PUBLISHED, changeId, ref);
+    }
+    return ok;
+  }
+
+  public boolean migrateDrafts(
+      Project.NameKey allUsers,
+      Repository allUsersRepo,
+      RevWalk rw,
+      ObjectInserter ins,
+      BatchRefUpdate bru)
+      throws IOException {
+    boolean ok = true;
+    for (Ref ref : allUsersRepo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS)) {
+      Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
+      if (changeId == null) {
+        continue;
+      }
+      ok &= migrateOne(allUsers, rw, ins, bru, Status.DRAFT, changeId, ref);
+    }
+    return ok;
+  }
+
+  private boolean migrateOne(
+      Project.NameKey project,
+      RevWalk rw,
+      ObjectInserter ins,
+      BatchRefUpdate bru,
+      Status status,
+      Change.Id changeId,
+      Ref ref) {
+    ObjectId oldId = ref.getObjectId();
+    try {
+      if (!hasAnyLegacyComments(rw, oldId)) {
+        return true;
+      }
+    } catch (IOException e) {
+      logger.atInfo().log(
+          String.format(
+              "Error reading change %s in %s; attempting migration anyway", changeId, project),
+          e);
+    }
+
+    try {
+      reset(rw, oldId);
+
+      ObjectReader reader = rw.getObjectReader();
+      ObjectId newId = null;
+      RevCommit c;
+      while ((c = rw.next()) != null) {
+        CommitBuilder cb = new CommitBuilder();
+        cb.setAuthor(c.getAuthorIdent());
+        cb.setCommitter(c.getCommitterIdent());
+        cb.setMessage(c.getFullMessage());
+        cb.setEncoding(c.getEncoding());
+        if (newId != null) {
+          cb.setParentId(newId);
+        }
+
+        // Read/write using the low-level RevisionNote API, which works regardless of NotesMigration
+        // state.
+        NoteMap noteMap = NoteMap.read(reader, c);
+        RevisionNoteMap<ChangeRevisionNote> revNoteMap =
+            RevisionNoteMap.parse(
+                changeNoteJson, legacyChangeNoteRead, changeId, reader, noteMap, status);
+        RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(revNoteMap);
+
+        for (RevId revId : revNoteMap.revisionNotes.keySet()) {
+          // Call cache.get on each known RevId to read the old note in whichever format, then write
+          // the note in JSON format.
+          byte[] data = cache.get(revId).build(changeNoteJson);
+          noteMap.set(ObjectId.fromString(revId.get()), ins.insert(OBJ_BLOB, data));
+        }
+        cb.setTreeId(noteMap.writeTree(ins));
+        newId = ins.insert(cb);
+      }
+
+      bru.addCommand(new ReceiveCommand(oldId, newId, ref.getName()));
+      return true;
+    } catch (ConfigInvalidException | IOException e) {
+      logger.atInfo().log(String.format("Error migrating change %s in %s", changeId, project), e);
+      return false;
+    }
+  }
+
+  private static boolean hasAnyLegacyComments(RevWalk rw, ObjectId id) throws IOException {
+    ObjectReader reader = rw.getObjectReader();
+    reset(rw, id);
+
+    // Check the note map at each commit, not just the tip. It's possible that the server switched
+    // from legacy to JSON partway through its history, which would have mixed legacy/JSON comments
+    // in its history. Although the tip commit would continue to parse once we remove the legacy
+    // parser, our goal is really to expunge all vestiges of the old format, which implies rewriting
+    // history (and thus returning true) in this case.
+    RevCommit c;
+    while ((c = rw.next()) != null) {
+      NoteMap noteMap = NoteMap.read(reader, c);
+      for (Note note : noteMap) {
+        // Match pre-parsing logic in RevisionNote#parse().
+        byte[] raw = reader.open(note.getData(), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+        MutableInteger p = new MutableInteger();
+        RevisionNote.trimLeadingEmptyLines(raw, p);
+        if (!ChangeRevisionNote.isJson(raw, p.value)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  private static void reset(RevWalk rw, ObjectId id) throws IOException {
+    rw.reset();
+    rw.sort(RevSort.TOPO);
+    rw.sort(RevSort.REVERSE);
+    rw.markStart(rw.parseCommit(id));
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index 9a8c130..0cd3452 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -239,13 +239,7 @@
     Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
     for (Map.Entry<RevId, RevisionNoteBuilder> entry : builders.entrySet()) {
       ObjectId objectId = ObjectId.fromString(entry.getKey().get());
-      byte[] data =
-          entry
-              .getValue()
-              .build(
-                  noteUtil.getChangeNoteJson(),
-                  noteUtil.getLegacyChangeNoteWrite(),
-                  noteUtil.getChangeNoteJson().getWriteJson());
+      byte[] data = entry.getValue().build(noteUtil.getChangeNoteJson());
       if (data.length == 0) {
         revNotesMap.noteMap.remove(objectId);
       } else {
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 8bf286d..b8c7d7d 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -82,19 +82,13 @@
     delete = new HashSet<>();
   }
 
-  public byte[] build(ChangeNoteUtil noteUtil, boolean writeJson) throws IOException {
-    return build(noteUtil.getChangeNoteJson(), noteUtil.getLegacyChangeNoteWrite(), writeJson);
+  public byte[] build(ChangeNoteUtil noteUtil) throws IOException {
+    return build(noteUtil.getChangeNoteJson());
   }
 
-  public byte[] build(
-      ChangeNoteJson changeNoteJson, LegacyChangeNoteWrite legacyChangeNoteWrite, boolean writeJson)
-      throws IOException {
+  public byte[] build(ChangeNoteJson changeNoteJson) throws IOException {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
-    if (writeJson) {
-      buildNoteJson(changeNoteJson, out);
-    } else {
-      buildNoteLegacy(legacyChangeNoteWrite, out);
-    }
+    buildNoteJson(changeNoteJson, out);
     return out.toByteArray();
   }
 
@@ -142,22 +136,4 @@
       noteUtil.getGson().toJson(data, osw);
     }
   }
-
-  private void buildNoteLegacy(LegacyChangeNoteWrite noteUtil, OutputStream out)
-      throws IOException {
-    if (pushCert != null) {
-      byte[] certBytes = pushCert.getBytes(UTF_8);
-      out.write(certBytes, 0, trimTrailingNewlines(certBytes));
-      out.write('\n');
-    }
-    noteUtil.buildNote(buildCommentMap(), out);
-  }
-
-  private static int trimTrailingNewlines(byte[] bytes) {
-    int p = bytes.length;
-    while (p > 1 && bytes[p - 1] == '\n') {
-      p--;
-    }
-    return p;
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index 3a0d595..e7ac5b7 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -142,7 +142,7 @@
     for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
       updatedRevs.add(e.getKey());
       ObjectId id = ObjectId.fromString(e.getKey().get());
-      byte[] data = e.getValue().build(noteUtil, true);
+      byte[] data = e.getValue().build(noteUtil);
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
       }
diff --git a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
index 5d2ce97..ab2203e 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
@@ -546,7 +546,7 @@
     if (id == null) {
       return new PersonIdent(serverIdent, events.getWhen());
     }
-    return changeNoteUtil.getLegacyChangeNoteWrite().newIdent(id, events.getWhen(), serverIdent);
+    return changeNoteUtil.newIdent(id, events.getWhen(), serverIdent);
   }
 
   private List<HashtagsEvent> getHashtagsEvents(Change change, NoteDbUpdateManager manager)
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index f1b6639..15fedd7 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.inject.Inject;
@@ -54,6 +55,7 @@
 public class AutoMerger {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static boolean cacheAutomerge(Config cfg) {
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
   }
diff --git a/java/com/google/gerrit/server/project/AccountsSection.java b/java/com/google/gerrit/server/project/AccountsSection.java
index 087a314a..30bd244 100644
--- a/java/com/google/gerrit/server/project/AccountsSection.java
+++ b/java/com/google/gerrit/server/project/AccountsSection.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.PermissionRule;
 import java.util.ArrayList;
 import java.util.List;
@@ -21,14 +22,14 @@
 public class AccountsSection {
   protected List<PermissionRule> sameGroupVisibility;
 
-  public List<PermissionRule> getSameGroupVisibility() {
+  public ImmutableList<PermissionRule> getSameGroupVisibility() {
     if (sameGroupVisibility == null) {
-      sameGroupVisibility = new ArrayList<>();
+      sameGroupVisibility = ImmutableList.of();
     }
-    return sameGroupVisibility;
+    return ImmutableList.copyOf(sameGroupVisibility);
   }
 
   public void setSameGroupVisibility(List<PermissionRule> sameGroupVisibility) {
-    this.sameGroupVisibility = sameGroupVisibility;
+    this.sameGroupVisibility = new ArrayList<>(sameGroupVisibility);
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 1796c40..d42b652 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -737,7 +737,7 @@
     }
   }
 
-  private List<PermissionRule> loadPermissionRules(
+  private ImmutableList<PermissionRule> loadPermissionRules(
       Config rc,
       String section,
       String subsection,
@@ -746,7 +746,7 @@
       boolean useRange) {
     Permission perm = new Permission(varName);
     loadPermissionRules(rc, section, subsection, varName, groupsByName, perm, useRange);
-    return perm.getRules();
+    return ImmutableList.copyOf(perm.getRules());
   }
 
   private void loadPermissionRules(
diff --git a/java/com/google/gerrit/server/restapi/account/Module.java b/java/com/google/gerrit/server/restapi/account/Module.java
index 7b09bc9..f37687f 100644
--- a/java/com/google/gerrit/server/restapi/account/Module.java
+++ b/java/com/google/gerrit/server/restapi/account/Module.java
@@ -107,6 +107,8 @@
     get(ACCOUNT_KIND, "external.ids").to(GetExternalIds.class);
     post(ACCOUNT_KIND, "external.ids:delete").to(DeleteExternalIds.class);
 
+    // The gpgkeys REST endpoints are bound via GpgApiModule.
+
     factory(AccountsUpdate.Factory.class);
   }
 
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index e42e5d1..4bbd8fc 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.UsedAt;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountsUpdate;
@@ -119,6 +120,7 @@
     return Strings.isNullOrEmpty(newPassword) ? Response.<String>none() : Response.ok(newPassword);
   }
 
+  @UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
   public static String generate() {
     byte[] rand = new byte[LEN];
     rng.nextBytes(rand);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index ae77060..826c792 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -120,9 +120,10 @@
   }
 
   /**
-   * Create handler that is activated when collection element is accessed but doesn't exist, e. g.
-   * PUT request with a path was called but change edit wasn't created yet. Change edit is created
-   * and PUT handler is called.
+   * This method is invoked if a DELETE request on a non-existing member is done. For change edits
+   * this is the case if a DELETE request for a file in a change edit is done and the change edit
+   * doesn't exist yet (and hence the parse method returned ResourceNotFoundException). In this case
+   * we want to create the change edit on the fly and delete the file with the given id in it.
    */
   @Override
   public DeleteFile delete(ChangeResource parent, IdString id) throws RestApiException {
@@ -132,6 +133,11 @@
     return deleteFileFactory.create(id.get());
   }
 
+  /**
+   * Create handler that is activated when collection element is accessed but doesn't exist, e. g.
+   * PUT request with a path was called but change edit wasn't created yet. Change edit is created
+   * and PUT handler is called.
+   */
   public static class Create
       implements RestCreateView<ChangeResource, ChangeEditResource, Put.Input> {
     private final Put putEdit;
diff --git a/java/com/google/gerrit/server/restapi/change/GetPureRevert.java b/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
index 42675f6..75019af 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPureRevert.java
@@ -30,13 +30,15 @@
 public class GetPureRevert implements RestReadView<ChangeResource> {
 
   private final PureRevert pureRevert;
+  @Nullable private String claimedOriginal;
 
   @Option(
       name = "--claimed-original",
       aliases = {"-o"},
       usage = "SHA1 (40 digit hex) of the original commit")
-  @Nullable
-  private String claimedOriginal;
+  public void setClaimedOriginal(String claimedOriginal) {
+    this.claimedOriginal = claimedOriginal;
+  }
 
   @Inject
   GetPureRevert(PureRevert pureRevert) {
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index d27b136..ef0ba2d 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.server.change.ChangeEditResource.CHANGE_EDIT_KIND;
+import static com.google.gerrit.server.change.ChangeEditResource.CHANGE_EDIT_PUBLISH_KIND;
+import static com.google.gerrit.server.change.ChangeEditResource.CHANGE_EDIT_REBASE_KIND;
 import static com.google.gerrit.server.change.ChangeMessageResource.CHANGE_MESSAGE_KIND;
 import static com.google.gerrit.server.change.ChangeResource.CHANGE_KIND;
 import static com.google.gerrit.server.change.CommentResource.COMMENT_KIND;
@@ -65,6 +67,8 @@
     DynamicMap.mapOf(binder(), REVIEWER_KIND);
     DynamicMap.mapOf(binder(), REVISION_KIND);
     DynamicMap.mapOf(binder(), CHANGE_EDIT_KIND);
+    DynamicMap.mapOf(binder(), CHANGE_EDIT_PUBLISH_KIND);
+    DynamicMap.mapOf(binder(), CHANGE_EDIT_REBASE_KIND);
     DynamicMap.mapOf(binder(), VOTE_KIND);
     DynamicMap.mapOf(binder(), CHANGE_MESSAGE_KIND);
 
diff --git a/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java b/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
index b356f18..36a0561 100644
--- a/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/PublishChangeEdit.java
@@ -19,8 +19,8 @@
 import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
 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.RestView;
@@ -44,28 +44,32 @@
 
 @Singleton
 public class PublishChangeEdit
-    implements ChildCollection<ChangeResource, ChangeEditResource>, AcceptsPost<ChangeResource> {
+    implements ChildCollection<ChangeResource, ChangeEditResource.Publish>,
+        AcceptsPost<ChangeResource> {
 
+  private final DynamicMap<RestView<ChangeEditResource.Publish>> views;
   private final Publish publish;
 
   @Inject
-  PublishChangeEdit(Publish publish) {
+  PublishChangeEdit(DynamicMap<RestView<ChangeEditResource.Publish>> views, Publish publish) {
+    this.views = views;
     this.publish = publish;
   }
 
   @Override
-  public DynamicMap<RestView<ChangeEditResource>> views() {
-    throw new NotImplementedException();
+  public DynamicMap<RestView<ChangeEditResource.Publish>> views() {
+    return views;
   }
 
   @Override
-  public RestView<ChangeResource> list() {
-    throw new NotImplementedException();
+  public RestView<ChangeResource> list() throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
   }
 
   @Override
-  public ChangeEditResource parse(ChangeResource parent, IdString id) {
-    throw new NotImplementedException();
+  public ChangeEditResource.Publish parse(ChangeResource parent, IdString id)
+      throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index b6fc010..b7be2a8 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -89,7 +89,10 @@
       throw new UnprocessableEntityException(input.assignee + " is not active");
     }
     try {
-      rsrc.permissions().database(db).user(assignee).check(ChangePermission.READ);
+      rsrc.permissions()
+          .database(db)
+          .absentUser(assignee.getAccountId())
+          .check(ChangePermission.READ);
     } catch (AuthException e) {
       throw new AuthException("read not permitted for " + input.assignee);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
index 7e1bb4d..47af894 100644
--- a/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChangeEdit.java
@@ -20,11 +20,10 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
 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.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.change.ChangeEditResource;
@@ -33,6 +32,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -41,28 +43,32 @@
 
 @Singleton
 public class RebaseChangeEdit
-    implements ChildCollection<ChangeResource, ChangeEditResource>, AcceptsPost<ChangeResource> {
+    implements ChildCollection<ChangeResource, ChangeEditResource.Rebase>,
+        AcceptsPost<ChangeResource> {
 
+  private final DynamicMap<RestView<ChangeEditResource.Rebase>> views;
   private final Rebase rebase;
 
   @Inject
-  RebaseChangeEdit(Rebase rebase) {
+  RebaseChangeEdit(DynamicMap<RestView<ChangeEditResource.Rebase>> views, Rebase rebase) {
+    this.views = views;
     this.rebase = rebase;
   }
 
   @Override
-  public DynamicMap<RestView<ChangeEditResource>> views() {
-    throw new NotImplementedException();
+  public DynamicMap<RestView<ChangeEditResource.Rebase>> views() {
+    return views;
   }
 
   @Override
-  public RestView<ChangeResource> list() {
-    throw new NotImplementedException();
+  public RestView<ChangeResource> list() throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
   }
 
   @Override
-  public ChangeEditResource parse(ChangeResource parent, IdString id) {
-    throw new NotImplementedException();
+  public ChangeEditResource.Rebase parse(ChangeResource parent, IdString id)
+      throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
   }
 
   @Override
@@ -71,19 +77,23 @@
   }
 
   @Singleton
-  public static class Rebase implements RestModifyView<ChangeResource, Input> {
-
+  public static class Rebase extends RetryingRestModifyView<ChangeResource, Input, Response<?>> {
     private final GitRepositoryManager repositoryManager;
     private final ChangeEditModifier editModifier;
 
     @Inject
-    Rebase(GitRepositoryManager repositoryManager, ChangeEditModifier editModifier) {
+    Rebase(
+        RetryHelper retryHelper,
+        GitRepositoryManager repositoryManager,
+        ChangeEditModifier editModifier) {
+      super(retryHelper);
       this.repositoryManager = repositoryManager;
       this.editModifier = editModifier;
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, Input in)
+    protected Response<?> applyImpl(
+        BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input in)
         throws AuthException, ResourceConflictException, IOException, OrmException,
             PermissionBackendException {
       Project.NameKey project = rsrc.getProject();
diff --git a/java/com/google/gerrit/server/restapi/project/Index.java b/java/com/google/gerrit/server/restapi/project/Index.java
index 24f32f6..5547864 100644
--- a/java/com/google/gerrit/server/restapi/project/Index.java
+++ b/java/com/google/gerrit/server/restapi/project/Index.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2018 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -16,57 +16,73 @@
 
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
-import com.google.common.io.ByteStreams;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.api.projects.IndexProjectInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.MultiProgressMonitor;
-import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.index.change.AllChangesIndexer;
-import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.concurrent.Future;
-import org.eclipse.jgit.util.io.NullOutputStream;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
 @Singleton
-public class Index implements RestModifyView<ProjectResource, ProjectInput> {
+public class Index implements RestModifyView<ProjectResource, IndexProjectInput> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final Provider<AllChangesIndexer> allChangesIndexerProvider;
-  private final ChangeIndexer indexer;
+  private final ProjectIndexer indexer;
   private final ListeningExecutorService executor;
+  private final Provider<ListChildProjects> listChildProjectsProvider;
 
   @Inject
   Index(
-      Provider<AllChangesIndexer> allChangesIndexerProvider,
-      ChangeIndexer indexer,
-      @IndexExecutor(BATCH) ListeningExecutorService executor) {
-    this.allChangesIndexerProvider = allChangesIndexerProvider;
+      ProjectIndexer indexer,
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      Provider<ListChildProjects> listChildProjectsProvider) {
     this.indexer = indexer;
     this.executor = executor;
+    this.listChildProjectsProvider = listChildProjectsProvider;
   }
 
   @Override
-  public Response.Accepted apply(ProjectResource resource, ProjectInput input) {
-    Project.NameKey project = resource.getNameKey();
-    Task mpt =
-        new MultiProgressMonitor(ByteStreams.nullOutputStream(), "Reindexing project")
-            .beginSubTask("", MultiProgressMonitor.UNKNOWN);
-    AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
-    allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
-    // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
-    // return value.
+  public Response.Accepted apply(ProjectResource rsrc, IndexProjectInput input)
+      throws IOException, AuthException, OrmException, PermissionBackendException,
+          ResourceConflictException {
+    String response = "Project " + rsrc.getName() + " submitted for reindexing";
+
+    reindexAsync(rsrc.getNameKey());
+    if (Boolean.TRUE.equals(input.indexChildren)) {
+      ListChildProjects listChildProjects = listChildProjectsProvider.get();
+      listChildProjects.setRecursive(true);
+      listChildProjects.apply(rsrc).forEach(p -> reindexAsync(new Project.NameKey(p.name)));
+
+      response += " (indexing children recursively)";
+    }
+    return Response.accepted(response);
+  }
+
+  private void reindexAsync(Project.NameKey project) {
     @SuppressWarnings("unused")
-    Future<Void> ignored =
-        executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt));
-    return Response.accepted("Project " + project + " submitted for reindexing");
+    Future<?> possiblyIgnoredError =
+        executor.submit(
+            () -> {
+              try {
+                indexer.index(project);
+              } catch (IOException e) {
+                logger.atWarning().withCause(e).log("reindexing project %s failed", project);
+              }
+            });
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/IndexChanges.java b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
new file mode 100644
index 0000000..b84f86c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.io.ByteStreams;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.change.AllChangesIndexer;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.util.io.NullOutputStream;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class IndexChanges implements RestModifyView<ProjectResource, ProjectInput> {
+
+  private final Provider<AllChangesIndexer> allChangesIndexerProvider;
+  private final ChangeIndexer indexer;
+  private final ListeningExecutorService executor;
+
+  @Inject
+  IndexChanges(
+      Provider<AllChangesIndexer> allChangesIndexerProvider,
+      ChangeIndexer indexer,
+      @IndexExecutor(BATCH) ListeningExecutorService executor) {
+    this.allChangesIndexerProvider = allChangesIndexerProvider;
+    this.indexer = indexer;
+    this.executor = executor;
+  }
+
+  @Override
+  public Response.Accepted apply(ProjectResource resource, ProjectInput input) {
+    Project.NameKey project = resource.getNameKey();
+    Task mpt =
+        new MultiProgressMonitor(ByteStreams.nullOutputStream(), "Reindexing project")
+            .beginSubTask("", MultiProgressMonitor.UNKNOWN);
+    AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
+    allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
+    // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
+    // return value.
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt));
+    return Response.accepted("Project " + project + " submitted for reindexing");
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index d2e59cd..f2047e0 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -68,6 +68,7 @@
     get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
     post(PROJECT_KIND, "gc").to(GarbageCollect.class);
     post(PROJECT_KIND, "index").to(Index.class);
+    post(PROJECT_KIND, "index.changes").to(IndexChanges.class);
 
     child(PROJECT_KIND, "branches").to(BranchesCollection.class);
     create(BRANCH_KIND).to(CreateBranch.class);
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 422c749..c5c8cf0 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -115,7 +115,7 @@
           }
           p.add(r);
         }
-        accessSection.getPermissions().add(p);
+        accessSection.addPermission(p);
       }
       sections.add(accessSection);
     }
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
index 083898b..412e0f9 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -176,6 +177,7 @@
     private final int reductionLimit;
     private final int compileLimit;
     private final PatchSetUtil patchsetUtil;
+    private Emails emails;
 
     @Inject
     Args(
@@ -187,7 +189,8 @@
         IdentifiedUser.GenericFactory userFactory,
         Provider<AnonymousUser> anonymousUser,
         @GerritServerConfig Config config,
-        PatchSetUtil patchsetUtil) {
+        PatchSetUtil patchsetUtil,
+        Emails emails) {
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
       this.repositoryManager = repositoryManager;
@@ -196,6 +199,7 @@
       this.userFactory = userFactory;
       this.anonymousUser = anonymousUser;
       this.patchsetUtil = patchsetUtil;
+      this.emails = emails;
 
       int limit = config.getInt("rules", null, "reductionLimit", 100000);
       reductionLimit = limit <= 0 ? Integer.MAX_VALUE : limit;
@@ -247,5 +251,9 @@
     public PatchSetUtil getPatchsetUtil() {
       return patchsetUtil;
     }
+
+    public Emails getEmails() {
+      return emails;
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 8e39b50..e61fc90 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -297,7 +297,8 @@
     try {
       return LabelType.checkName(name);
     } catch (IllegalArgumentException e) {
-      return LabelType.checkName("Invalid-Prolog-Rules-Label-Name--" + sanitizeLabelName(name));
+      String newName = "Invalid-Prolog-Rules-Label-Name-" + sanitizeLabelName(name);
+      return LabelType.checkName(newName.replace("--", "-"));
     }
   }
 
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index 6770732..8b9cfe3 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
@@ -34,8 +33,6 @@
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -47,6 +44,7 @@
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 public final class StoredValues {
   public static final StoredValue<Accounts> ACCOUNTS = create(Accounts.class);
@@ -74,32 +72,16 @@
     }
   }
 
-  public static final StoredValue<PatchSetInfo> PATCH_SET_INFO =
-      new StoredValue<PatchSetInfo>() {
+  public static final StoredValue<RevCommit> COMMIT =
+      new StoredValue<RevCommit>() {
         @Override
-        public PatchSetInfo createValue(Prolog engine) {
-          Change change = getChange(engine);
-          PatchSet ps = getPatchSet(engine);
-          PrologEnvironment env = (PrologEnvironment) engine.control;
-          PatchSetInfoFactory patchInfoFactory = env.getArgs().getPatchSetInfoFactory();
-          try {
-            return patchInfoFactory.get(change.getProject(), ps);
-          } catch (PatchSetInfoNotAvailableException e) {
-            throw new SystemException(e.getMessage());
-          }
-        }
-      };
-
-  public static final StoredValue<String> COMMIT_MESSAGE =
-      new StoredValue<String>() {
-        @Override
-        public String createValue(Prolog engine) {
+        public RevCommit createValue(Prolog engine) {
           Change change = getChange(engine);
           PatchSet ps = getPatchSet(engine);
           PrologEnvironment env = (PrologEnvironment) engine.control;
           PatchSetUtil patchSetUtil = env.getArgs().getPatchsetUtil();
           try {
-            return patchSetUtil.getFullCommitMessage(change.getProject(), ps);
+            return patchSetUtil.getRevCommit(change.getProject(), ps);
           } catch (IOException e) {
             throw new SystemException(e.getMessage());
           }
diff --git a/java/com/google/gerrit/server/schema/JdbcUtil.java b/java/com/google/gerrit/server/schema/JdbcUtil.java
index 2624923..dddf23a 100644
--- a/java/com/google/gerrit/server/schema/JdbcUtil.java
+++ b/java/com/google/gerrit/server/schema/JdbcUtil.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.gerrit.server.UsedAt;
+
 public class JdbcUtil {
 
   public static String hostname(String hostname) {
@@ -26,6 +28,7 @@
     return hostname;
   }
 
+  @UsedAt(UsedAt.Project.PLUGINS_ALL)
   public static String port(String port) {
     if (port != null && !port.isEmpty()) {
       return ":" + port;
diff --git a/java/com/google/gerrit/server/schema/SchemaVersion.java b/java/com/google/gerrit/server/schema/SchemaVersion.java
index e8a59d0..61e9c92 100644
--- a/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.UsedAt;
 import com.google.gwtorm.jdbc.JdbcExecutor;
 import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmException;
@@ -35,7 +36,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_168> C = Schema_168.class;
+  public static final Class<Schema_169> C = Schema_169.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
@@ -49,6 +50,7 @@
     this.versionNbr = guessVersion(getClass());
   }
 
+  @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
   public static int guessVersion(Class<?> c) {
     String n = c.getName();
     n = n.substring(n.lastIndexOf('_') + 1);
diff --git a/java/com/google/gerrit/server/schema/Schema_169.java b/java/com/google/gerrit/server/schema/Schema_169.java
new file mode 100644
index 0000000..11aebdd
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_169.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.CommentJsonMigrator;
+import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.SortedSet;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.internal.storage.file.PackInserter;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Migrate NoteDb inline comments to JSON format. */
+public class Schema_169 extends SchemaVersion {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private final AllUsersName allUsers;
+  private final CommentJsonMigrator migrator;
+  private final GitRepositoryManager repoManager;
+  private final NotesMigration notesMigration;
+
+  @Inject
+  Schema_169(
+      Provider<Schema_168> prior,
+      AllUsersName allUsers,
+      CommentJsonMigrator migrator,
+      GitRepositoryManager repoManager,
+      @GerritServerConfig Config config) {
+    super(prior);
+    this.allUsers = allUsers;
+    this.migrator = migrator;
+    this.repoManager = repoManager;
+    this.notesMigration = MutableNotesMigration.fromConfig(config);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    migrateData(ui);
+  }
+
+  private static ObjectInserter newPackInserter(Repository repo) {
+    if (!(repo instanceof FileRepository)) {
+      return repo.newObjectInserter();
+    }
+    PackInserter ins = ((FileRepository) repo).getObjectDatabase().newPackInserter();
+    ins.checkExisting(false);
+    return ins;
+  }
+
+  @VisibleForTesting
+  protected void migrateData(UpdateUI ui) throws OrmException {
+    //  If the migration hasn't started, no need to look for non-JSON
+    if (!notesMigration.commitChangeWrites()) {
+      return;
+    }
+
+    boolean ok = true;
+    ProgressMonitor pm = new TextProgressMonitor();
+    SortedSet<Project.NameKey> projects = repoManager.list();
+    pm.beginTask("Migrating projects", projects.size());
+    int skipped = 0;
+    for (Project.NameKey project : projects) {
+      try (Repository repo = repoManager.openRepository(project);
+          RevWalk rw = new RevWalk(repo);
+          ObjectInserter ins = newPackInserter(repo)) {
+        BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+        bru.setAllowNonFastForwards(true);
+        ok &= migrator.migrateChanges(project, repo, rw, ins, bru);
+        if (project.equals(allUsers)) {
+          ok &= migrator.migrateDrafts(allUsers, repo, rw, ins, bru);
+        }
+
+        if (!bru.getCommands().isEmpty()) {
+          ins.flush();
+          RefUpdateUtil.executeChecked(bru, rw);
+        } else {
+          skipped++;
+        }
+      } catch (IOException e) {
+        logger.atWarning().log("Error migrating project " + project, e);
+        ok = false;
+      }
+      pm.update(1);
+    }
+    pm.endTask();
+    ui.message(
+        "Skipped " + skipped + " project" + (skipped == 1 ? "" : "s") + " with no legacy comments");
+
+    if (!ok) {
+      throw new OrmException("Migration failed");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
index 3c6f6fd..9bf4bb2 100644
--- a/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
+++ b/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
@@ -827,8 +827,12 @@
       ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg, t);
     }
 
-    private void logDebug(String msg, Object... args) {
-      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg, args);
+    private void logDebug(String msg) {
+      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg);
+    }
+
+    private void logDebug(String msg, @Nullable Object arg) {
+      ReviewDbBatchUpdate.this.logDebug("[" + taskId + "] " + msg, arg);
     }
   }
 
diff --git a/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
similarity index 88%
rename from java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
rename to java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
index 407bbd0..56b00a5 100644
--- a/java/com/google/gerrit/sshd/commands/IndexProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.restapi.project.Index;
+import com.google.gerrit.server.restapi.project.IndexChanges;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -28,10 +28,10 @@
 import org.kohsuke.args4j.Argument;
 
 @RequiresAnyCapability({MAINTAIN_SERVER})
-@CommandMetaData(name = "project", description = "Index changes of a project")
-final class IndexProjectCommand extends SshCommand {
+@CommandMetaData(name = "changes-in-project", description = "Index changes of a project")
+final class IndexChangesInProjectCommand extends SshCommand {
 
-  @Inject private Index index;
+  @Inject private IndexChanges index;
 
   @Argument(
       index = 0,
diff --git a/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java b/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
index 599c9dc..332ed69 100644
--- a/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
+++ b/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
@@ -40,6 +40,6 @@
       command(index, IndexStartCommand.class);
     }
     command(index, IndexChangesCommand.class);
-    command(index, IndexProjectCommand.class);
+    command(index, IndexChangesInProjectCommand.class);
   }
 }
diff --git a/java/gerrit/AbstractCommitUserIdentityPredicate.java b/java/gerrit/AbstractCommitUserIdentityPredicate.java
index c2aaa76..1bfc95c 100644
--- a/java/gerrit/AbstractCommitUserIdentityPredicate.java
+++ b/java/gerrit/AbstractCommitUserIdentityPredicate.java
@@ -14,9 +14,12 @@
 
 package gerrit;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.UserIdentity;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.rules.PrologEnvironment;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.exceptions.SystemException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
@@ -24,6 +27,8 @@
 import com.googlecode.prolog_cafe.lang.StructureTerm;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
+import java.io.IOException;
+import org.eclipse.jgit.lib.PersonIdent;
 
 abstract class AbstractCommitUserIdentityPredicate extends Predicate.P3 {
   private static final SymbolTerm user = SymbolTerm.intern("user", 1);
@@ -36,7 +41,7 @@
     cont = n;
   }
 
-  protected Operation exec(Prolog engine, UserIdentity userId) throws PrologException {
+  protected Operation exec(Prolog engine, PersonIdent userId) throws PrologException {
     engine.setB0();
     Term a1 = arg1.dereference();
     Term a2 = arg2.dereference();
@@ -46,7 +51,18 @@
     Term nameTerm = Prolog.Nil;
     Term emailTerm = Prolog.Nil;
 
-    Account.Id id = userId.getAccount();
+    PrologEnvironment env = (PrologEnvironment) engine.control;
+    Emails emails = env.getArgs().getEmails();
+    Account.Id id = null;
+    try {
+      ImmutableSet<Account.Id> ids = emails.getAccountForExternal(userId.getEmailAddress());
+      if (ids.size() == 1) {
+        id = ids.iterator().next();
+      }
+    } catch (IOException e) {
+      throw new SystemException(e.getMessage());
+    }
+
     if (id == null) {
       idTerm = anonymous;
     } else {
@@ -58,7 +74,7 @@
       nameTerm = SymbolTerm.create(name);
     }
 
-    String email = userId.getEmail();
+    String email = userId.getEmailAddress();
     if (email != null && !email.equals("")) {
       emailTerm = SymbolTerm.create(email);
     }
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index 4644af87..8281d8e 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -11,5 +11,6 @@
         "//lib/flogger:api",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/prolog:runtime",
+        "@guava//jar",
     ],
 )
diff --git a/java/gerrit/PRED_commit_author_3.java b/java/gerrit/PRED_commit_author_3.java
index a876b5e..998b30e 100644
--- a/java/gerrit/PRED_commit_author_3.java
+++ b/java/gerrit/PRED_commit_author_3.java
@@ -14,13 +14,12 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.Term;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 public class PRED_commit_author_3 extends AbstractCommitUserIdentityPredicate {
   public PRED_commit_author_3(Term a1, Term a2, Term a3, Operation n) {
@@ -29,8 +28,7 @@
 
   @Override
   public Operation exec(Prolog engine) throws PrologException {
-    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
-    UserIdentity author = psInfo.getAuthor();
-    return exec(engine, author);
+    RevCommit revCommit = StoredValues.COMMIT.get(engine);
+    return exec(engine, revCommit.getAuthorIdent());
   }
 }
diff --git a/java/gerrit/PRED_commit_committer_3.java b/java/gerrit/PRED_commit_committer_3.java
index b24b004..293d8ce 100644
--- a/java/gerrit/PRED_commit_committer_3.java
+++ b/java/gerrit/PRED_commit_committer_3.java
@@ -14,13 +14,12 @@
 
 package gerrit;
 
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.Term;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 public class PRED_commit_committer_3 extends AbstractCommitUserIdentityPredicate {
   public PRED_commit_committer_3(Term a1, Term a2, Term a3, Operation n) {
@@ -29,8 +28,7 @@
 
   @Override
   public Operation exec(Prolog engine) throws PrologException {
-    PatchSetInfo psInfo = StoredValues.PATCH_SET_INFO.get(engine);
-    UserIdentity committer = psInfo.getCommitter();
-    return exec(engine, committer);
+    RevCommit revCommit = StoredValues.COMMIT.get(engine);
+    return exec(engine, revCommit.getCommitterIdent());
   }
 }
diff --git a/java/gerrit/PRED_commit_message_1.java b/java/gerrit/PRED_commit_message_1.java
index 05bb4bb..eb996d6 100644
--- a/java/gerrit/PRED_commit_message_1.java
+++ b/java/gerrit/PRED_commit_message_1.java
@@ -21,6 +21,7 @@
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
  * Returns the commit message as a symbol
@@ -40,7 +41,8 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    String commitMessage = StoredValues.COMMIT_MESSAGE.get(engine);
+    RevCommit revCommit = StoredValues.COMMIT.get(engine);
+    String commitMessage = revCommit.getFullMessage();
 
     SymbolTerm msg = SymbolTerm.create(commitMessage);
     if (!a1.unify(msg, engine.trail)) {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index ed771af..d64a9d7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -2713,9 +2713,7 @@
 
       assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
       PersonIdent expectedAuthor =
-          changeNoteUtil
-              .getLegacyChangeNoteWrite()
-              .newIdent(getAccount(admin.id), c.updated, serverIdent.get());
+          changeNoteUtil.newIdent(getAccount(admin.id), c.updated, serverIdent.get());
       assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitPatchSetCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
@@ -2723,10 +2721,7 @@
 
       RevCommit commitChangeCreation = rw.parseCommit(commitPatchSetCreation.getParent(0));
       assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
-      expectedAuthor =
-          changeNoteUtil
-              .getLegacyChangeNoteWrite()
-              .newIdent(getAccount(admin.id), c.created, serverIdent.get());
+      expectedAuthor = changeNoteUtil.newIdent(getAccount(admin.id), c.created, serverIdent.get());
       assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitChangeCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index b4a05fc..8479dd1 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -15,10 +15,13 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.util.concurrent.AtomicLongMap;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -39,7 +42,9 @@
 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.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.index.IndexExecutor;
 import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
@@ -52,6 +57,10 @@
 public class ProjectIT extends AbstractDaemonTest {
   @Inject private DynamicSet<ProjectIndexedListener> projectIndexedListeners;
 
+  @Inject
+  @IndexExecutor(BATCH)
+  private ListeningExecutorService executor;
+
   private ProjectIndexedCounter projectIndexedCounter;
   private RegistrationHandle projectIndexedCounterHandle;
 
@@ -381,6 +390,26 @@
     }
   }
 
+  @Test
+  public void reindexProject() throws Exception {
+    createProject("child", project);
+    projectIndexedCounter.clear();
+
+    gApi.projects().name(allProjects.get()).index(false);
+    projectIndexedCounter.assertReindexOf(allProjects.get());
+  }
+
+  @Test
+  public void reindexProjectWithChildren() throws Exception {
+    Project.NameKey middle = createProject("middle", project);
+    Project.NameKey leave = createProject("leave", middle);
+    projectIndexedCounter.clear();
+
+    gApi.projects().name(project.get()).index(true);
+    projectIndexedCounter.assertReindexExactly(
+        ImmutableMap.of(project.get(), 1L, middle.get(), 1L, leave.get(), 1L));
+  }
+
   private ConfigInput createTestConfigInput() {
     ConfigInput input = new ConfigInput();
     input.description = "some description";
@@ -427,5 +456,10 @@
     void assertNoReindex() {
       assertThat(countsByProject).isEmpty();
     }
+
+    void assertReindexExactly(ImmutableMap<String, Long> expected) {
+      assertThat(countsByProject.asMap()).containsExactlyEntriesIn(expected);
+      clear();
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 4c8f53f..51946e9 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -366,6 +366,102 @@
   }
 
   @Test
+  public void singleHunkAtBeginningIsFollowedByCorrectCommonLines() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 1\n", "Line one\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(0).linesOfB().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(1)
+        .commonLines()
+        .containsExactly("Line 2", "Line 3", "Line 4", "Line 5")
+        .inOrder();
+  }
+
+  @Test
+  public void singleHunkAtEndIsPrecededByCorrectCommonLines() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 5\n", "Line five\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsExactly("Line 1", "Line 2", "Line 3", "Line 4")
+        .inOrder();
+    assertThat(diffInfo).content().element(1).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfB().isNotEmpty();
+  }
+
+  @Test
+  public void singleHunkInTheMiddleIsSurroundedByCorrectCommonLines() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, filePath, content -> content.replace("Line 3\n", "Line three\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsExactly("Line 1", "Line 2")
+        .inOrder();
+    assertThat(diffInfo).content().element(1).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfB().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Line 4", "Line 5", "Line 6")
+        .inOrder();
+  }
+
+  @Test
+  public void twoHunksAreSeparatedByCorrectCommonLines() throws Exception {
+    String filePath = "a_new_file.txt";
+    String fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(fileContent));
+    gApi.changes().id(changeId).edit().publish();
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(
+        changeId,
+        filePath,
+        content -> content.replace("Line 2\n", "Line two\n").replace("Line 5\n", "Line five\n"));
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, filePath).withBase(previousPatchSetId).get();
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(1).linesOfB().isNotEmpty();
+    assertThat(diffInfo)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Line 3", "Line 4")
+        .inOrder();
+    assertThat(diffInfo).content().element(3).linesOfA().isNotEmpty();
+    assertThat(diffInfo).content().element(3).linesOfB().isNotEmpty();
+  }
+
+  @Test
   public void rebaseHunksAtStartOfFileAreIdentified() throws Exception {
     String newFileContent =
         FILE_CONTENT.replace("Line 1\n", "Line one\n").replace("Line 5\n", "Line five\n");
@@ -381,15 +477,15 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
     assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(2).isDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(44);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(4).isNotDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(50);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -412,15 +508,15 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(49);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
     assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(39);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 100");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line one hundred");
     assertThat(diffInfo).content().element(5).isDueToRebase();
@@ -450,15 +546,15 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(2).isDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 45");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty five");
     assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(54);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(6).linesOfA().containsExactly("Line 100");
     assertThat(diffInfo).content().element(6).linesOfB().containsExactly("Line one hundred");
     assertThat(diffInfo).content().element(6).isNotDueToRebase();
@@ -489,11 +585,11 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(41);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
@@ -522,7 +618,7 @@
     assertThat(diffInfo).content().element(0).linesOfA().isNull();
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line zero");
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10");
     assertThat(diffInfo)
         .content()
@@ -530,11 +626,11 @@
         .linesOfB()
         .containsExactly("Line ten", "Line ten and a half");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(29);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(60);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -562,11 +658,11 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(37);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 100");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line one hundred");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
@@ -595,15 +691,15 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().isNull();
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(8);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 10", "Line 11");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line ten");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(28);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line forty");
     assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(60);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -623,7 +719,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(39);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 40");
     assertThat(diffInfo)
         .content()
@@ -631,7 +727,7 @@
         .linesOfB()
         .containsExactly("Line modified after rebase");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(60);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -655,7 +751,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 39", "Line 40");
     assertThat(diffInfo)
         .content()
@@ -663,7 +759,7 @@
         .linesOfB()
         .containsExactly("Line thirty nine", "Line forty");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(60);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -687,7 +783,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
     assertThat(diffInfo)
         .content()
@@ -695,7 +791,7 @@
         .linesOfB()
         .containsExactly("Line forty one", "Line forty two");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(58);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -717,7 +813,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(38);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo)
         .content()
         .element(1)
@@ -729,7 +825,7 @@
         .linesOfB()
         .containsExactly("Line thirty nine", "A different line forty", "Line forty one");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(59);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -755,7 +851,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(initialPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 41", "Line 42");
     assertThat(diffInfo)
         .content()
@@ -763,11 +859,11 @@
         .linesOfB()
         .containsExactly("Line forty one", "Line forty two", "Line forty two and a half");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(17);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 60");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line sixty");
     assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -791,15 +887,15 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
     assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 3");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line three");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(4).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(4).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(4).isDueToRebase();
-    assertThat(diffInfo).content().element(5).commonLines().hasSize(95);
+    assertThat(diffInfo).content().element(5).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -834,15 +930,15 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 2");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line two");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(7);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 10");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line ten");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(90);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -879,19 +975,19 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 2");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line two");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line seven");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 7");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 9");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line nine");
     assertThat(diffInfo).content().element(5).isNotDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(8);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 18", "Line 19");
     assertThat(diffInfo)
         .content()
@@ -899,15 +995,15 @@
         .linesOfB()
         .containsExactly("Line eighteen", "Line nineteen");
     assertThat(diffInfo).content().element(7).isDueToRebase();
-    assertThat(diffInfo).content().element(8).commonLines().hasSize(29);
+    assertThat(diffInfo).content().element(8).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(9).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(9).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(9).isDueToRebase();
-    assertThat(diffInfo).content().element(10).commonLines().hasSize(9);
+    assertThat(diffInfo).content().element(10).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(11).linesOfA().containsExactly("Line 60");
     assertThat(diffInfo).content().element(11).linesOfB().containsExactly("Line sixty");
     assertThat(diffInfo).content().element(11).isNotDueToRebase();
-    assertThat(diffInfo).content().element(12).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(12).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -980,11 +1076,11 @@
     assertThat(diffInfo).content().element(0).linesOfA().containsExactly("Line 1");
     assertThat(diffInfo).content().element(0).linesOfB().containsExactly("Line one");
     assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(48);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(50);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(initialPatchSetId);
@@ -1015,15 +1111,15 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, renamedFilePath).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(44);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(50);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1059,11 +1155,11 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, newFilePath2).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(95);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
   }
 
   @Test
@@ -1096,11 +1192,11 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, newFilePath3).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(95);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
   }
 
   @Test
@@ -1125,19 +1221,19 @@
 
     DiffInfo renamedFileDiffInfo =
         getDiffRequest(changeId, CURRENT, renamedFileName).withBase(initialPatchSetId).get();
-    assertThat(renamedFileDiffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(renamedFileDiffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(renamedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(renamedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(renamedFileDiffInfo).content().element(1).isDueToRebase();
-    assertThat(renamedFileDiffInfo).content().element(2).commonLines().hasSize(95);
+    assertThat(renamedFileDiffInfo).content().element(2).commonLines().isNotEmpty();
 
     DiffInfo copiedFileDiffInfo =
         getDiffRequest(changeId, CURRENT, copyFileName).withBase(initialPatchSetId).get();
-    assertThat(copiedFileDiffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(copiedFileDiffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(copiedFileDiffInfo).content().element(1).linesOfA().containsExactly("Line 5");
     assertThat(copiedFileDiffInfo).content().element(1).linesOfB().containsExactly("Line five");
     assertThat(copiedFileDiffInfo).content().element(1).isDueToRebase();
-    assertThat(copiedFileDiffInfo).content().element(2).commonLines().hasSize(95);
+    assertThat(copiedFileDiffInfo).content().element(2).commonLines().isNotEmpty();
   }
 
   /*
@@ -1168,23 +1264,23 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 20");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line twenty");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 35");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line thirty five");
     assertThat(diffInfo).content().element(5).isDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(24);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(7).linesOfA().containsExactly("Line 60");
     assertThat(diffInfo).content().element(7).linesOfB().containsExactly("Line sixty");
     assertThat(diffInfo).content().element(7).isDueToRebase();
-    assertThat(diffInfo).content().element(8).commonLines().hasSize(40);
+    assertThat(diffInfo).content().element(8).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1239,19 +1335,19 @@
     String currentPatchSetId = gApi.changes().id(changeId).get().currentRevision;
     DiffInfo diffInfo =
         getDiffRequest(changeId, initialPatchSetId, FILE_NAME).withBase(currentPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(4);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line five");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line 5");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line twenty");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line 20");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(14);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().isNull();
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line 35");
     assertThat(diffInfo).content().element(5).isDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(65);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).revision(initialPatchSetId).files(currentPatchSetId);
@@ -1282,7 +1378,7 @@
         .intralineEditsOfB()
         .containsExactly(ImmutableList.of(5, 3));
     assertThat(diffInfo).content().element(0).isNotDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(99);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
   }
 
   @Test
@@ -1312,11 +1408,11 @@
         .intralineEditsOfB()
         .containsExactly(ImmutableList.of(5, 3));
     assertThat(diffInfo).content().element(0).isDueToRebase();
-    assertThat(diffInfo).content().element(1).commonLines().hasSize(48);
+    assertThat(diffInfo).content().element(1).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(2).linesOfA().containsExactly("Line 50");
     assertThat(diffInfo).content().element(2).linesOfB().containsExactly("Line fifty");
     assertThat(diffInfo).content().element(2).isNotDueToRebase();
-    assertThat(diffInfo).content().element(3).commonLines().hasSize(50);
+    assertThat(diffInfo).content().element(3).commonLines().isNotEmpty();
   }
 
   @Test
@@ -1335,7 +1431,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4", "{", "Line 6");
     assertThat(diffInfo)
         .content()
@@ -1343,7 +1439,7 @@
         .linesOfB()
         .containsExactly("Line four", "{", "Line six");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(94);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1372,19 +1468,19 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line four");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 6");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line six");
     assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(13);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 20");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line twenty");
     assertThat(diffInfo).content().element(5).isNotDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(80);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1411,19 +1507,19 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4");
     assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line four");
     assertThat(diffInfo).content().element(1).isDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 6");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line six");
     assertThat(diffInfo).content().element(3).isNotDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(5).linesOfA().containsExactly("Line 8");
     assertThat(diffInfo).content().element(5).linesOfB().containsExactly("Line eight");
     assertThat(diffInfo).content().element(5).isDueToRebase();
-    assertThat(diffInfo).content().element(6).commonLines().hasSize(92);
+    assertThat(diffInfo).content().element(6).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
@@ -1452,7 +1548,7 @@
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
-    assertThat(diffInfo).content().element(0).commonLines().hasSize(3);
+    assertThat(diffInfo).content().element(0).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 4", "{", "Line 6");
     assertThat(diffInfo)
         .content()
@@ -1460,11 +1556,11 @@
         .linesOfB()
         .containsExactly("Line four", "{", "Line six");
     assertThat(diffInfo).content().element(1).isNotDueToRebase();
-    assertThat(diffInfo).content().element(2).commonLines().hasSize(1);
+    assertThat(diffInfo).content().element(2).commonLines().isNotEmpty();
     assertThat(diffInfo).content().element(3).linesOfA().containsExactly("Line 8");
     assertThat(diffInfo).content().element(3).linesOfB().containsExactly("Line eight!");
     assertThat(diffInfo).content().element(3).isDueToRebase();
-    assertThat(diffInfo).content().element(4).commonLines().hasSize(92);
+    assertThat(diffInfo).content().element(4).commonLines().isNotEmpty();
 
     Map<String, FileInfo> changedFiles =
         gApi.changes().id(changeId).current().files(previousPatchSetId);
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index b362a36..80430c4 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -336,8 +336,7 @@
             c ->
                 cfg.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
                     .getPermission(c, true)
-                    .getRules()
-                    .clear());
+                    .clearRules());
   }
 
   private PushResult push(String... refSpecs) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 1de9d29..45294fb 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -433,9 +433,7 @@
       if (notesMigration.commitChangeWrites()) {
         PersonIdent committer = serverIdent.get();
         PersonIdent author =
-            noteUtil
-                .getLegacyChangeNoteWrite()
-                .newIdent(getAccount(admin.getId()), committer.getWhen(), committer);
+            noteUtil.newIdent(getAccount(admin.getId()), committer.getWhen(), committer);
         tr.branch(RefNames.changeMetaRef(c3.getId()))
             .commit()
             .author(author)
diff --git a/javatests/com/google/gerrit/acceptance/rest/AbstractRestApiBindingsTest.java b/javatests/com/google/gerrit/acceptance/rest/AbstractRestApiBindingsTest.java
index 26bc345..2bb3dca 100644
--- a/javatests/com/google/gerrit/acceptance/rest/AbstractRestApiBindingsTest.java
+++ b/javatests/com/google/gerrit/acceptance/rest/AbstractRestApiBindingsTest.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static org.apache.http.HttpStatus.SC_FORBIDDEN;
 import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
+import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
 import static org.apache.http.HttpStatus.SC_NOT_FOUND;
 
 import com.google.auto.value.AutoValue;
@@ -80,8 +81,13 @@
     String msg = String.format("%s %s returned %d: %s", method, uri, status, body);
     if (restCall.expectedResponseCode().isPresent()) {
       assertWithMessage(msg).that(status).isEqualTo(restCall.expectedResponseCode().get());
+      if (restCall.expectedMessage().isPresent()) {
+        assertWithMessage(msg).that(body).contains(restCall.expectedMessage().get());
+      }
     } else {
-      assertWithMessage(msg).that(status).isNotIn(ImmutableList.of(SC_FORBIDDEN, SC_NOT_FOUND));
+      assertWithMessage(msg)
+          .that(status)
+          .isNotIn(ImmutableList.of(SC_FORBIDDEN, SC_NOT_FOUND, SC_METHOD_NOT_ALLOWED));
       assertWithMessage(msg).that(status).isLessThan(SC_INTERNAL_SERVER_ERROR);
     }
   }
@@ -123,6 +129,8 @@
 
     abstract Optional<Integer> expectedResponseCode();
 
+    abstract Optional<String> expectedMessage();
+
     String uri(String... args) {
       String uriFormat = uriFormat();
       int expectedArgNum = StringUtils.countMatches(uriFormat, "%s");
@@ -144,6 +152,8 @@
 
       abstract Builder expectedResponseCode(int expectedResponseCode);
 
+      abstract Builder expectedMessage(String expectedMessage);
+
       abstract RestCall build();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/AccountsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/AccountsRestApiBindingsIT.java
index 8de85a9..1a401b0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/AccountsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/AccountsRestApiBindingsIT.java
@@ -14,9 +14,20 @@
 
 package com.google.gerrit.acceptance.rest;
 
+import static com.google.gerrit.acceptance.rest.AbstractRestApiBindingsTest.Method.PUT;
+import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
+import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
+
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.gpg.testing.TestKey;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
 import org.junit.Test;
 
 /**
@@ -27,6 +38,8 @@
  * AbstractRestApiBindingsTest}).
  */
 public class AccountsRestApiBindingsIT extends AbstractRestApiBindingsTest {
+  @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
+
   /**
    * Account REST endpoints to be tested, each URL contains a placeholder for the account
    * identifier.
@@ -40,7 +53,11 @@
           RestCall.put("/accounts/%s/name"),
           RestCall.delete("/accounts/%s/name"),
           RestCall.get("/accounts/%s/username"),
-          RestCall.put("/accounts/%s/username"),
+          RestCall.builder(PUT, "/accounts/%s/username")
+              // Changing the username is not allowed.
+              .expectedResponseCode(SC_METHOD_NOT_ALLOWED)
+              .expectedMessage("Username cannot be changed.")
+              .build(),
           RestCall.get("/accounts/%s/active"),
           RestCall.put("/accounts/%s/active"),
           RestCall.delete("/accounts/%s/active"),
@@ -73,7 +90,9 @@
           RestCall.post("/accounts/%s/external.ids:delete"),
           RestCall.get("/accounts/%s/oauthtoken"),
           RestCall.get("/accounts/%s/capabilities"),
-          RestCall.get("/accounts/%s/capabilities/viewPlugins"));
+          RestCall.get("/accounts/%s/capabilities/viewPlugins"),
+          RestCall.get("/accounts/%s/gpgkeys"),
+          RestCall.post("/accounts/%s/gpgkeys"));
 
   /**
    * Email REST endpoints to be tested, each URL contains a placeholders for the account and email
@@ -89,6 +108,17 @@
           RestCall.delete("/accounts/%s/emails/%s"));
 
   /**
+   * GPG key REST endpoints to be tested, each URL contains a placeholders for the account
+   * identifier and the GPG key identifier.
+   */
+  private static final ImmutableList<RestCall> GPG_KEY_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/accounts/%s/gpgkeys/%s"),
+
+          // GPG key deletion must be tested last
+          RestCall.delete("/accounts/%s/gpgkeys/%s"));
+
+  /**
    * SSH key REST endpoints to be tested, each URL contains a placeholders for the account and SSH
    * key identifier.
    */
@@ -121,6 +151,30 @@
   }
 
   @Test
+  @GerritConfig(name = "receive.enableSignedPush", value = "true")
+  public void gpgKeyEndpoints() throws Exception {
+    TestKey key = validKeyWithoutExpiration();
+    String id = key.getKeyIdString();
+
+    String email = "test1@example.com"; // email that is hard-coded in the test GPG key
+    accountsUpdateProvider
+        .get()
+        .update(
+            "Add Email",
+            admin.getId(),
+            u ->
+                u.addExternalId(
+                    ExternalId.createWithEmail(name("test"), email, admin.getId(), email)));
+
+    setApiUser(admin);
+    gApi.accounts()
+        .self()
+        .putGpgKeys(ImmutableList.of(key.getPublicKeyArmored()), ImmutableList.of());
+
+    execute(GPG_KEY_ENDPOINTS, "self", id);
+  }
+
+  @Test
   @UseSsh
   public void sshKeyEndpoints() throws Exception {
     String sshKeySeq = Integer.toString(gApi.accounts().self().listSshKeys().size());
diff --git a/javatests/com/google/gerrit/acceptance/rest/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/ChangesRestApiBindingsIT.java
index 53c544d..59c0903 100644
--- a/javatests/com/google/gerrit/acceptance/rest/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/ChangesRestApiBindingsIT.java
@@ -59,7 +59,6 @@
           RestCall.delete("/changes/%s/topic"),
           RestCall.get("/changes/%s/in"),
           RestCall.get("/changes/%s/hashtags"),
-          RestCall.post("/changes/%s/hashtags"),
           RestCall.get("/changes/%s/comments"),
           RestCall.get("/changes/%s/robotcomments"),
           RestCall.get("/changes/%s/drafts"),
@@ -98,6 +97,7 @@
               .expectedResponseCode(SC_NOT_FOUND)
               .build(),
           RestCall.get("/changes/%s/edit"),
+          RestCall.post("/changes/%s/edit"),
           RestCall.post("/changes/%s/edit:rebase"),
           RestCall.get("/changes/%s/edit:message"),
           RestCall.put("/changes/%s/edit:message"),
@@ -115,7 +115,8 @@
    * identifier.
    */
   private static final ImmutableList<RestCall> CHANGE_ENDPOINTS_NOTEDB =
-      ImmutableList.of(RestCall.post("/changes/%s/rebuild.notedb"));
+      ImmutableList.of(
+          RestCall.post("/changes/%s/hashtags"), RestCall.post("/changes/%s/rebuild.notedb"));
 
   /**
    * Reviewer REST endpoints to be tested, each URL contains placeholders for the change identifier
@@ -239,7 +240,38 @@
           RestCall.get("/changes/%s/revisions/%s/files/%s/diff"),
           RestCall.get("/changes/%s/revisions/%s/files/%s/blame"));
 
-  // TODO(ekempin): Add tests for change message and change edit REST endpoints
+  /**
+   * Change message REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier and the change message identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_MESSAGE_ENDPOINTS =
+      ImmutableList.of(RestCall.get("/changes/%s/messages/%s"));
+
+  /**
+   * Change edit REST endpoints that create an edit to be tested, each URL contains placeholders for
+   * the change identifier and the change edit identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_EDIT_CREATE_ENDPOINTS =
+      ImmutableList.of(
+          // Create change edit by editing an existing file.
+          RestCall.put("/changes/%s/edit/%s"),
+
+          // Create change edit by deleting an existing file.
+          RestCall.delete("/changes/%s/edit/%s"));
+
+  /**
+   * Change edit REST endpoints to be tested, each URL contains placeholders for the change
+   * identifier and the change edit identifier.
+   */
+  private static final ImmutableList<RestCall> CHANGE_EDIT_ENDPOINTS =
+      ImmutableList.of(
+          // Calls on existing change edit.
+          RestCall.get("/changes/%s/edit/%s"),
+          RestCall.put("/changes/%s/edit/%s"),
+          RestCall.get("/changes/%s/edit/%s/meta"),
+
+          // Delete content of a file in an existing change edit.
+          RestCall.delete("/changes/%s/edit/%s"));
 
   private static final String FILENAME = "test.txt";
 
@@ -426,6 +458,35 @@
     execute(REVISION_FILE_ENDPOINTS, changeId, "current", FILENAME);
   }
 
+  @Test
+  public void changeMessageEndpoints() throws Exception {
+    String changeId = createChange().getChangeId();
+
+    // A change message is created on change creation.
+    String changeMessageId = Iterables.getOnlyElement(gApi.changes().id(changeId).messages()).id;
+
+    execute(CHANGE_MESSAGE_ENDPOINTS, changeId, changeMessageId);
+  }
+
+  @Test
+  public void changeEditCreateEndpoints() throws Exception {
+    String changeId = createChange("Subject", FILENAME, "content").getChangeId();
+
+    // Each of the REST calls creates the change edit newly.
+    execute(
+        CHANGE_EDIT_CREATE_ENDPOINTS,
+        () -> adminRestSession.delete("/changes/" + changeId + "/edit"),
+        changeId,
+        FILENAME);
+  }
+
+  @Test
+  public void changeEditEndpoints() throws Exception {
+    String changeId = createChange("Subject", FILENAME, "content").getChangeId();
+    gApi.changes().id(changeId).edit().create();
+    execute(CHANGE_EDIT_ENDPOINTS, changeId, FILENAME);
+  }
+
   private static Comment.Range createRange(
       int startLine, int startCharacter, int endLine, int endCharacter) {
     Comment.Range range = new Comment.Range();
diff --git a/javatests/com/google/gerrit/acceptance/rest/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/ConfigRestApiBindingsIT.java
index b930b20..508d407 100644
--- a/javatests/com/google/gerrit/acceptance/rest/ConfigRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/ConfigRestApiBindingsIT.java
@@ -56,6 +56,7 @@
           RestCall.get("/config/server/summary"),
           RestCall.get("/config/server/capabilities"),
           RestCall.get("/config/server/caches"),
+          RestCall.post("/config/server/caches"),
           RestCall.get("/config/server/tasks"));
 
   /**
diff --git a/javatests/com/google/gerrit/acceptance/rest/PluginsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/PluginsRestApiBindingsIT.java
index aba677f..07ea3d0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/PluginsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/PluginsRestApiBindingsIT.java
@@ -42,9 +42,9 @@
           RestCall.get("/plugins/%s/gerrit~status"),
 
           // POST (and PUT) requests don't require the 'gerrit~' prefix in front of the view name.
-          RestCall.post("/plugins/%s/enable"),
-          RestCall.post("/plugins/%s/disable"),
-          RestCall.post("/plugins/%s/reload"),
+          RestCall.post("/plugins/%s/gerrit~enable"),
+          RestCall.post("/plugins/%s/gerrit~disable"),
+          RestCall.post("/plugins/%s/gerrit~reload"),
 
           // Plugin deletion must be tested last
           RestCall.delete("/plugins/%s"));
diff --git a/javatests/com/google/gerrit/acceptance/rest/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/ProjectsRestApiBindingsIT.java
index d59f037..a86a739 100644
--- a/javatests/com/google/gerrit/acceptance/rest/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/ProjectsRestApiBindingsIT.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.acceptance.rest.AbstractRestApiBindingsTest.Method.GET;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
 import static com.google.gerrit.server.restapi.project.DashboardsCollection.DEFAULT_DASHBOARD_NAME;
+import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
 import static org.apache.http.HttpStatus.SC_NOT_FOUND;
 
 import com.google.common.collect.ImmutableList;
@@ -93,7 +94,11 @@
           RestCall.get("/projects/%s/branches/%s"),
           RestCall.put("/projects/%s/branches/%s"),
           RestCall.get("/projects/%s/branches/%s/mergeable"),
-          RestCall.get("/projects/%s/branches/%s/reflog"),
+          RestCall.builder(GET, "/projects/%s/branches/%s/reflog")
+              // The tests use DfsRepository which does not support getting the reflog.
+              .expectedResponseCode(SC_METHOD_NOT_ALLOWED)
+              .expectedMessage("reflog not supported on")
+              .build(),
           RestCall.builder(GET, "/projects/%s/branches/%s/files")
               // GET /projects/<project>/branches/<branch>/files is not implemented
               .expectedResponseCode(SC_NOT_FOUND)
diff --git a/javatests/com/google/gerrit/acceptance/rest/RootCollectionsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/RootCollectionsRestApiBindingsIT.java
index 131ed1a..a2c4ea6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RootCollectionsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RootCollectionsRestApiBindingsIT.java
@@ -18,6 +18,7 @@
 import static org.apache.http.HttpStatus.SC_NOT_FOUND;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.GerritConfig;
 import org.junit.Test;
 
 /**
@@ -39,6 +40,7 @@
               .expectedResponseCode(SC_NOT_FOUND)
               .build(),
           RestCall.get("/changes/"),
+          RestCall.post("/changes/"),
           RestCall.get("/groups/"),
           RestCall.put("/groups/new-group"),
           RestCall.get("/plugins/"),
@@ -47,6 +49,7 @@
           RestCall.put("/projects/new-project"));
 
   @Test
+  @GerritConfig(name = "plugins.allowRemoteAdmin", value = "true")
   public void rootEndpoints() throws Exception {
     execute(ROOT_ENDPOINTS);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index e6f61fa..eb80092 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -189,7 +189,6 @@
     testVoteOnBehalfOfWithComment();
   }
 
-  @GerritConfig(name = "notedb.writeJson", value = "true")
   @Test
   public void voteOnBehalfOfWithCommentWritingJson() throws Exception {
     assume().that(notesMigration.readChanges()).isTrue();
@@ -225,7 +224,6 @@
     assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
   }
 
-  @GerritConfig(name = "notedb.writeJson", value = "true")
   @Test
   public void voteOnBehalfOfWithRobotComment() throws Exception {
     assume().that(notesMigration.readChanges()).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index c723082..09c7e0b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -289,9 +289,7 @@
       assertThat(commit.getShortMessage()).isEqualTo("Create change");
 
       PersonIdent expectedAuthor =
-          changeNoteUtil
-              .getLegacyChangeNoteWrite()
-              .newIdent(getAccount(admin.id), c.created, serverIdent.get());
+          changeNoteUtil.newIdent(getAccount(admin.id), c.created, serverIdent.get());
       assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
 
       assertThat(commit.getCommitterIdent())
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 5555185..211af59 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -950,8 +950,6 @@
   @Test
   public void jsonCommentHasLegacyFormatFalse() throws Exception {
     assume().that(notesMigration.readChanges()).isTrue();
-    assertThat(noteUtil.getChangeNoteJson().getWriteJson()).isTrue();
-
     PushOneCommit.Result result = createChange();
     Change.Id changeId = result.getChange().getId();
     addComment(result.getChangeId(), "comment");
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index ea44bd7..29c043a 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -902,9 +902,7 @@
     }
     PersonIdent committer = serverIdent.get();
     PersonIdent author =
-        noteUtil
-            .getLegacyChangeNoteWrite()
-            .newIdent(getAccount(admin.getId()), committer.getWhen(), committer);
+        noteUtil.newIdent(getAccount(admin.getId()), committer.getWhen(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java
deleted file mode 100644
index e029e7a..0000000
--- a/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.server.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.inject.Inject;
-import java.util.Collection;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Before;
-import org.junit.Test;
-
-@NoHttpd
-public class LegacyCommentsIT extends AbstractDaemonTest {
-  @Inject private ChangeNoteUtil noteUtil;
-
-  @ConfigSuite.Default
-  public static Config writeJsonFalseConfig() {
-    Config c = new Config();
-    c.setBoolean("noteDb", null, "writeJson", false);
-    return c;
-  }
-
-  @Before
-  public void setUp() {
-    setApiUser(user);
-  }
-
-  @Test
-  public void legacyCommentHasLegacyFormatTrue() throws Exception {
-    assume().that(notesMigration.readChanges()).isTrue();
-    assertThat(noteUtil.getChangeNoteJson().getWriteJson()).isFalse();
-
-    PushOneCommit.Result result = createChange();
-    Change.Id changeId = result.getChange().getId();
-
-    CommentInput cin = new CommentInput();
-    cin.message = "comment";
-    cin.path = PushOneCommit.FILE_NAME;
-
-    ReviewInput rin = new ReviewInput();
-    rin.comments = ImmutableMap.of(cin.path, ImmutableList.of(cin));
-    gApi.changes().id(changeId.get()).current().review(rin);
-
-    Collection<Comment> comments =
-        notesFactory.createChecked(db, project, changeId).getComments().values();
-    assertThat(comments).hasSize(1);
-    Comment comment = comments.iterator().next();
-    assertThat(comment.message).isEqualTo("comment");
-    assertThat(comment.legacyFormat).isTrue();
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
index 834dbfa..bcae987 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/ReflogIT.java
@@ -25,16 +25,10 @@
 import java.io.File;
 import org.eclipse.jgit.lib.ReflogEntry;
 import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
 import org.junit.Test;
 
 @UseLocalDisk
 public class ReflogIT extends AbstractDaemonTest {
-  @Before
-  public void setUp() throws Exception {
-    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
-  }
-
   @Test
   public void guessRestApiInReflog() throws Exception {
     assume().that(notesMigration.disableChangeReviewDb()).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index bf01a21..98dca3e 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -71,21 +71,17 @@
 
   @Test
   public void testUserPredicate() throws Exception {
-    // This test results in a RULE_ERROR as Prolog tries to find accounts by email, using the index.
-    // TODO(maximeg) get OK results
     modifySubmitRules(
         String.format(
             "gerrit:commit_author(user(%d), '%s', '%s')",
             user.getId().get(), user.fullName, user.email));
-    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
   }
 
   @Test
   public void testCommitAuthorPredicate() throws Exception {
-    // This test results in a RULE_ERROR as Prolog tries to find accounts by email, using the index.
-    // TODO(maximeg) get OK results
     modifySubmitRules("gerrit:commit_author(Id)");
-    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.RULE_ERROR);
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
   }
 
   private SubmitRecord.Status statusForRule() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
index 208f380..25bb7a6 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -102,7 +102,7 @@
     enableChangeIndexWrites();
 
     changeIndexedCounter.clear();
-    String cmd = Joiner.on(" ").join("gerrit", "index", "project", project.get());
+    String cmd = Joiner.on(" ").join("gerrit", "index", "changes-in-project", project.get());
     adminSshSession.exec(cmd);
     adminSshSession.assertSuccess();
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index 5694bd0..cc86c0b 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -93,7 +93,8 @@
                 }
               }),
           "index",
-          ImmutableList.of("changes", "project"), // "activate" and "start" are not included
+          ImmutableList.of(
+              "changes", "changes-in-project"), // "activate" and "start" are not included
           "logging",
           ImmutableList.of("ls", "set"),
           "plugin",
diff --git a/javatests/com/google/gerrit/common/data/AccessSectionTest.java b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
new file mode 100644
index 0000000..ecdb3c8
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
@@ -0,0 +1,253 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class AccessSectionTest {
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  private static final String REF_PATTERN = "refs/heads/master";
+
+  private AccessSection accessSection;
+
+  @Before
+  public void setup() {
+    this.accessSection = new AccessSection(REF_PATTERN);
+  }
+
+  @Test
+  public void getName() {
+    assertThat(accessSection.getName()).isEqualTo(REF_PATTERN);
+  }
+
+  @Test
+  public void getEmptyPermissions() {
+    assertThat(accessSection.getPermissions()).isNotNull();
+    assertThat(accessSection.getPermissions()).isEmpty();
+  }
+
+  @Test
+  public void setAndGetPermissions() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.setPermissions(ImmutableList.of(submitPermission));
+    assertThat(accessSection.getPermissions()).containsExactly(submitPermission);
+  }
+
+  @Test
+  public void cannotSetDuplicatePermissions() {
+    exception.expect(IllegalArgumentException.class);
+    accessSection.setPermissions(
+        ImmutableList.of(new Permission(Permission.ABANDON), new Permission(Permission.ABANDON)));
+  }
+
+  @Test
+  public void cannotSetPermissionsWithConflictingNames() {
+    Permission abandonPermissionLowerCase =
+        new Permission(Permission.ABANDON.toLowerCase(Locale.US));
+    Permission abandonPermissionUpperCase =
+        new Permission(Permission.ABANDON.toUpperCase(Locale.US));
+
+    exception.expect(IllegalArgumentException.class);
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermissionLowerCase, abandonPermissionUpperCase));
+  }
+
+  @Test
+  public void getNonExistingPermission() {
+    assertThat(accessSection.getPermission("non-existing")).isNull();
+    assertThat(accessSection.getPermission("non-existing", false)).isNull();
+  }
+
+  @Test
+  public void getPermission() {
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.setPermissions(ImmutableList.of(submitPermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
+  }
+
+  @Test
+  public void getPermissionWithOtherCase() {
+    Permission submitPermissionLowerCase = new Permission(Permission.SUBMIT.toLowerCase(Locale.US));
+    accessSection.setPermissions(ImmutableList.of(submitPermissionLowerCase));
+    assertThat(accessSection.getPermission(Permission.SUBMIT.toUpperCase(Locale.US)))
+        .isEqualTo(submitPermissionLowerCase);
+  }
+
+  @Test
+  public void createMissingPermissionOnGet() {
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    assertThat(accessSection.getPermission(Permission.SUBMIT, true))
+        .isEqualTo(new Permission(Permission.SUBMIT));
+  }
+
+  @Test
+  public void addPermission() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.addPermission(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission, submitPermission)
+        .inOrder();
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    List<Permission> permissions = new ArrayList<>();
+    permissions.add(abandonPermission);
+    permissions.add(rebasePermission);
+    accessSection.setPermissions(permissions);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    permissions.add(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasRetrievedFromAccessSection() {
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+    accessSection.getPermissions().add(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+
+    List<Permission> permissions = new ArrayList<>();
+    permissions.add(new Permission(Permission.ABANDON));
+    permissions.add(new Permission(Permission.REBASE));
+    accessSection.setPermissions(permissions);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    accessSection.getPermissions().add(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+  }
+
+  @Test
+  public void removePermission() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
+
+    accessSection.remove(submitPermission);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+  }
+
+  @Test
+  public void removePermissionByName() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
+
+    accessSection.removePermission(Permission.SUBMIT);
+    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+  }
+
+  @Test
+  public void removePermissionByNameOtherCase() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    String submitLowerCase = Permission.SUBMIT.toLowerCase(Locale.US);
+    String submitUpperCase = Permission.SUBMIT.toUpperCase(Locale.US);
+    Permission submitPermissionLowerCase = new Permission(submitLowerCase);
+
+    accessSection.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission, submitPermissionLowerCase));
+    assertThat(accessSection.getPermission(submitLowerCase)).isNotNull();
+    assertThat(accessSection.getPermission(submitUpperCase)).isNotNull();
+
+    accessSection.removePermission(submitUpperCase);
+    assertThat(accessSection.getPermission(submitLowerCase)).isNull();
+    assertThat(accessSection.getPermission(submitUpperCase)).isNull();
+    assertThat(accessSection.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission)
+        .inOrder();
+  }
+
+  @Test
+  public void mergeAccessSections() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+    Permission submitPermission = new Permission(Permission.SUBMIT);
+
+    AccessSection accessSection1 = new AccessSection("refs/heads/foo");
+    accessSection1.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+
+    AccessSection accessSection2 = new AccessSection("refs/heads/bar");
+    accessSection2.setPermissions(ImmutableList.of(rebasePermission, submitPermission));
+
+    accessSection1.mergeFrom(accessSection2);
+    assertThat(accessSection1.getPermissions())
+        .containsExactly(abandonPermission, rebasePermission, submitPermission)
+        .inOrder();
+  }
+
+  @Test
+  public void testEquals() {
+    Permission abandonPermission = new Permission(Permission.ABANDON);
+    Permission rebasePermission = new Permission(Permission.REBASE);
+
+    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
+
+    AccessSection accessSectionSamePermissionsOtherRef = new AccessSection("refs/heads/other");
+    accessSectionSamePermissionsOtherRef.setPermissions(
+        ImmutableList.of(abandonPermission, rebasePermission));
+    assertThat(accessSection.equals(accessSectionSamePermissionsOtherRef)).isFalse();
+
+    AccessSection accessSectionOther = new AccessSection(REF_PATTERN);
+    accessSectionOther.setPermissions(ImmutableList.of(abandonPermission));
+    assertThat(accessSection.equals(accessSectionOther)).isFalse();
+
+    accessSectionOther.addPermission(rebasePermission);
+    assertThat(accessSection.equals(accessSectionOther)).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java b/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
index 4c4c769..dcd3c05 100644
--- a/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
+++ b/javatests/com/google/gerrit/common/data/EncodePathSeparatorTest.java
@@ -14,20 +14,20 @@
 
 package com.google.gerrit.common.data;
 
-import static org.junit.Assert.assertEquals;
+import static com.google.common.truth.Truth.assertThat;
 
 import org.junit.Test;
 
 public class EncodePathSeparatorTest {
   @Test
   public void defaultBehaviour() {
-    assertEquals("a/b", new GitwebType().replacePathSeparator("a/b"));
+    assertThat(new GitwebType().replacePathSeparator("a/b")).isEqualTo("a/b");
   }
 
   @Test
   public void exclamationMark() {
     GitwebType gitwebType = new GitwebType();
     gitwebType.setPathSeparator('!');
-    assertEquals("a!b", gitwebType.replacePathSeparator("a/b"));
+    assertThat(gitwebType.replacePathSeparator("a/b")).isEqualTo("a!b");
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
index 0f067c4..b646d2b 100644
--- a/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
+++ b/javatests/com/google/gerrit/common/data/ParameterizedStringTest.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.common.data;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertTrue;
+import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableMap;
 import java.util.HashMap;
@@ -26,155 +24,148 @@
 public class ParameterizedStringTest {
   @Test
   public void emptyString() {
-    final ParameterizedString p = new ParameterizedString("");
-    assertEquals("", p.getPattern());
-    assertEquals("", p.getRawPattern());
-    assertTrue(p.getParameterNames().isEmpty());
+    ParameterizedString p = new ParameterizedString("");
+    assertThat(p.getPattern()).isEmpty();
+    assertThat(p.getRawPattern()).isEmpty();
+    assertThat(p.getParameterNames()).isEmpty();
 
-    final Map<String, String> a = new HashMap<>();
-    assertNotNull(p.bind(a));
-    assertEquals(0, p.bind(a).length);
-    assertEquals("", p.replace(a));
+    Map<String, String> a = new HashMap<>();
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).isEmpty();
+    assertThat(p.replace(a)).isEmpty();
   }
 
   @Test
   public void asis1() {
-    final ParameterizedString p = ParameterizedString.asis("${bar}c");
-    assertEquals("${bar}c", p.getPattern());
-    assertEquals("${bar}c", p.getRawPattern());
-    assertTrue(p.getParameterNames().isEmpty());
+    ParameterizedString p = ParameterizedString.asis("${bar}c");
+    assertThat(p.getPattern()).isEqualTo("${bar}c");
+    assertThat(p.getRawPattern()).isEqualTo("${bar}c");
+    assertThat(p.getParameterNames()).isEmpty();
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("bar", "frobinator");
-    assertNotNull(p.bind(a));
-    assertEquals(0, p.bind(a).length);
-    assertEquals("${bar}c", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).isEmpty();
+    assertThat(p.replace(a)).isEqualTo("${bar}c");
   }
 
   @Test
   public void replace1() {
-    final ParameterizedString p = new ParameterizedString("${bar}c");
-    assertEquals("${bar}c", p.getPattern());
-    assertEquals("{0}c", p.getRawPattern());
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("bar"));
+    ParameterizedString p = new ParameterizedString("${bar}c");
+    assertThat(p.getPattern()).isEqualTo("${bar}c");
+    assertThat(p.getRawPattern()).isEqualTo("{0}c");
+    assertThat(p.getParameterNames()).containsExactly("bar");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("bar", "frobinator");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("frobinator", p.bind(a)[0]);
-    assertEquals("frobinatorc", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("frobinator");
+    assertThat(p.replace(a)).isEqualTo("frobinatorc");
   }
 
   @Test
   public void replace2() {
-    final ParameterizedString p = new ParameterizedString("a${bar}c");
-    assertEquals("a${bar}c", p.getPattern());
-    assertEquals("a{0}c", p.getRawPattern());
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("bar"));
+    ParameterizedString p = new ParameterizedString("a${bar}c");
+    assertThat(p.getPattern()).isEqualTo("a${bar}c");
+    assertThat(p.getRawPattern()).isEqualTo("a{0}c");
+    assertThat(p.getParameterNames()).containsExactly("bar");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("bar", "frobinator");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("frobinator", p.bind(a)[0]);
-    assertEquals("afrobinatorc", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("frobinator");
+    assertThat(p.replace(a)).isEqualTo("afrobinatorc");
   }
 
   @Test
   public void replace3() {
-    final ParameterizedString p = new ParameterizedString("a${bar}");
-    assertEquals("a${bar}", p.getPattern());
-    assertEquals("a{0}", p.getRawPattern());
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("bar"));
+    ParameterizedString p = new ParameterizedString("a${bar}");
+    assertThat(p.getPattern()).isEqualTo("a${bar}");
+    assertThat(p.getRawPattern()).isEqualTo("a{0}");
+    assertThat(p.getParameterNames()).containsExactly("bar");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("bar", "frobinator");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("frobinator", p.bind(a)[0]);
-    assertEquals("afrobinator", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("frobinator");
+    assertThat(p.replace(a)).isEqualTo("afrobinator");
   }
 
   @Test
   public void replace4() {
-    final ParameterizedString p = new ParameterizedString("a${bar}c");
-    assertEquals("a${bar}c", p.getPattern());
-    assertEquals("a{0}c", p.getRawPattern());
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("bar"));
+    ParameterizedString p = new ParameterizedString("a${bar}c");
+    assertThat(p.getPattern()).isEqualTo("a${bar}c");
+    assertThat(p.getRawPattern()).isEqualTo("a{0}c");
+    assertThat(p.getParameterNames()).containsExactly("bar");
 
-    final Map<String, String> a = new HashMap<>();
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("", p.bind(a)[0]);
-    assertEquals("ac", p.replace(a));
+    Map<String, String> a = new HashMap<>();
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEmpty();
+    assertThat(p.replace(a)).isEqualTo("ac");
   }
 
   @Test
   public void replaceToLowerCase() {
-    final ParameterizedString p = new ParameterizedString("${a.toLowerCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toLowerCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
 
     a.put("a", "FOO");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
   public void replaceToUpperCase() {
-    final ParameterizedString p = new ParameterizedString("${a.toUpperCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toUpperCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
 
     a.put("a", "FOO");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
   }
 
   @Test
   public void replaceLocalName() {
-    final ParameterizedString p = new ParameterizedString("${a.localPart}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.localPart}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
 
     a.put("a", "foo");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
@@ -182,226 +173,216 @@
     ParameterizedString p =
         new ParameterizedString(
             "hi, ${userName.toUpperCase},your eamil address is '${email.toLowerCase.localPart}'.right?");
-    assertEquals(2, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("userName"));
-    assertTrue(p.getParameterNames().contains("email"));
+    assertThat(p.getParameterNames()).containsExactly("userName", "email");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
     a.put("userName", "firstName lastName");
     a.put("email", "FIRSTNAME.LASTNAME@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(2, p.bind(a).length);
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(2);
 
-    assertEquals("FIRSTNAME LASTNAME", p.bind(a)[0]);
-    assertEquals("firstname.lastname", p.bind(a)[1]);
-    assertEquals(
-        "hi, FIRSTNAME LASTNAME,your eamil address is 'firstname.lastname'.right?", p.replace(a));
+    assertThat(p.bind(a)[0]).isEqualTo("FIRSTNAME LASTNAME");
+    assertThat(p.bind(a)[1]).isEqualTo("firstname.lastname");
+    assertThat(p.replace(a))
+        .isEqualTo("hi, FIRSTNAME LASTNAME,your eamil address is 'firstname.lastname'.right?");
   }
 
   @Test
   public void replaceToUpperCaseToLowerCase() {
-    final ParameterizedString p = new ParameterizedString("${a.toUpperCase.toLowerCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toUpperCase.toLowerCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo@example.com", p.bind(a)[0]);
-    assertEquals("foo@example.com", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo@example.com");
+    assertThat(p.replace(a)).isEqualTo("foo@example.com");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo@example.com", p.bind(a)[0]);
-    assertEquals("foo@example.com", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo@example.com");
+    assertThat(p.replace(a)).isEqualTo("foo@example.com");
   }
 
   @Test
   public void replaceToUpperCaseLocalName() {
-    final ParameterizedString p = new ParameterizedString("${a.toUpperCase.localPart}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toUpperCase.localPart}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
   }
 
   @Test
   public void replaceToUpperCaseAnUndefinedMethod() {
-    final ParameterizedString p = new ParameterizedString("${a.toUpperCase.anUndefinedMethod}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toUpperCase.anUndefinedMethod}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO@EXAMPLE.COM", p.bind(a)[0]);
-    assertEquals("FOO@EXAMPLE.COM", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO@EXAMPLE.COM");
+    assertThat(p.replace(a)).isEqualTo("FOO@EXAMPLE.COM");
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO@EXAMPLE.COM", p.bind(a)[0]);
-    assertEquals("FOO@EXAMPLE.COM", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO@EXAMPLE.COM");
+    assertThat(p.replace(a)).isEqualTo("FOO@EXAMPLE.COM");
   }
 
   @Test
   public void replaceLocalNameToUpperCase() {
-    final ParameterizedString p = new ParameterizedString("${a.localPart.toUpperCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.localPart.toUpperCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
   }
 
   @Test
   public void replaceLocalNameToLowerCase() {
-    final ParameterizedString p = new ParameterizedString("${a.localPart.toLowerCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.localPart.toLowerCase}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
   public void replaceLocalNameAnUndefinedMethod() {
-    final ParameterizedString p = new ParameterizedString("${a.localPart.anUndefinedMethod}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.localPart.anUndefinedMethod}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO", p.bind(a)[0]);
-    assertEquals("FOO", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO");
+    assertThat(p.replace(a)).isEqualTo("FOO");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
   public void replaceToLowerCaseToUpperCase() {
-    final ParameterizedString p = new ParameterizedString("${a.toLowerCase.toUpperCase}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toLowerCase.toUpperCase}");
+    assertThat(p.getParameterNames()).hasSize(1);
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO@EXAMPLE.COM", p.bind(a)[0]);
-    assertEquals("FOO@EXAMPLE.COM", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO@EXAMPLE.COM");
+    assertThat(p.replace(a)).isEqualTo("FOO@EXAMPLE.COM");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("FOO@EXAMPLE.COM", p.bind(a)[0]);
-    assertEquals("FOO@EXAMPLE.COM", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("FOO@EXAMPLE.COM");
+    assertThat(p.replace(a)).isEqualTo("FOO@EXAMPLE.COM");
   }
 
   @Test
   public void replaceToLowerCaseLocalName() {
-    final ParameterizedString p = new ParameterizedString("${a.toLowerCase.localPart}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toLowerCase.localPart}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo", p.bind(a)[0]);
-    assertEquals("foo", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo");
+    assertThat(p.replace(a)).isEqualTo("foo");
   }
 
   @Test
   public void replaceToLowerCaseAnUndefinedMethod() {
-    final ParameterizedString p = new ParameterizedString("${a.toLowerCase.anUndefinedMethod}");
-    assertEquals(1, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("a"));
+    ParameterizedString p = new ParameterizedString("${a.toLowerCase.anUndefinedMethod}");
+    assertThat(p.getParameterNames()).containsExactly("a");
 
-    final Map<String, String> a = new HashMap<>();
+    Map<String, String> a = new HashMap<>();
 
     a.put("a", "foo@example.com");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo@example.com", p.bind(a)[0]);
-    assertEquals("foo@example.com", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo@example.com");
+    assertThat(p.replace(a)).isEqualTo("foo@example.com");
 
     a.put("a", "FOO@EXAMPLE.COM");
-    assertNotNull(p.bind(a));
-    assertEquals(1, p.bind(a).length);
-    assertEquals("foo@example.com", p.bind(a)[0]);
-    assertEquals("foo@example.com", p.replace(a));
+    assertThat(p.bind(a)).isNotNull();
+    assertThat(p.bind(a)).hasLength(1);
+    assertThat(p.bind(a)[0]).isEqualTo("foo@example.com");
+    assertThat(p.replace(a)).isEqualTo("foo@example.com");
   }
 
   @Test
   public void replaceSubmitTooltipWithVariables() {
     ParameterizedString p = new ParameterizedString("Submit patch set ${patchSet} into ${branch}");
-    assertEquals(2, p.getParameterNames().size());
-    assertTrue(p.getParameterNames().contains("patchSet"));
+    assertThat(p.getParameterNames()).hasSize(2);
+    assertThat(p.getParameterNames()).containsExactly("patchSet", "branch");
 
     Map<String, String> params =
         ImmutableMap.of(
             "patchSet", "42",
             "branch", "foo");
-    assertNotNull(p.bind(params));
-    assertEquals(2, p.bind(params).length);
-    assertEquals("42", p.bind(params)[0]);
-    assertEquals("foo", p.bind(params)[1]);
-    assertEquals("Submit patch set 42 into foo", p.replace(params));
+    assertThat(p.bind(params)).isNotNull();
+    assertThat(p.bind(params)).hasLength(2);
+    assertThat(p.bind(params)[0]).isEqualTo("42");
+    assertThat(p.bind(params)[1]).isEqualTo("foo");
+    assertThat(p.replace(params)).isEqualTo("Submit patch set 42 into foo");
   }
 
   @Test
@@ -411,7 +392,7 @@
         ImmutableMap.of(
             "patchSet", "42",
             "branch", "foo");
-    assertEquals(0, p.bind(params).length);
-    assertEquals("Submit patch set 40 into master", p.replace(params));
+    assertThat(p.bind(params)).isEmpty();
+    assertThat(p.replace(params)).isEqualTo("Submit patch set 40 into master");
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/PermissionTest.java b/javatests/com/google/gerrit/common/data/PermissionTest.java
new file mode 100644
index 0000000..f76323f
--- /dev/null
+++ b/javatests/com/google/gerrit/common/data/PermissionTest.java
@@ -0,0 +1,341 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common.data;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PermissionTest {
+  private static final String PERMISSION_NAME = "foo";
+
+  private Permission permission;
+
+  @Before
+  public void setup() {
+    this.permission = new Permission(PERMISSION_NAME);
+  }
+
+  @Test
+  public void isPermission() {
+    assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
+    assertThat(Permission.isPermission("no-permission")).isFalse();
+
+    assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.isPermission("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void hasRange() {
+    assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
+    assertThat(Permission.hasRange("no-permission")).isFalse();
+
+    assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.hasRange("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void isLabel() {
+    assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isLabel("no-permission")).isFalse();
+
+    assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
+    assertThat(Permission.isLabel("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void isLabelAs() {
+    assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isLabelAs("no-permission")).isFalse();
+
+    assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
+    assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.isLabelAs("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void forLabel() {
+    assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
+  }
+
+  @Test
+  public void forLabelAs() {
+    assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
+  }
+
+  @Test
+  public void extractLabel() {
+    assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
+    assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
+        .isEqualTo("Code-Review");
+    assertThat(Permission.extractLabel("Code-Review")).isNull();
+    assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
+  }
+
+  @Test
+  public void canBeOnAllProjects() {
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
+        .isTrue();
+    assertThat(
+            Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
+        .isTrue();
+
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
+        .isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
+        .isTrue();
+  }
+
+  @Test
+  public void getName() {
+    assertThat(permission.getName()).isEqualTo(PERMISSION_NAME);
+  }
+
+  @Test
+  public void getLabel() {
+    assertThat(new Permission(Permission.LABEL + "Code-Review").getLabel())
+        .isEqualTo("Code-Review");
+    assertThat(new Permission(Permission.LABEL_AS + "Code-Review").getLabel())
+        .isEqualTo("Code-Review");
+    assertThat(new Permission("Code-Review").getLabel()).isNull();
+    assertThat(new Permission(Permission.ABANDON).getLabel()).isNull();
+  }
+
+  @Test
+  public void exclusiveGroup() {
+    assertThat(permission.getExclusiveGroup()).isFalse();
+
+    permission.setExclusiveGroup(true);
+    assertThat(permission.getExclusiveGroup()).isTrue();
+
+    permission.setExclusiveGroup(false);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void noExclusiveGroupOnOwnerPermission() {
+    Permission permission = new Permission(Permission.OWNER);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+
+    permission.setExclusiveGroup(true);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void getEmptyRules() {
+    assertThat(permission.getRules()).isNotNull();
+    assertThat(permission.getRules()).isEmpty();
+  }
+
+  @Test
+  public void setAndGetRules() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+
+    PermissionRule permissionRule3 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3"));
+    permission.setRules(ImmutableList.of(permissionRule3));
+    assertThat(permission.getRules()).containsExactly(permissionRule3);
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+
+    List<PermissionRule> rules = new ArrayList<>();
+    rules.add(permissionRule1);
+    rules.add(permissionRule2);
+    permission.setRules(rules);
+    assertThat(permission.getRule(groupReference3)).isNull();
+
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+    rules.add(permissionRule3);
+    assertThat(permission.getRule(groupReference3)).isNull();
+  }
+
+  @Test
+  public void cannotAddPermissionByModifyingListThatWasRetrievedFromAccessSection() {
+    GroupReference groupReference1 = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
+    permission.getRules().add(permissionRule1);
+    assertThat(permission.getRule(groupReference1)).isNull();
+
+    List<PermissionRule> rules = new ArrayList<>();
+    rules.add(new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2")));
+    rules.add(new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3")));
+    permission.setRules(rules);
+    assertThat(permission.getRule(groupReference1)).isNull();
+    permission.getRules().add(permissionRule1);
+    assertThat(permission.getRule(groupReference1)).isNull();
+  }
+
+  @Test
+  public void getNonExistingRule() {
+    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    assertThat(permission.getRule(groupReference)).isNull();
+    assertThat(permission.getRule(groupReference, false)).isNull();
+  }
+
+  @Test
+  public void getRule() {
+    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    PermissionRule permissionRule = new PermissionRule(groupReference);
+    permission.setRules(ImmutableList.of(permissionRule));
+    assertThat(permission.getRule(groupReference)).isEqualTo(permissionRule);
+  }
+
+  @Test
+  public void createMissingRuleOnGet() {
+    GroupReference groupReference = new GroupReference(new AccountGroup.UUID("uuid-1"), "group1");
+    assertThat(permission.getRule(groupReference)).isNull();
+
+    assertThat(permission.getRule(groupReference, true))
+        .isEqualTo(new PermissionRule(groupReference));
+  }
+
+  @Test
+  public void addRule() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+    assertThat(permission.getRule(groupReference3)).isNull();
+
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+    permission.add(permissionRule3);
+    assertThat(permission.getRule(groupReference3)).isEqualTo(permissionRule3);
+    assertThat(permission.getRules())
+        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
+        .inOrder();
+  }
+
+  @Test
+  public void removeRule() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
+    assertThat(permission.getRule(groupReference3)).isNotNull();
+
+    permission.remove(permissionRule3);
+    assertThat(permission.getRule(groupReference3)).isNull();
+    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+  }
+
+  @Test
+  public void removeRuleByGroupReference() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    GroupReference groupReference3 = new GroupReference(new AccountGroup.UUID("uuid-3"), "group3");
+    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
+    assertThat(permission.getRule(groupReference3)).isNotNull();
+
+    permission.removeRule(groupReference3);
+    assertThat(permission.getRule(groupReference3)).isNull();
+    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
+  }
+
+  @Test
+  public void clearRules() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    assertThat(permission.getRules()).isNotEmpty();
+
+    permission.clearRules();
+    assertThat(permission.getRules()).isEmpty();
+  }
+
+  @Test
+  public void mergePermissions() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+    PermissionRule permissionRule3 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-3"), "group3"));
+
+    Permission permission1 = new Permission("foo");
+    permission1.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+
+    Permission permission2 = new Permission("bar");
+    permission2.setRules(ImmutableList.of(permissionRule2, permissionRule3));
+
+    permission1.mergeFrom(permission2);
+    assertThat(permission1.getRules())
+        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
+        .inOrder();
+  }
+
+  @Test
+  public void testEquals() {
+    PermissionRule permissionRule1 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-1"), "group1"));
+    PermissionRule permissionRule2 =
+        new PermissionRule(new GroupReference(new AccountGroup.UUID("uuid-2"), "group2"));
+
+    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+
+    Permission permissionSameRulesOtherName = new Permission("bar");
+    permissionSameRulesOtherName.setRules(ImmutableList.of(permissionRule1, permissionRule2));
+    assertThat(permission.equals(permissionSameRulesOtherName)).isFalse();
+
+    Permission permissionSameRulesSameNameOtherExclusiveGroup = new Permission("foo");
+    permissionSameRulesSameNameOtherExclusiveGroup.setRules(
+        ImmutableList.of(permissionRule1, permissionRule2));
+    permissionSameRulesSameNameOtherExclusiveGroup.setExclusiveGroup(true);
+    assertThat(permission.equals(permissionSameRulesSameNameOtherExclusiveGroup)).isFalse();
+
+    Permission permissionOther = new Permission(PERMISSION_NAME);
+    permissionOther.setRules(ImmutableList.of(permissionRule1));
+    assertThat(permission.equals(permissionOther)).isFalse();
+
+    permissionOther.add(permissionRule2);
+    assertThat(permission.equals(permissionOther)).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 3113a8a..f405c28 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -30,6 +30,7 @@
     visibility = ["//visibility:public"],
     runtime_deps = [
         "//lib/bouncycastle:bcprov",
+        "//prolog:gerrit-prolog-common",
     ],
     deps = [
         ":custom-truth-subjects",
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index c677be5..cc7302f 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -79,24 +79,10 @@
 @Ignore
 @RunWith(ConfigSuite.class)
 public abstract class AbstractChangeNotesTest extends GerritBaseTests {
-  @ConfigSuite.Default
-  public static Config changeNotesLegacy() {
-    Config cfg = new Config();
-    cfg.setBoolean("notedb", null, "writeJson", false);
-    return cfg;
-  }
-
-  @ConfigSuite.Config
-  public static Config changeNotesJson() {
-    Config cfg = new Config();
-    cfg.setBoolean("notedb", null, "writeJson", true);
-    return cfg;
-  }
+  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
 
   @ConfigSuite.Parameter public Config testConfig;
 
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
-
   protected Account.Id otherUserId;
   protected FakeAccountCache accountCache;
   protected IdentifiedUser changeOwner;
@@ -172,7 +158,6 @@
                 bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
                 bind(MetricMaker.class).to(DisabledMetricMaker.class);
                 bind(ReviewDb.class).toProvider(Providers.<ReviewDb>of(null));
-
                 MutableNotesMigration migration = MutableNotesMigration.newDisabled();
                 migration.setFrom(NotesMigrationState.FINAL);
                 bind(MutableNotesMigration.class).toInstance(migration);
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index de964d8..313fa07 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -498,11 +498,7 @@
   private RevCommit writeCommit(String body) throws Exception {
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
-        body,
-        noteUtil
-            .getLegacyChangeNoteWrite()
-            .newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent),
-        false);
+        body, noteUtil.newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent), false);
   }
 
   private RevCommit writeCommit(String body, PersonIdent author) throws Exception {
@@ -513,9 +509,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil
-            .getLegacyChangeNoteWrite()
-            .newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newIdent(changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent),
         initWorkInProgress);
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 74ba0c2..bea61ed 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -58,17 +58,14 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.sql.Timestamp;
-import java.util.ArrayList;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.Note;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -79,7 +76,6 @@
 
   @Inject private ChangeNoteJson changeNoteJson;
   @Inject private LegacyChangeNoteRead legacyChangeNoteRead;
-  @Inject private ChangeNoteUtil noteUtil;
 
   @Inject private @GerritServerId String serverId;
 
@@ -999,7 +995,7 @@
     update.commit();
 
     try {
-      notes = newNotes(c);
+      newNotes(c);
       fail("Expected IOException");
     } catch (OrmException e) {
       assertCause(
@@ -1149,10 +1145,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    String note = readNote(notes, commit);
-    if (!testJson()) {
-      assertThat(note).isEqualTo(pushCert);
-    }
+    readNote(notes, commit);
+
     Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
     assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
@@ -1185,27 +1179,6 @@
     assertThat(patchSets.get(psId1).getPushCertificate()).isNull();
     assertThat(patchSets.get(psId2).getPushCertificate()).isEqualTo(pushCert);
     assertThat(notes.getComments()).isNotEmpty();
-
-    if (!testJson()) {
-      assertThat(readNote(notes, commit))
-          .isEqualTo(
-              pushCert
-                  + "Revision: "
-                  + commit.name()
-                  + "\n"
-                  + "Patch-set: 2\n"
-                  + "File: a.txt\n"
-                  + "\n"
-                  + "1:2-3:4\n"
-                  + NoteDbUtil.formatTime(serverIdent, ts)
-                  + "\n"
-                  + "Author: Change Owner <1@gerrit>\n"
-                  + "Unresolved: false\n"
-                  + "UUID: uuid1\n"
-                  + "Bytes: 7\n"
-                  + "Comment\n"
-                  + "\n");
-    }
   }
 
   @Test
@@ -1591,307 +1564,6 @@
   }
 
   @Test
-  public void patchLineCommentNotesFormatSide1() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String uuid3 = "uuid3";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    String message3 = "comment 3";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    Timestamp time2 = TimeUtil.nowTs();
-    Timestamp time3 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment1 =
-        newComment(
-            psId,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time1,
-            message1,
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    Comment comment2 =
-        newComment(
-            psId,
-            "file1",
-            uuid2,
-            range2,
-            range2.getEndLine(),
-            otherUser,
-            null,
-            time2,
-            message2,
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    CommentRange range3 = new CommentRange(3, 0, 4, 1);
-    Comment comment3 =
-        newComment(
-            psId,
-            "file2",
-            uuid3,
-            range3,
-            range3.getEndLine(),
-            otherUser,
-            null,
-            time3,
-            message3,
-            (short) 1,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment3);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time1)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "2:1-3:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time2)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n"
-                    + "File: file2\n"
-                    + "\n"
-                    + "3:0-4:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time3)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid3\n"
-                    + "Bytes: 9\n"
-                    + "comment 3\n"
-                    + "\n");
-      }
-    }
-  }
-
-  @Test
-  public void patchLineCommentNotesFormatSide0() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    Timestamp time2 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment1 =
-        newComment(
-            psId,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time1,
-            message1,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    Comment comment2 =
-        newComment(
-            psId,
-            "file1",
-            uuid2,
-            range2,
-            range2.getEndLine(),
-            otherUser,
-            null,
-            time2,
-            message2,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Base-for-patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time1)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "2:1-3:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time2)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n");
-      }
-    }
-  }
-
-  @Test
-  public void patchLineCommentNotesResolvedChangesValue() throws Exception {
-    Change c = newChange();
-    ChangeUpdate update = newUpdate(c, otherUser);
-    String uuid1 = "uuid1";
-    String uuid2 = "uuid2";
-    String message1 = "comment 1";
-    String message2 = "comment 2";
-    CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
-    Timestamp time2 = TimeUtil.nowTs();
-    PatchSet.Id psId = c.currentPatchSetId();
-
-    Comment comment1 =
-        newComment(
-            psId,
-            "file1",
-            uuid1,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            null,
-            time1,
-            message1,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            false);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment1);
-    update.commit();
-
-    update = newUpdate(c, otherUser);
-    Comment comment2 =
-        newComment(
-            psId,
-            "file1",
-            uuid2,
-            range1,
-            range1.getEndLine(),
-            otherUser,
-            uuid1,
-            time2,
-            message2,
-            (short) 0,
-            "abcd1234abcd1234abcd1234abcd1234abcd1234",
-            true);
-    update.setPatchSetId(psId);
-    update.putComment(Status.PUBLISHED, comment2);
-    update.commit();
-
-    ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Base-for-patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time1)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time2)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Parent: uuid1\n"
-                    + "Unresolved: true\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n");
-      }
-    }
-  }
-
-  @Test
   public void patchLineCommentNotesFormatMultiplePatchSetsSameRevId() throws Exception {
     Change c = newChange();
     PatchSet.Id psId1 = c.currentPatchSetId();
@@ -1959,54 +1631,6 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-      String timeStr = NoteDbUtil.formatTime(serverIdent, time);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Base-for-patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid1\n"
-                    + "Bytes: 9\n"
-                    + "comment 1\n"
-                    + "\n"
-                    + "2:1-3:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid2\n"
-                    + "Bytes: 9\n"
-                    + "comment 2\n"
-                    + "\n"
-                    + "Base-for-patch-set: 2\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid3\n"
-                    + "Bytes: 9\n"
-                    + "comment 3\n"
-                    + "\n");
-      }
-    }
     assertThat(notes.getComments())
         .isEqualTo(
             ImmutableListMultimap.of(
@@ -2048,32 +1672,6 @@
 
     ChangeNotes notes = newNotes(c);
 
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Patch-set: 1\n"
-                    + "File: file\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + NoteDbUtil.formatTime(serverIdent, time)
-                    + "\n"
-                    + "Author: Other Account <2@gerrit>\n"
-                    + "Real-author: Change Owner <1@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid\n"
-                    + "Bytes: 7\n"
-                    + "comment\n"
-                    + "\n");
-      }
-    }
     assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(revId, comment));
   }
 
@@ -2112,32 +1710,6 @@
 
     ChangeNotes notes = newNotes(c);
 
-    try (RevWalk walk = new RevWalk(repo)) {
-      ArrayList<Note> notesInTree = Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
-      Note note = Iterables.getOnlyElement(notesInTree);
-
-      byte[] bytes = walk.getObjectReader().open(note.getData(), Constants.OBJ_BLOB).getBytes();
-      String noteString = new String(bytes, UTF_8);
-      String timeStr = NoteDbUtil.formatTime(serverIdent, time);
-
-      if (!testJson()) {
-        assertThat(noteString)
-            .isEqualTo(
-                "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
-                    + "Patch-set: 1\n"
-                    + "File: file1\n"
-                    + "\n"
-                    + "1:1-2:1\n"
-                    + timeStr
-                    + "\n"
-                    + "Author: Weird\u0002User <3@gerrit>\n"
-                    + "Unresolved: false\n"
-                    + "UUID: uuid\n"
-                    + "Bytes: 7\n"
-                    + "comment\n"
-                    + "\n");
-      }
-    }
     assertThat(notes.getComments())
         .isEqualTo(ImmutableListMultimap.of(new RevId(comment.revId), comment));
   }
@@ -2604,7 +2176,6 @@
     assertThat(notes.getDraftCommentNotes().getNoteMap().contains(objId)).isTrue();
 
     update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
     update.setPatchSetId(psId);
     update.deleteComment(comment);
     update.commit();
@@ -2674,7 +2245,6 @@
     assertThat(notes.getDraftComments(otherUserId)).hasSize(2);
 
     update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
     update.setPatchSetId(ps2);
     update.deleteComment(comment2);
     update.commit();
@@ -3525,10 +3095,6 @@
     update.commit();
   }
 
-  private boolean testJson() {
-    return noteUtil.getChangeNoteJson().getWriteJson();
-  }
-
   private String readNote(ChangeNotes notes, ObjectId noteId) throws Exception {
     ObjectId dataId = notes.revisionNoteMap.noteMap.getNote(noteId).getData();
     return new String(rw.getObjectReader().open(dataId, OBJ_BLOB).getCachedBytes(), UTF_8);
diff --git a/javatests/com/google/gerrit/server/notedb/CommentJsonMigratorTest.java b/javatests/com/google/gerrit/server/notedb/CommentJsonMigratorTest.java
new file mode 100644
index 0000000..a5c4e49
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/CommentJsonMigratorTest.java
@@ -0,0 +1,535 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimaps;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.testing.TestChanges;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CommentJsonMigratorTest extends AbstractChangeNotesTest {
+  private CommentJsonMigrator migrator;
+  @Inject private ChangeNoteUtil noteUtil;
+  @Inject private CommentsUtil commentsUtil;
+  @Inject private LegacyChangeNoteWrite legacyChangeNoteWrite;
+
+  private AtomicInteger uuidCounter;
+
+  @Before
+  public void setUpCounter() {
+    uuidCounter = new AtomicInteger();
+    migrator = new CommentJsonMigrator(new ChangeNoteJson(), "gerrit");
+  }
+
+  @Test
+  public void noOpIfAllCommentsAreJson() throws Exception {
+    Change c = newChange();
+    incrementPatchSet(c);
+
+    ChangeNotes notes = newNotes(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    Comment ps1Comment = newComment(notes, 1, "comment on ps1");
+    update.putComment(Status.PUBLISHED, ps1Comment);
+    update.commit();
+
+    notes = newNotes(c);
+    update = newUpdate(c, changeOwner);
+    Comment ps2Comment = newComment(notes, 2, "comment on ps2");
+    update.putComment(Status.PUBLISHED, ps2Comment);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(getToStringRepresentations(notes.getComments()))
+        .containsExactly(
+            getRevId(notes, 1), ps1Comment.toString(),
+            getRevId(notes, 2), ps2Comment.toString());
+
+    ChangeNotes oldNotes = notes;
+    migrate(project, migrator::migrateChanges, 0);
+    assertNoDifferences(notes, oldNotes);
+    assertThat(notes.getMetaId()).isEqualTo(oldNotes.getMetaId());
+  }
+
+  @Test
+  public void migratePublishedComments() throws Exception {
+    Change c = newChange();
+    incrementPatchSet(c);
+
+    ChangeNotes notes = newNotes(c);
+
+    Comment ps1Comment1 = newComment(notes, 1, "first comment on ps1");
+    Comment ps2Comment1 = newComment(notes, 2, "first comment on ps2");
+    Comment ps1Comment2 = newComment(notes, 1, "second comment on ps1");
+
+    // Construct legacy format 'by hand'.
+    ByteArrayOutputStream out1 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(1, ps1Comment1).build(), out1);
+
+    ByteArrayOutputStream out2 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(2, ps2Comment1).build(), out2);
+
+    ByteArrayOutputStream out3 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder()
+            .put(1, ps1Comment2)
+            .put(1, ps1Comment1)
+            .build(),
+        out3);
+
+    TestRepository<Repository> testRepository = new TestRepository<>(repo, rw);
+
+    String metaRefName = RefNames.changeMetaRef(c.getId());
+    testRepository
+        .branch(metaRefName)
+        .commit()
+        .message("Review ps 1\n\nPatch-set: 1")
+        .add(ps1Comment1.revId, out1.toString())
+        .author(serverIdent)
+        .committer(serverIdent)
+        .create();
+
+    testRepository
+        .branch(metaRefName)
+        .commit()
+        .message("Review ps 2\n\nPatch-set: 2")
+        .add(ps2Comment1.revId, out2.toString())
+        .add(ps1Comment1.revId, out3.toString())
+        .author(serverIdent)
+        .committer(serverIdent)
+        .create();
+
+    notes = newNotes(c);
+    assertThat(getToStringRepresentations(notes.getComments()))
+        .containsExactly(
+            getRevId(notes, 1), ps1Comment1.toString(),
+            getRevId(notes, 1), ps1Comment2.toString(),
+            getRevId(notes, 2), ps2Comment1.toString());
+
+    // Comments at each commit all have legacy format.
+    ImmutableList<RevCommit> oldLog = log(project, RefNames.changeMetaRef(c.getId()));
+    assertThat(oldLog).hasSize(4);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(0))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(1))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(2)))
+        .containsExactly(ps1Comment1.key, true);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(3)))
+        .containsExactly(ps1Comment1.key, true, ps1Comment2.key, true, ps2Comment1.key, true);
+
+    ChangeNotes oldNotes = notes;
+    migrate(project, migrator::migrateChanges, 1);
+
+    // Comment content is the same.
+    notes = newNotes(c);
+    assertNoDifferences(notes, oldNotes);
+    assertThat(getToStringRepresentations(notes.getComments()))
+        .containsExactly(
+            getRevId(notes, 1), ps1Comment1.toString(),
+            getRevId(notes, 1), ps1Comment2.toString(),
+            getRevId(notes, 2), ps2Comment1.toString());
+
+    // Comments at each commit all have JSON format.
+    ImmutableList<RevCommit> newLog = log(project, RefNames.changeMetaRef(c.getId()));
+    assertLogEqualExceptTrees(newLog, oldLog);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(0))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(1))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(2)))
+        .containsExactly(ps1Comment1.key, false);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(3)))
+        .containsExactly(ps1Comment1.key, false, ps1Comment2.key, false, ps2Comment1.key, false);
+  }
+
+  @Test
+  public void migrateDraftComments() throws Exception {
+    Change c = newChange();
+    incrementPatchSet(c);
+
+    ChangeNotes notes = newNotes(c);
+    ObjectId origMetaId = notes.getMetaId();
+
+    Comment ownerCommentPs1 = newComment(notes, 1, "owner comment on ps1", changeOwner);
+    Comment ownerCommentPs2 = newComment(notes, 2, "owner comment on ps2", changeOwner);
+    Comment otherCommentPs1 = newComment(notes, 1, "other user comment on ps1", otherUser);
+
+    ByteArrayOutputStream out1 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(1, ownerCommentPs1).build(), out1);
+
+    ByteArrayOutputStream out2 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(2, ownerCommentPs2).build(), out2);
+
+    ByteArrayOutputStream out3 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(1, otherCommentPs1).build(), out3);
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk allUsersRw = new RevWalk(allUsersRepo)) {
+      TestRepository<Repository> testRepository = new TestRepository<>(allUsersRepo, allUsersRw);
+
+      testRepository
+          .branch(RefNames.refsDraftComments(c.getId(), changeOwner.getAccountId()))
+          .commit()
+          .message("Review ps 1\n\nPatch-set: 1")
+          .add(ownerCommentPs1.revId, out1.toString())
+          .author(serverIdent)
+          .committer(serverIdent)
+          .create();
+
+      testRepository
+          .branch(RefNames.refsDraftComments(c.getId(), changeOwner.getAccountId()))
+          .commit()
+          .message("Review ps 1\n\nPatch-set: 2")
+          .add(ownerCommentPs2.revId, out2.toString())
+          .author(serverIdent)
+          .committer(serverIdent)
+          .create();
+
+      testRepository
+          .branch(RefNames.refsDraftComments(c.getId(), otherUser.getAccountId()))
+          .commit()
+          .message("Review ps 2\n\nPatch-set: 2")
+          .add(otherCommentPs1.revId, out3.toString())
+          .author(serverIdent)
+          .committer(serverIdent)
+          .create();
+    }
+
+    notes = newNotes(c);
+    assertThat(getToStringRepresentations(notes.getDraftComments(changeOwner.getAccountId())))
+        .containsExactly(
+            getRevId(notes, 1), ownerCommentPs1.toString(),
+            getRevId(notes, 2), ownerCommentPs2.toString());
+    assertThat(getToStringRepresentations(notes.getDraftComments(otherUser.getAccountId())))
+        .containsExactly(getRevId(notes, 1), otherCommentPs1.toString());
+
+    // Comments at each commit all have legacy format.
+    ImmutableList<RevCommit> oldOwnerLog =
+        log(allUsers, RefNames.refsDraftComments(c.getId(), changeOwner.getAccountId()));
+    assertThat(oldOwnerLog).hasSize(2);
+    assertThat(getLegacyFormatMapForDraftComments(notes, oldOwnerLog.get(0)))
+        .containsExactly(ownerCommentPs1.key, true);
+    assertThat(getLegacyFormatMapForDraftComments(notes, oldOwnerLog.get(1)))
+        .containsExactly(ownerCommentPs1.key, true, ownerCommentPs2.key, true);
+
+    ImmutableList<RevCommit> oldOtherLog =
+        log(allUsers, RefNames.refsDraftComments(c.getId(), otherUser.getAccountId()));
+    assertThat(oldOtherLog).hasSize(1);
+    assertThat(getLegacyFormatMapForDraftComments(notes, oldOtherLog.get(0)))
+        .containsExactly(otherCommentPs1.key, true);
+
+    ChangeNotes oldNotes = notes;
+    migrate(allUsers, migrator::migrateDrafts, 2);
+    assertNoDifferences(notes, oldNotes);
+
+    // Migration doesn't touch change ref.
+    assertThat(repo.exactRef(RefNames.changeMetaRef(c.getId())).getObjectId())
+        .isEqualTo(origMetaId);
+
+    // Comment content is the same.
+    notes = newNotes(c);
+    assertThat(getToStringRepresentations(notes.getDraftComments(changeOwner.getAccountId())))
+        .containsExactly(
+            getRevId(notes, 1), ownerCommentPs1.toString(),
+            getRevId(notes, 2), ownerCommentPs2.toString());
+    assertThat(getToStringRepresentations(notes.getDraftComments(otherUser.getAccountId())))
+        .containsExactly(getRevId(notes, 1), otherCommentPs1.toString());
+
+    // Comments at each commit all have JSON format.
+    ImmutableList<RevCommit> newOwnerLog =
+        log(allUsers, RefNames.refsDraftComments(c.getId(), changeOwner.getAccountId()));
+    assertLogEqualExceptTrees(newOwnerLog, oldOwnerLog);
+    assertThat(getLegacyFormatMapForDraftComments(notes, newOwnerLog.get(0)))
+        .containsExactly(ownerCommentPs1.key, false);
+    assertThat(getLegacyFormatMapForDraftComments(notes, newOwnerLog.get(1)))
+        .containsExactly(ownerCommentPs1.key, false, ownerCommentPs2.key, false);
+
+    ImmutableList<RevCommit> newOtherLog =
+        log(allUsers, RefNames.refsDraftComments(c.getId(), otherUser.getAccountId()));
+    assertLogEqualExceptTrees(newOtherLog, oldOtherLog);
+    assertThat(getLegacyFormatMapForDraftComments(notes, newOtherLog.get(0)))
+        .containsExactly(otherCommentPs1.key, false);
+  }
+
+  @Test
+  public void migrateMixOfJsonAndLegacyComments() throws Exception {
+    // 3 comments: legacy, JSON, legacy. Because adding a comment necessarily rewrites the entire
+    // note, these comments need to be on separate patch sets.
+    Change c = newChange();
+    incrementPatchSet(c);
+    incrementPatchSet(c);
+
+    ChangeNotes notes = newNotes(c);
+
+    Comment ps1Comment = newComment(notes, 1, "comment on ps1 (legacy)");
+
+    ByteArrayOutputStream out1 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(1, ps1Comment).build(), out1);
+
+    TestRepository<Repository> testRepository = new TestRepository<>(repo, rw);
+
+    String metaRefName = RefNames.changeMetaRef(c.getId());
+    testRepository
+        .branch(metaRefName)
+        .commit()
+        .message("Review ps 1\n\nPatch-set: 1")
+        .add(ps1Comment.revId, out1.toString())
+        .author(serverIdent)
+        .committer(serverIdent)
+        .create();
+
+    notes = newNotes(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    Comment ps2Comment = newComment(notes, 2, "comment on ps2 (JSON)");
+    update.putComment(Status.PUBLISHED, ps2Comment);
+    update.commit();
+
+    Comment ps3Comment = newComment(notes, 3, "comment on ps3 (legacy)");
+    ByteArrayOutputStream out3 = new ByteArrayOutputStream(0);
+    legacyChangeNoteWrite.buildNote(
+        ImmutableListMultimap.<Integer, Comment>builder().put(3, ps3Comment).build(), out3);
+
+    testRepository
+        .branch(metaRefName)
+        .commit()
+        .message("Review ps 3\n\nPatch-set: 3")
+        .add(ps3Comment.revId, out3.toString())
+        .author(serverIdent)
+        .committer(serverIdent)
+        .create();
+
+    notes = newNotes(c);
+    assertThat(getToStringRepresentations(notes.getComments()))
+        .containsExactly(
+            getRevId(notes, 1), ps1Comment.toString(),
+            getRevId(notes, 2), ps2Comment.toString(),
+            getRevId(notes, 3), ps3Comment.toString());
+
+    // Comments at each commit match expected format.
+    ImmutableList<RevCommit> oldLog = log(project, RefNames.changeMetaRef(c.getId()));
+    assertThat(oldLog).hasSize(6);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(0))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(1))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(2))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(3)))
+        .containsExactly(ps1Comment.key, true);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(4)))
+        .containsExactly(ps1Comment.key, true, ps2Comment.key, false);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, oldLog.get(5)))
+        .containsExactly(ps1Comment.key, true, ps2Comment.key, false, ps3Comment.key, true);
+
+    ChangeNotes oldNotes = notes;
+    migrate(project, migrator::migrateChanges, 1);
+    assertNoDifferences(notes, oldNotes);
+
+    // Comment content is the same.
+    notes = newNotes(c);
+    assertThat(getToStringRepresentations(notes.getComments()))
+        .containsExactly(
+            getRevId(notes, 1), ps1Comment.toString(),
+            getRevId(notes, 2), ps2Comment.toString(),
+            getRevId(notes, 3), ps3Comment.toString());
+
+    // Comments at each commit all have JSON format.
+    ImmutableList<RevCommit> newLog = log(project, RefNames.changeMetaRef(c.getId()));
+    assertLogEqualExceptTrees(newLog, oldLog);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(0))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(1))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(2))).isEmpty();
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(3)))
+        .containsExactly(ps1Comment.key, false);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(4)))
+        .containsExactly(ps1Comment.key, false, ps2Comment.key, false);
+    assertThat(getLegacyFormatMapForPublishedComments(notes, newLog.get(5)))
+        .containsExactly(ps1Comment.key, false, ps2Comment.key, false, ps3Comment.key, false);
+  }
+
+  @FunctionalInterface
+  interface MigrateFunction {
+    boolean call(
+        Project.NameKey project,
+        Repository repo,
+        RevWalk rw,
+        ObjectInserter ins,
+        BatchRefUpdate bru)
+        throws Exception;
+  }
+
+  private void migrate(Project.NameKey project, MigrateFunction func, int expectedCommands)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      bru.setAllowNonFastForwards(true);
+      assertThat(func.call(project, repo, rw, ins, bru)).isTrue();
+      assertThat(bru.getCommands()).hasSize(expectedCommands);
+      if (!bru.getCommands().isEmpty()) {
+        ins.flush();
+        RefUpdateUtil.executeChecked(bru, rw);
+      }
+    }
+  }
+
+  private Comment newComment(ChangeNotes notes, int psNum, String message) {
+    return newComment(notes, psNum, message, changeOwner);
+  }
+
+  private Comment newComment(
+      ChangeNotes notes, int psNum, String message, IdentifiedUser commenter) {
+    return newComment(
+        new PatchSet.Id(notes.getChangeId(), psNum),
+        "filename",
+        "uuid-" + uuidCounter.getAndIncrement(),
+        null,
+        0,
+        commenter,
+        null,
+        TimeUtil.nowTs(),
+        message,
+        (short) 1,
+        getRevId(notes, psNum).get(),
+        false);
+  }
+
+  private void incrementPatchSet(Change c) throws Exception {
+    TestChanges.incrementPatchSet(c);
+    RevCommit commit = tr.commit().message("PS" + c.currentPatchSetId().get()).create();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setCommit(rw, commit);
+    update.commit();
+  }
+
+  private static RevId getRevId(ChangeNotes notes, int psNum) {
+    PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), psNum);
+    PatchSet ps = notes.getPatchSets().get(psId);
+    checkArgument(ps != null, "no patch set %s: %s", psNum, notes.getPatchSets());
+    return ps.getRevision();
+  }
+
+  private static ListMultimap<RevId, String> getToStringRepresentations(
+      ListMultimap<RevId, Comment> comments) {
+    // Use string representation for equality comparison in this test, because Comment#equals only
+    // compares keys.
+    return Multimaps.transformValues(comments, Comment::toString);
+  }
+
+  private ImmutableMap<Comment.Key, Boolean> getLegacyFormatMapForPublishedComments(
+      ChangeNotes notes, ObjectId metaId) throws Exception {
+    return getLegacyFormatMap(project, notes.getChangeId(), metaId, Status.PUBLISHED);
+  }
+
+  private ImmutableMap<Comment.Key, Boolean> getLegacyFormatMapForDraftComments(
+      ChangeNotes notes, ObjectId metaId) throws Exception {
+    return getLegacyFormatMap(allUsers, notes.getChangeId(), metaId, Status.DRAFT);
+  }
+
+  private ImmutableMap<Comment.Key, Boolean> getLegacyFormatMap(
+      Project.NameKey project, Change.Id changeId, ObjectId metaId, Status status)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectReader reader = repo.newObjectReader();
+        RevWalk rw = new RevWalk(reader)) {
+      NoteMap noteMap = NoteMap.read(reader, rw.parseCommit(metaId));
+      RevisionNoteMap<ChangeRevisionNote> revNoteMap =
+          RevisionNoteMap.parse(
+              noteUtil.getChangeNoteJson(),
+              noteUtil.getLegacyChangeNoteRead(),
+              changeId,
+              reader,
+              noteMap,
+              status);
+      return revNoteMap
+          .revisionNotes
+          .values()
+          .stream()
+          .flatMap(crn -> crn.getComments().stream())
+          .collect(toImmutableMap(c -> c.key, c -> c.legacyFormat));
+    }
+  }
+
+  private ImmutableList<RevCommit> log(Project.NameKey project, String refName) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      rw.sort(RevSort.TOPO);
+      rw.sort(RevSort.REVERSE);
+      Ref ref = repo.exactRef(refName);
+      checkArgument(ref != null, "missing ref: %s", refName);
+      rw.markStart(rw.parseCommit(ref.getObjectId()));
+      return ImmutableList.copyOf(rw);
+    }
+  }
+
+  private static void assertLogEqualExceptTrees(
+      ImmutableList<RevCommit> actualLog, ImmutableList<RevCommit> expectedLog) {
+    assertThat(actualLog).hasSize(expectedLog.size());
+    for (int i = 0; i < expectedLog.size(); i++) {
+      RevCommit actual = actualLog.get(i);
+      RevCommit expected = expectedLog.get(i);
+      assertThat(actual.getAuthorIdent())
+          .named("author of entry %s", i)
+          .isEqualTo(expected.getAuthorIdent());
+      assertThat(actual.getCommitterIdent())
+          .named("committer of entry %s", i)
+          .isEqualTo(expected.getCommitterIdent());
+      assertThat(actual.getFullMessage()).named("message of entry %s", i).isNotNull();
+      assertThat(actual.getFullMessage())
+          .named("message of entry %s", i)
+          .isEqualTo(expected.getFullMessage());
+    }
+  }
+
+  private void assertNoDifferences(ChangeNotes actual, ChangeNotes expected) throws Exception {
+    assertThat(
+            ChangeBundle.fromNotes(commentsUtil, actual)
+                .differencesFrom(ChangeBundle.fromNotes(commentsUtil, expected)))
+        .isEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
index 314941e..086dd65 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
@@ -49,7 +49,7 @@
             bind(PrologEnvironment.Args.class)
                 .toInstance(
                     new PrologEnvironment.Args(
-                        null, null, null, null, null, null, null, cfg, null));
+                        null, null, null, null, null, null, null, cfg, null, null));
           }
         });
   }
diff --git a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
index f709f55..8622b32 100644
--- a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
+++ b/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
@@ -30,18 +30,18 @@
   @Test
   public void labelWithSpacesIsTransformed() {
     assertThat(PrologRuleEvaluator.checkLabelName("Label with spaces"))
-        .isEqualTo("Invalid-Prolog-Rules-Label-Name--Labelwithspaces");
+        .isEqualTo("Invalid-Prolog-Rules-Label-Name-Labelwithspaces");
   }
 
   @Test
   public void labelStartingWithADashIsTransformed() {
     assertThat(PrologRuleEvaluator.checkLabelName("-dashed-label"))
-        .isEqualTo("Invalid-Prolog-Rules-Label-Name---dashed-label");
+        .isEqualTo("Invalid-Prolog-Rules-Label-Name-dashed-label");
   }
 
   @Test
   public void labelWithInvalidCharactersIsTransformed() {
     assertThat(PrologRuleEvaluator.checkLabelName("*urgent*"))
-        .isEqualTo("Invalid-Prolog-Rules-Label-Name--urgent");
+        .isEqualTo("Invalid-Prolog-Rules-Label-Name-urgent");
   }
 }
diff --git a/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
index ed94c97..c4844b1 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.config.GerritServerIdProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -94,7 +95,11 @@
                     Config cfg = new Config();
                     cfg.setString("user", null, "name", "Gerrit Code Review");
                     cfg.setString("user", null, "email", "gerrit@localhost");
-
+                    cfg.setString(
+                        GerritServerIdProvider.SECTION,
+                        null,
+                        GerritServerIdProvider.KEY,
+                        "1234567");
                     bind(Config.class) //
                         .annotatedWith(GerritServerConfig.class) //
                         .toInstance(cfg);
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html
new file mode 100644
index 0000000..68000bc
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.html
@@ -0,0 +1,75 @@
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<script>
+(function(window) {
+  'use strict';
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior Gerrit.SafeTypes */
+  Gerrit.SafeTypes = {};
+
+  const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|\/|#)/i;
+
+  /**
+   * Wraps a string to be used as a URL. An error is thrown if the string cannot
+   * be considered safe.
+   * @constructor
+   * @param {string} url the unwrapped, potentially unsafe URL.
+   */
+  Gerrit.SafeTypes.SafeUrl = function(url) {
+    if (!SAFE_URL_PATTERN.test(url)) {
+      throw new Error(`URL not marked as safe: ${url}`);
+    }
+    this._url = url;
+  };
+
+  /**
+   * Get the string representation of the safe URL.
+   * @returns {string}
+   */
+  Gerrit.SafeTypes.SafeUrl.prototype.asString = function() {
+    return this._url;
+  };
+
+  Gerrit.SafeTypes.safeTypesBridge = function(value, type) {
+    // If the value is being bound to a URL, ensure the value is wrapped in the
+    // SafeUrl type first. If the URL is not safe, allow the SafeUrl constructor
+    // to surface the error.
+    if (type === 'URL') {
+      let safeValue = null;
+      if (value instanceof Gerrit.SafeTypes.SafeUrl) {
+        safeValue = value;
+      } else if (typeof value === 'string') {
+        safeValue = new Gerrit.SafeTypes.SafeUrl(value);
+      }
+      if (safeValue) {
+        return safeValue.asString();
+      }
+    }
+
+    // If the value is being bound to a string or a constant, then the string
+    // can be used as is.
+    if (type === 'STRING' || type === 'CONSTANT') {
+      return value;
+    }
+
+    // Otherwise fail.
+    throw new Error(`Refused to bind value as ${type}: ${value}`);
+  };
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
new file mode 100644
index 0000000..bc16b39
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<!--
+@license
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<title>safe-types-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
+<link rel="import" href="safe-types-behavior.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <safe-types-element></safe-types-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-tooltip-behavior tests', () => {
+    let element;
+    let sandbox;
+
+    suiteSetup(() => {
+      Polymer({
+        is: 'safe-types-element',
+        behaviors: [Gerrit.SafeTypes],
+      });
+    });
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('SafeUrl accepts valid urls', () => {
+      function accepts(url) {
+        const safeUrl = new element.SafeUrl(url);
+        assert.isOk(safeUrl);
+        assert.equal(url, safeUrl.asString());
+      }
+      accepts('http://www.google.com/');
+      accepts('https://www.google.com/');
+      accepts('HtTpS://www.google.com/');
+      accepts('//www.google.com/');
+      accepts('/c/1234/file/path.html@45');
+      accepts('#hash-url');
+      accepts('mailto:name@example.com');
+    });
+
+    test('SafeUrl rejects invalid urls', () => {
+      function rejects(url) {
+        assert.throws(() => { new element.SafeUrl(url); });
+      }
+      rejects('javascript://alert("evil");');
+      rejects('ftp:example.com');
+      rejects('data:text/html,scary business');
+    });
+
+    suite('safeTypesBridge', () => {
+      function acceptsString(value, type) {
+        assert.equal(Gerrit.SafeTypes.safeTypesBridge(value, type),
+            value);
+      }
+
+      function rejects(value, type) {
+        assert.throws(() => { Gerrit.SafeTypes.safeTypesBridge(value, type); });
+      }
+
+      test('accepts valid URL strings', () => {
+        acceptsString('/foo/bar', 'URL');
+        acceptsString('#baz', 'URL');
+      });
+
+      test('rejects invalid URL strings', () => {
+        rejects('javascript://void();', 'URL');
+      });
+
+      test('accepts SafeUrl values', () => {
+        const url = '/abc/123';
+        const safeUrl = new element.SafeUrl(url);
+        assert.equal(Gerrit.SafeTypes.safeTypesBridge(safeUrl, 'URL'), url);
+      });
+
+      test('rejects non-string or non-SafeUrl types', () => {
+        rejects(3.1415926, 'URL');
+      });
+
+      test('accepts any binding to STRING or CONSTANT', () => {
+        acceptsString('foo/bar/baz', 'STRING');
+        acceptsString('lorem ipsum dolor', 'CONSTANT');
+      });
+
+      test('rejects all other types', () => {
+        rejects('foo', 'JAVASCRIPT');
+        rejects('foo', 'HTML');
+        rejects('foo', 'RESOURCE_URL');
+        rejects('foo', 'STYLE');
+      });
+    });
+  });
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
index 1893536..b99125c 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -43,7 +43,7 @@
 
   const ForcePushOptions = {
     ALLOW: [
-      {name: 'Allow pushing but not force pushing', value: false},
+      {name: 'Allow pushing (but not force pushing)', value: false},
       {name: 'Allow pushing with or without force', value: true},
     ],
     BLOCK: [
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
index 556568b..f85c2b2 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
@@ -52,7 +52,7 @@
           () => {
             const ForcePushOptions = {
               ALLOW: [
-                {name: 'Allow pushing but not force pushing', value: false},
+                {name: 'Allow pushing (but not force pushing)', value: false},
                 {name: 'Allow pushing with or without force', value: true},
               ],
               BLOCK: [
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index dc41f59..9930bf5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -21,6 +21,8 @@
 
   const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
 
+  const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
+
   Polymer({
     is: 'gr-change-list',
 
@@ -180,7 +182,11 @@
     },
 
     _computeLabelShortcut(labelName) {
+      if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
+        labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
+      }
       return labelName.split('-').reduce((a, i) => {
+        if (!i) { return a; }
         return a + i[0].toUpperCase();
       }, '');
     },
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
index 3dd8c9d..9ce5764 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -127,7 +127,11 @@
       assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
       assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
       assert.equal(element._computeLabelShortcut(
+          'Invalid-Prolog-Rules-Label-Name--Verified'), 'V');
+      assert.equal(element._computeLabelShortcut(
           'Some-Special-Label-7'), 'SSL7');
+      assert.equal(element._computeLabelShortcut('--Too----many----dashes---'),
+          'TMD');
     });
 
     test('colspans', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
index c0a09f3..234d903 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
@@ -78,7 +78,7 @@
       assert.isOk(element._computeShowWebLink(element.change,
           element.commitInfo, element.serverConfig));
       assert.equal(element._computeWebLink(element.change, element.commitInfo,
-          element.serverConfig), '../../link-url');
+          element.serverConfig), 'link-url');
     });
 
     test('does not relativize web links that begin with scheme', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html
index ad14a18..299508b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.html
@@ -30,6 +30,12 @@
       p {
         margin-bottom: 1em;
       }
+      @media screen and (max-width: 50em) {
+        #dialog {
+          min-width: inherit;
+          width: 100%;
+        }
+      }
     </style>
     <gr-confirm-dialog
         id="dialog"
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index e601b93..3e886d5 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -20,7 +20,8 @@
   // Latency reporting constants.
   const TIMING = {
     TYPE: 'timing-report',
-    CATEGORY: 'UI Latency',
+    CATEGORY_UI_LATENCY: 'UI Latency',
+    CATEGORY_RPC: 'RPC Timing',
     // Reported events - alphabetize below.
     APP_STARTED: 'App Started',
     PAGE_LOADED: 'Page Loaded',
@@ -170,7 +171,16 @@
       report.apply(this, args);
     },
 
-    defaultReporter(type, category, eventName, eventValue) {
+    /**
+     * The default reporter reports events immediately.
+     * @param {string} type
+     * @param {string} category
+     * @param {string} eventName
+     * @param {string|number} eventValue
+     * @param {boolean|undefined} opt_noLog If true, the event will not be
+     *     logged to the JS console.
+     */
+    defaultReporter(type, category, eventName, eventValue, opt_noLog) {
       const detail = {
         type,
         category,
@@ -178,6 +188,7 @@
         value: eventValue,
       };
       document.dispatchEvent(new CustomEvent(type, {detail}));
+      if (opt_noLog) { return; }
       if (type === ERROR.TYPE) {
         console.error(eventValue.error || eventName);
       } else {
@@ -186,7 +197,17 @@
       }
     },
 
-    cachingReporter(type, category, eventName, eventValue) {
+    /**
+     * The caching reporter will queue reports until plugins have loaded, and
+     * log events immediately if they're reported after plugins have loaded.
+     * @param {string} type
+     * @param {string} category
+     * @param {string} eventName
+     * @param {string|number} eventValue
+     * @param {boolean|undefined} opt_noLog If true, the event will not be
+     *     logged to the JS console.
+     */
+    cachingReporter(type, category, eventName, eventValue, opt_noLog) {
       if (type === ERROR.TYPE) {
         console.error(eventValue.error || eventName);
       }
@@ -196,9 +217,9 @@
             this.reporter(...args);
           }
         }
-        this.reporter(type, category, eventName, eventValue);
+        this.reporter(type, category, eventName, eventValue, opt_noLog);
       } else {
-        pending.push([type, category, eventName, eventValue]);
+        pending.push([type, category, eventName, eventValue, opt_noLog]);
       }
     },
 
@@ -208,8 +229,8 @@
     appStarted(hidden) {
       const startTime =
           new Date().getTime() - this.performanceTiming.navigationStart;
-      this.reporter(
-          TIMING.TYPE, TIMING.CATEGORY, TIMING.APP_STARTED, startTime);
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
+          TIMING.APP_STARTED, startTime);
       if (hidden) {
         this.reporter(PAGE_VISIBILITY.TYPE, PAGE_VISIBILITY.CATEGORY,
             PAGE_VISIBILITY.STARTED_HIDDEN);
@@ -226,8 +247,8 @@
       } else {
         const loadTime = this.performanceTiming.loadEventEnd -
             this.performanceTiming.navigationStart;
-        this.reporter(
-            TIMING.TYPE, TIMING.CATEGORY, TIMING.PAGE_LOADED, loadTime);
+        this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
+            TIMING.PAGE_LOADED, loadTime);
       }
     },
 
@@ -344,7 +365,8 @@
      * @param {number} time The time to report as an integer of milliseconds.
      */
     _reportTiming(name, time) {
-      this.reporter(TIMING.TYPE, TIMING.CATEGORY, name, Math.round(time));
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, name,
+          Math.round(time));
     },
 
     /**
@@ -395,6 +417,16 @@
       return timer.reset();
     },
 
+    /**
+     * Log timing information for an RPC.
+     * @param {string} anonymizedUrl The URL of the RPC with tokens obfuscated.
+     * @param {number} elapsed The time elapsed of the RPC.
+     */
+    reportRpcTiming(anonymizedUrl, elapsed) {
+      this.reporter(TIMING.TYPE, TIMING.CATEGORY_RPC, 'RPC-' + anonymizedUrl,
+          elapsed, true);
+    },
+
     reportInteraction(eventName, opt_msg) {
       this.reporter(INTERACTION_TYPE, this.category, eventName, opt_msg);
     },
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
index e78f49fb..8b85074 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -264,7 +264,7 @@
         sandbox.stub(element, 'now').returns(42);
         element.pluginsLoaded();
         assert.isTrue(element.defaultReporter.calledWithExactly(
-            'timing-report', 'UI Latency', 'PluginsLoaded', 42
+            'timing-report', 'UI Latency', 'PluginsLoaded', 42, undefined
         ));
       });
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 6adc286..3b4f1ec 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -309,12 +309,7 @@
       if (!weblinks) { return null; }
       const weblink = weblinks.find(this._isDirectCommit);
       if (!weblink) { return null; }
-      const url = weblink.url;
-      if (url.startsWith('https:') || url.startsWith('http:')) {
-        return url;
-      } else {
-        return `../../${url}`;
-      }
+      return weblink.url;
     },
 
     _getChangeWeblinks({repo, commit, options: {weblinks}}) {
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index efcefe2..e7bd965 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -30,10 +30,12 @@
 
 <link rel="import" href="../bower_components/polymer/polymer.html">
 <link rel="import" href="../bower_components/polymer-resin/standalone/polymer-resin.html">
+<link rel="import" href="../behaviors/safe-types-behavior/safe-types-behavior.html">
 <script>
   security.polymer_resin.install({
     allowedIdentifierPrefixes: [''],
     reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
+    safeTypesBridge: Gerrit.SafeTypes.safeTypesBridge,
   });
 </script>
 
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 7acb680..b4aec99 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -99,6 +99,7 @@
       'page-error': '_handlePageError',
       'title-change': '_handleTitleChange',
       'location-change': '_handleLocationChange',
+      'rpc-log': '_handleRpcLog',
     },
 
     observers: [
@@ -332,5 +333,15 @@
       console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
       console.groupEnd();
     },
+
+    /**
+     * Intercept RPC log events emitted by REST API interfaces.
+     * Note: the REST API interface cannot use gr-reporting directly because
+     * that would create a cyclic dependency.
+     */
+    _handleRpcLog(e) {
+      this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl,
+          e.detail.elapsed);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 5d68e01..fa1ad11 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -28,6 +28,15 @@
   Defs.patchRange;
 
   /**
+   * @typedef {{
+   *    url: string,
+   *    fetchOptions: (Object|null|undefined),
+   *    anonymizedUrl: (string|undefined),
+   * }}
+   */
+  Defs.FetchRequest;
+
+  /**
    * Object to describe a request for passing into _fetchJSON or _fetchRawJSON.
    * - url is the URL for the request (excluding get params)
    * - errFn is a function to invoke when the request fails.
@@ -40,6 +49,8 @@
    *    cancelCondition: (function()|null|undefined),
    *    params: (Object|null|undefined),
    *    fetchOptions: (Object|null|undefined),
+   *    anonymizedUrl: (string|undefined),
+   *    reportUrlAsIs: (boolean|undefined),
    * }}
    */
   Defs.FetchJSONRequest;
@@ -53,6 +64,8 @@
    *   cancelCondition: (function()|null|undefined),
    *   params: (Object|null|undefined),
    *   fetchOptions: (Object|null|undefined),
+   *   anonymizedEndpoint: (string|undefined),
+   *   reportEndpointAsIs: (boolean|undefined),
    * }}
    */
   Defs.ChangeFetchRequest;
@@ -78,6 +91,8 @@
    *   contentType: (string|null|undefined),
    *   headers: (Object|undefined),
    *   parseResponse: (boolean|undefined),
+   *   anonymizedUrl: (string|undefined),
+   *   reportUrlAsIs: (boolean|undefined),
    * }}
    */
   Defs.SendRequest;
@@ -93,6 +108,8 @@
    *   contentType: (string|null|undefined),
    *   headers: (Object|undefined),
    *   parseResponse: (boolean|undefined),
+   *   anonymizedEndpoint: (string|undefined),
+   *   reportEndpointAsIs: (boolean|undefined),
    * }}
    */
   Defs.ChangeSendRequest;
@@ -115,6 +132,9 @@
       'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
   const HEADER_REPORTING_BLACKLIST = /^set-cookie$/i;
 
+  const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
+  const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
+      '/revisions/*';
 
   Polymer({
     is: 'gr-rest-api-interface',
@@ -143,6 +163,12 @@
      * @event auth-error
      */
 
+    /**
+     * Fired after an RPC completes.
+     *
+     * @event rpc-log
+     */
+
     properties: {
       _cache: {
         type: Object,
@@ -182,15 +208,14 @@
     /**
      * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
      * with timing and logging.
-     * @param {string} url
-     * @param {Object=} opt_fetchOptions
+     * @param {Defs.FetchRequest} req
      */
-    _fetch(url, opt_fetchOptions) {
+    _fetch(req) {
       const start = Date.now();
-      const xhr = this._auth.fetch(url, opt_fetchOptions);
+      const xhr = this._auth.fetch(req.url, req.fetchOptions);
 
       // Log the call after it completes.
-      xhr.then(res => this._logCall(url, opt_fetchOptions, start, res.status));
+      xhr.then(res => this._logCall(req, start, res.status));
 
       // Return the XHR directly (without the log).
       return xhr;
@@ -200,18 +225,27 @@
      * Log information about a REST call. Because the elapsed time is determined
      * by this method, it should be called immediately after the request
      * finishes.
-     * @param {string} url
-     * @param {Object|undefined} fetchOptions
+     * @param {Defs.FetchRequest} req
      * @param {number} startTime the time that the request was started.
      * @param {number} status the HTTP status of the response. The status value
      *     is used here rather than the response object so there is no way this
      *     method can read the body stream.
      */
-    _logCall(url, fetchOptions, startTime, status) {
-      const method = (fetchOptions && fetchOptions.method) ?
-          fetchOptions.method : 'GET';
-      const elapsed = (Date.now() - startTime) + 'ms';
-      console.log(['HTTP', status, method, elapsed, url].join(' '));
+    _logCall(req, startTime, status) {
+      const method = (req.fetchOptions && req.fetchOptions.method) ?
+          req.fetchOptions.method : 'GET';
+      const elapsed = (Date.now() - startTime);
+      console.log([
+        'HTTP',
+        status,
+        method,
+        elapsed + 'ms',
+        req.anonymizedUrl || req.url,
+      ].join(' '));
+      if (req.anonymizedUrl) {
+        this.fire('rpc-log',
+            {status, method, elapsed, anonymizedUrl: req.anonymizedUrl});
+      }
     },
 
     /**
@@ -223,7 +257,12 @@
      */
     _fetchRawJSON(req) {
       const urlWithParams = this._urlWithParams(req.url, req.params);
-      return this._fetch(urlWithParams, req.fetchOptions).then(res => {
+      const fetchReq = {
+        url: urlWithParams,
+        fetchOptions: req.fetchOptions,
+        anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
+      };
+      return this._fetch(fetchReq).then(res => {
         if (req.cancelCondition && req.cancelCondition()) {
           res.body.cancel();
           return;
@@ -324,10 +363,16 @@
 
     getConfig(noCache) {
       if (!noCache) {
-        return this._fetchSharedCacheURL({url: '/config/server/info'});
+        return this._fetchSharedCacheURL({
+          url: '/config/server/info',
+          reportUrlAsIs: true,
+        });
       }
 
-      return this._fetchJSON({url: '/config/server/info'});
+      return this._fetchJSON({
+        url: '/config/server/info',
+        reportUrlAsIs: true,
+      });
     },
 
     getRepo(repo, opt_errFn) {
@@ -336,6 +381,7 @@
       return this._fetchSharedCacheURL({
         url: '/projects/' + encodeURIComponent(repo),
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*',
       });
     },
 
@@ -345,6 +391,7 @@
       return this._fetchSharedCacheURL({
         url: '/projects/' + encodeURIComponent(repo) + '/config',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/config',
       });
     },
 
@@ -353,6 +400,7 @@
       // supports it.
       return this._fetchSharedCacheURL({
         url: '/access/?project=' + encodeURIComponent(repo),
+        anonymizedUrl: '/access/?project=*',
       });
     },
 
@@ -362,6 +410,7 @@
       return this._fetchSharedCacheURL({
         url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/dashboards?inherited',
       });
     },
 
@@ -374,6 +423,7 @@
         url: `/projects/${encodeName}/config`,
         body: config,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/config',
       });
     },
 
@@ -387,6 +437,7 @@
         url: `/projects/${encodeName}/gc`,
         body: '',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/gc',
       });
     },
 
@@ -404,6 +455,7 @@
         url: `/projects/${encodeName}`,
         body: config,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*',
       });
     },
 
@@ -419,6 +471,7 @@
         url: `/groups/${encodeName}`,
         body: config,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*',
       });
     },
 
@@ -426,6 +479,7 @@
       return this._fetchJSON({
         url: `/groups/${encodeURIComponent(group)}/detail`,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/detail',
       });
     },
 
@@ -445,6 +499,7 @@
         url: `/projects/${encodeName}/branches/${encodeRef}`,
         body: '',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/branches/*',
       });
     },
 
@@ -464,6 +519,7 @@
         url: `/projects/${encodeName}/tags/${encodeRef}`,
         body: '',
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/tags/*',
       });
     },
 
@@ -484,6 +540,7 @@
         url: `/projects/${encodeName}/branches/${encodeBranch}`,
         body: revision,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/branches/*',
       });
     },
 
@@ -504,6 +561,7 @@
         url: `/projects/${encodeName}/tags/${encodeTag}`,
         body: revision,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/tags/*',
       });
     },
 
@@ -513,7 +571,11 @@
      */
     getIsGroupOwner(groupName) {
       const encodeName = encodeURIComponent(groupName);
-      return this._fetchSharedCacheURL({url: `/groups/?owned&q=${encodeName}`})
+      const req = {
+        url: `/groups/?owned&q=${encodeName}`,
+        anonymizedUrl: '/groups/owned&q=*',
+      };
+      return this._fetchSharedCacheURL(req)
           .then(configs => configs.hasOwnProperty(groupName));
     },
 
@@ -522,12 +584,15 @@
       return this._fetchJSON({
         url: `/groups/${encodeName}/members/`,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/members',
       });
     },
 
     getIncludedGroup(groupName) {
-      const encodeName = encodeURIComponent(groupName);
-      return this._fetchJSON({url: `/groups/${encodeName}/groups/`});
+      return this._fetchJSON({
+        url: `/groups/${encodeURIComponent(groupName)}/groups/`,
+        anonymizedUrl: '/groups/*/groups',
+      });
     },
 
     saveGroupName(groupId, name) {
@@ -536,6 +601,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/name`,
         body: {name},
+        anonymizedUrl: '/groups/*/name',
       });
     },
 
@@ -545,6 +611,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/owner`,
         body: {owner: ownerId},
+        anonymizedUrl: '/groups/*/owner',
       });
     },
 
@@ -554,6 +621,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/description`,
         body: {description},
+        anonymizedUrl: '/groups/*/description',
       });
     },
 
@@ -563,6 +631,7 @@
         method: 'PUT',
         url: `/groups/${encodeId}/options`,
         body: options,
+        anonymizedUrl: '/groups/*/options',
       });
     },
 
@@ -570,6 +639,7 @@
       return this._fetchSharedCacheURL({
         url: '/groups/' + group + '/log.audit',
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/log.audit',
       });
     },
 
@@ -580,6 +650,7 @@
         method: 'PUT',
         url: `/groups/${encodeName}/members/${encodeMember}`,
         parseResponse: true,
+        anonymizedUrl: '/groups/*/members/*',
       });
     },
 
@@ -590,6 +661,7 @@
         method: 'PUT',
         url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
         errFn: opt_errFn,
+        anonymizedUrl: '/groups/*/groups/*',
       };
       return this._send(req).then(response => {
         if (response.ok) {
@@ -604,6 +676,7 @@
       return this._send({
         method: 'DELETE',
         url: `/groups/${encodeName}/members/${encodeMember}`,
+        anonymizedUrl: '/groups/*/members/*',
       });
     },
 
@@ -613,11 +686,15 @@
       return this._send({
         method: 'DELETE',
         url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+        anonymizedUrl: '/groups/*/groups/*',
       });
     },
 
     getVersion() {
-      return this._fetchSharedCacheURL({url: '/config/server/version'});
+      return this._fetchSharedCacheURL({
+        url: '/config/server/version',
+        reportUrlAsIs: true,
+      });
     },
 
     getDiffPreferences() {
@@ -625,6 +702,7 @@
         if (loggedIn) {
           return this._fetchSharedCacheURL({
             url: '/accounts/self/preferences.diff',
+            reportUrlAsIs: true,
           });
         }
         // These defaults should match the defaults in
@@ -655,6 +733,7 @@
         if (loggedIn) {
           return this._fetchSharedCacheURL({
             url: '/accounts/self/preferences.edit',
+            reportUrlAsIs: true,
           });
         }
         // These defaults should match the defaults in
@@ -696,6 +775,7 @@
         url: '/accounts/self/preferences',
         body: prefs,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -711,6 +791,7 @@
         url: '/accounts/self/preferences.diff',
         body: prefs,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -726,12 +807,14 @@
         url: '/accounts/self/preferences.edit',
         body: prefs,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
     getAccount() {
       return this._fetchSharedCacheURL({
         url: '/accounts/self/detail',
+        reportUrlAsIs: true,
         errFn: resp => {
           if (!resp || resp.status === 403) {
             this._cache['/accounts/self/detail'] = null;
@@ -741,7 +824,10 @@
     },
 
     getExternalIds() {
-      return this._fetchJSON({url: '/accounts/self/external.ids'});
+      return this._fetchJSON({
+        url: '/accounts/self/external.ids',
+        reportUrlAsIs: true,
+      });
     },
 
     deleteAccountIdentity(id) {
@@ -750,6 +836,7 @@
         url: '/accounts/self/external.ids:delete',
         body: id,
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -760,11 +847,15 @@
     getAccountDetails(userId) {
       return this._fetchJSON({
         url: `/accounts/${encodeURIComponent(userId)}/detail`,
+        anonymizedUrl: '/accounts/*/detail',
       });
     },
 
     getAccountEmails() {
-      return this._fetchSharedCacheURL({url: '/accounts/self/emails'});
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/emails',
+        reportUrlAsIs: true,
+      });
     },
 
     /**
@@ -776,6 +867,7 @@
         method: 'PUT',
         url: '/accounts/self/emails/' + encodeURIComponent(email),
         errFn: opt_errFn,
+        anonymizedUrl: '/account/self/emails/*',
       });
     },
 
@@ -788,6 +880,7 @@
         method: 'DELETE',
         url: '/accounts/self/emails/' + encodeURIComponent(email),
         errFn: opt_errFn,
+        anonymizedUrl: '/accounts/self/email/*',
       });
     },
 
@@ -797,8 +890,13 @@
      */
     setPreferredAccountEmail(email, opt_errFn) {
       const encodedEmail = encodeURIComponent(email);
-      const url = `/accounts/self/emails/${encodedEmail}/preferred`;
-      return this._send({method: 'PUT', url, errFn: opt_errFn}).then(() => {
+      const req = {
+        method: 'PUT',
+        url: `/accounts/self/emails/${encodedEmail}/preferred`,
+        errFn: opt_errFn,
+        anonymizedUrl: '/accounts/self/emails/*/preferred',
+      };
+      return this._send(req).then(() => {
         // If result of getAccountEmails is in cache, update it in the cache
         // so we don't have to invalidate it.
         const cachedEmails = this._cache['/accounts/self/emails'];
@@ -840,6 +938,7 @@
         body: {name},
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(newName => this._updateCachedAccount({name: newName}));
@@ -856,6 +955,7 @@
         body: {username},
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(newName => this._updateCachedAccount({username: newName}));
@@ -872,6 +972,7 @@
         body: {status},
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(newStatus => this._updateCachedAccount({status: newStatus}));
@@ -880,15 +981,22 @@
     getAccountStatus(userId) {
       return this._fetchJSON({
         url: `/accounts/${encodeURIComponent(userId)}/status`,
+        anonymizedUrl: '/accounts/*/status',
       });
     },
 
     getAccountGroups() {
-      return this._fetchJSON({url: '/accounts/self/groups'});
+      return this._fetchJSON({
+        url: '/accounts/self/groups',
+        reportUrlAsIs: true,
+      });
     },
 
     getAccountAgreements() {
-      return this._fetchJSON({url: '/accounts/self/agreements'});
+      return this._fetchJSON({
+        url: '/accounts/self/agreements',
+        reportUrlAsIs: true,
+      });
     },
 
     saveAccountAgreement(name) {
@@ -896,6 +1004,7 @@
         method: 'PUT',
         url: '/accounts/self/agreements',
         body: name,
+        reportUrlAsIs: true,
       });
     },
 
@@ -911,6 +1020,7 @@
       }
       return this._fetchSharedCacheURL({
         url: '/accounts/self/capabilities' + queryString,
+        anonymizedUrl: '/accounts/self/capabilities?q=*',
       });
     },
 
@@ -937,8 +1047,9 @@
         return;
       }
       this._credentialCheck.checking = true;
+      const req = {url: '/accounts/self/detail', reportUrlAsIs: true};
       // Skip the REST response cache.
-      return this._fetchRawJSON({url: '/accounts/self/detail'}).then(res => {
+      return this._fetchRawJSON(req).then(res => {
         if (!res) { return; }
         if (res.status === 403) {
           this.fire('auth-error');
@@ -958,21 +1069,24 @@
     },
 
     getDefaultPreferences() {
-      return this._fetchSharedCacheURL({url: '/config/server/preferences'});
+      return this._fetchSharedCacheURL({
+        url: '/config/server/preferences',
+        reportUrlAsIs: true,
+      });
     },
 
     getPreferences() {
       return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
-          return this._fetchSharedCacheURL({url: '/accounts/self/preferences'})
-              .then(res => {
-                if (this._isNarrowScreen()) {
-                  res.default_diff_view = DiffViewMode.UNIFIED;
-                } else {
-                  res.default_diff_view = res.diff_view;
-                }
-                return Promise.resolve(res);
-              });
+          const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
+          return this._fetchSharedCacheURL(req).then(res => {
+            if (this._isNarrowScreen()) {
+              res.default_diff_view = DiffViewMode.UNIFIED;
+            } else {
+              res.default_diff_view = res.diff_view;
+            }
+            return Promise.resolve(res);
+          });
         }
 
         return Promise.resolve({
@@ -988,6 +1102,7 @@
     getWatchedProjects() {
       return this._fetchSharedCacheURL({
         url: '/accounts/self/watched.projects',
+        reportUrlAsIs: true,
       });
     },
 
@@ -1002,6 +1117,7 @@
         body: projects,
         errFn: opt_errFn,
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1015,6 +1131,7 @@
         url: '/accounts/self/watched.projects:delete',
         body: projects,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1079,7 +1196,12 @@
           this._maybeInsertInLookup(change);
         }
       };
-      return this._fetchJSON({url: '/changes/', params}).then(response => {
+      const req = {
+        url: '/changes/',
+        params,
+        reportUrlAsIs: true,
+      };
+      return this._fetchJSON(req).then(response => {
         // Response may be an array of changes OR an array of arrays of
         // changes.
         if (opt_query instanceof Array) {
@@ -1173,6 +1295,7 @@
           cancelCondition: opt_cancelCondition,
           params: {O: params},
           fetchOptions: this._etags.getOptions(urlWithParams),
+          anonymizedUrl: '/changes/*~*/detail?O=' + params,
         };
         return this._fetchRawJSON(req).then(response => {
           if (response && response.status === 304) {
@@ -1213,6 +1336,7 @@
         changeNum,
         endpoint: '/commit?links',
         patchNum,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1233,6 +1357,7 @@
         endpoint: '/files',
         patchNum: patchRange.patchNum,
         params,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1242,10 +1367,16 @@
      */
     getChangeEditFiles(changeNum, patchRange) {
       let endpoint = '/edit?list';
+      let anonymizedEndpoint = endpoint;
       if (patchRange.basePatchNum !== 'PARENT') {
         endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + '');
+        anonymizedEndpoint += '&base=*';
       }
-      return this._getChangeURLAndFetch({changeNum, endpoint});
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint,
+        anonymizedEndpoint,
+      });
     },
 
     /**
@@ -1259,6 +1390,7 @@
         changeNum,
         endpoint: `/files?q=${encodeURIComponent(query)}`,
         patchNum,
+        anonymizedEndpoint: '/files?q=*',
       });
     },
 
@@ -1287,7 +1419,12 @@
     },
 
     getChangeRevisionActions(changeNum, patchNum) {
-      const req = {changeNum, endpoint: '/actions', patchNum};
+      const req = {
+        changeNum,
+        endpoint: '/actions',
+        patchNum,
+        reportEndpointAsIs: true,
+      };
       return this._getChangeURLAndFetch(req).then(revisionActions => {
         // The rebase button on change screen is always enabled.
         if (revisionActions.rebase) {
@@ -1312,6 +1449,7 @@
         endpoint: '/suggest_reviewers',
         errFn: opt_errFn,
         params,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1319,7 +1457,11 @@
      * @param {number|string} changeNum
      */
     getChangeIncludedIn(changeNum) {
-      return this._getChangeURLAndFetch({changeNum, endpoint: '/in'});
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/in',
+        reportEndpointAsIs: true,
+      });
     },
 
     _computeFilter(filter) {
@@ -1345,6 +1487,7 @@
       return this._fetchSharedCacheURL({
         url: `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
             this._computeFilter(filter),
+        anonymizedUrl: '/groups/?*',
       });
     },
 
@@ -1362,6 +1505,7 @@
       return this._fetchSharedCacheURL({
         url: `/projects/?d&n=${reposPerPage + 1}&S=${offset}` +
             this._computeFilter(filter),
+        anonymizedUrl: '/projects/?*',
       });
     },
 
@@ -1372,6 +1516,7 @@
         method: 'PUT',
         url: `/projects/${encodeURIComponent(repo)}/HEAD`,
         body: {ref},
+        anonymizedUrl: '/projects/*/HEAD',
       });
     },
 
@@ -1391,7 +1536,11 @@
       const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchJSON({url, errFn: opt_errFn});
+      return this._fetchJSON({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/branches?*',
+      });
     },
 
     /**
@@ -1411,7 +1560,11 @@
           encodedFilter;
       // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
       // supports it.
-      return this._fetchJSON({url, errFn: opt_errFn});
+      return this._fetchJSON({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/tags',
+      });
     },
 
     /**
@@ -1426,7 +1579,11 @@
       const encodedFilter = this._computeFilter(filter);
       const n = pluginsPerPage + 1;
       const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
-      return this._fetchJSON({url, errFn: opt_errFn});
+      return this._fetchJSON({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/plugins/?all',
+      });
     },
 
     getRepoAccessRights(repoName, opt_errFn) {
@@ -1435,6 +1592,7 @@
       return this._fetchJSON({
         url: `/projects/${encodeURIComponent(repoName)}/access`,
         errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/access',
       });
     },
 
@@ -1445,6 +1603,7 @@
         method: 'POST',
         url: `/projects/${encodeURIComponent(repoName)}/access`,
         body: repoInfo,
+        anonymizedUrl: '/projects/*/access',
       });
     },
 
@@ -1454,6 +1613,7 @@
         url: `/projects/${encodeURIComponent(projectName)}/access:review`,
         body: projectInfo,
         parseResponse: true,
+        anonymizedUrl: '/projects/*/access:review',
       });
     },
 
@@ -1469,6 +1629,7 @@
         url: '/groups/',
         errFn: opt_errFn,
         params,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1488,6 +1649,7 @@
         url: '/projects/',
         errFn: opt_errFn,
         params,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1506,6 +1668,7 @@
         url: '/accounts/',
         errFn: opt_errFn,
         params,
+        anonymizedUrl: '/accounts/?n=*',
       });
     },
 
@@ -1541,6 +1704,7 @@
         changeNum,
         endpoint: '/related',
         patchNum,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1548,6 +1712,7 @@
       return this._getChangeURLAndFetch({
         changeNum,
         endpoint: '/submitted_together',
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1560,7 +1725,11 @@
         O: options,
         q: 'status:open is:mergeable conflicts:' + changeNum,
       };
-      return this._fetchJSON({url: '/changes/', params});
+      return this._fetchJSON({
+        url: '/changes/',
+        params,
+        anonymizedUrl: '/changes/conflicts:*',
+      });
     },
 
     getChangeCherryPicks(project, changeID, changeNum) {
@@ -1578,7 +1747,11 @@
         O: options,
         q: query,
       };
-      return this._fetchJSON({url: '/changes/', params});
+      return this._fetchJSON({
+        url: '/changes/',
+        params,
+        anonymizedUrl: '/changes/change:*',
+      });
     },
 
     getChangesWithSameTopic(topic) {
@@ -1592,7 +1765,11 @@
         O: options,
         q: 'status:open topic:' + topic,
       };
-      return this._fetchJSON({url: '/changes/', params});
+      return this._fetchJSON({
+        url: '/changes/',
+        params,
+        anonymizedUrl: '/changes/topic:*',
+      });
     },
 
     getReviewedFiles(changeNum, patchNum) {
@@ -1600,6 +1777,7 @@
         changeNum,
         endpoint: '/files?reviewed',
         patchNum,
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1617,6 +1795,7 @@
         patchNum,
         endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
         errFn: opt_errFn,
+        anonymizedEndpoint: '/files/*/reviewed',
       });
     },
 
@@ -1649,6 +1828,7 @@
           changeNum,
           endpoint: '/edit/',
           params,
+          reportEndpointAsIs: true,
         });
       });
     },
@@ -1679,6 +1859,7 @@
           base_commit: opt_baseCommit,
         },
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -1725,6 +1906,7 @@
         endpoint: `/files/${encodeURIComponent(path)}/content`,
         errFn: opt_errFn,
         headers: {Accept: 'application/json'},
+        anonymizedEndpoint: '/files/*/content',
       });
     },
 
@@ -1739,6 +1921,7 @@
         method: 'GET',
         endpoint: '/edit/' + encodeURIComponent(path),
         headers: {Accept: 'application/json'},
+        anonymizedEndpoint: '/edit/*',
       });
     },
 
@@ -1747,6 +1930,7 @@
         changeNum,
         method: 'POST',
         endpoint: '/edit:rebase',
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1755,6 +1939,7 @@
         changeNum,
         method: 'DELETE',
         endpoint: '/edit',
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1764,6 +1949,7 @@
         method: 'POST',
         endpoint: '/edit',
         body: {restore_path},
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1773,6 +1959,7 @@
         method: 'POST',
         endpoint: '/edit',
         body: {old_path, new_path},
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1781,6 +1968,7 @@
         changeNum,
         method: 'DELETE',
         endpoint: '/edit/' + encodeURIComponent(path),
+        anonymizedEndpoint: '/edit/*',
       });
     },
 
@@ -1791,6 +1979,7 @@
         endpoint: '/edit/' + encodeURIComponent(path),
         body: contents,
         contentType: 'text/plain',
+        anonymizedEndpoint: '/edit/*',
       });
     },
 
@@ -1801,6 +1990,7 @@
         method: 'PUT',
         endpoint: '/edit:message',
         body: {message},
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1809,6 +1999,7 @@
         changeNum,
         method: 'POST',
         endpoint: '/edit:publish',
+        reportEndpointAsIs: true,
       });
     },
 
@@ -1818,13 +2009,16 @@
         method: 'PUT',
         endpoint: '/message',
         body: {message},
+        reportEndpointAsIs: true,
       });
     },
 
     saveChangeStarred(changeNum, starred) {
-      const url = '/accounts/self/starred.changes/' + changeNum;
-      const method = starred ? 'PUT' : 'DELETE';
-      return this._send({method, url});
+      return this._send({
+        method: starred ? 'PUT' : 'DELETE',
+        url: '/accounts/self/starred.changes/' + changeNum,
+        anonymizedUrl: '/accounts/self/starred.changes/*',
+      });
     },
 
     /**
@@ -1850,7 +2044,12 @@
       }
       const url = req.url.startsWith('http') ?
           req.url : this.getBaseUrl() + req.url;
-      const xhr = this._fetch(url, options).then(response => {
+      const fetchReq = {
+        url,
+        fetchOptions: options,
+        anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
+      };
+      const xhr = this._fetch(fetchReq).then(response => {
         if (!response.ok) {
           if (req.errFn) {
             return req.errFn.call(undefined, response);
@@ -1928,6 +2127,7 @@
         errFn: opt_errFn,
         cancelCondition: opt_cancelCondition,
         params,
+        anonymizedEndpoint: '/files/*/diff',
       });
     },
 
@@ -2019,6 +2219,7 @@
           changeNum,
           endpoint,
           patchNum: opt_patchNum,
+          reportEndpointAsIs: true,
         });
       };
 
@@ -2109,8 +2310,10 @@
     _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
       const isCreate = !draft.id && method === 'PUT';
       let endpoint = '/drafts';
+      let anonymizedEndpoint = endpoint;
       if (draft.id) {
         endpoint += '/' + draft.id;
+        anonymizedEndpoint += '/*';
       }
       let body;
       if (method === 'PUT') {
@@ -2121,8 +2324,16 @@
         this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
       }
 
-      const promise = this._getChangeURLAndSend(
-          {changeNum, method, patchNum, endpoint, body});
+      const req = {
+        changeNum,
+        method,
+        patchNum,
+        endpoint,
+        body,
+        anonymizedEndpoint,
+      };
+
+      const promise = this._getChangeURLAndSend(req);
       this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
 
       if (isCreate) {
@@ -2136,11 +2347,12 @@
       return this._fetchJSON({
         url: '/projects/' + encodeURIComponent(project) +
             '/commits/' + encodeURIComponent(commit),
+        anonymizedUrl: '/projects/*/comments/*',
       });
     },
 
     _fetchB64File(url) {
-      return this._fetch(this.getBaseUrl() + url)
+      return this._fetch({url: this.getBaseUrl() + url})
           .then(response => {
             if (!response.ok) { return Promise.reject(response.statusText); }
             const type = response.headers.get('X-FYI-Content-Type');
@@ -2241,6 +2453,7 @@
         endpoint: '/topic',
         body: {topic},
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -2256,6 +2469,7 @@
         endpoint: '/hashtags',
         body: hashtag,
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
@@ -2263,6 +2477,7 @@
       return this._send({
         method: 'DELETE',
         url: '/accounts/self/password.http',
+        reportUrlAsIs: true,
       });
     },
 
@@ -2277,11 +2492,15 @@
         url: '/accounts/self/password.http',
         body: {generate: true},
         parseResponse: true,
+        reportUrlAsIs: true,
       });
     },
 
     getAccountSSHKeys() {
-      return this._fetchSharedCacheURL({url: '/accounts/self/sshkeys'});
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/sshkeys',
+        reportUrlAsIs: true,
+      });
     },
 
     addAccountSSHKey(key) {
@@ -2290,6 +2509,7 @@
         url: '/accounts/self/sshkeys',
         body: key,
         contentType: 'plain/text',
+        reportUrlAsIs: true,
       };
       return this._send(req)
           .then(response => {
@@ -2308,15 +2528,24 @@
       return this._send({
         method: 'DELETE',
         url: '/accounts/self/sshkeys/' + id,
+        anonymizedUrl: '/accounts/self/sshkeys/*',
       });
     },
 
     getAccountGPGKeys() {
-      return this._fetchJSON({url: '/accounts/self/gpgkeys'});
+      return this._fetchJSON({
+        url: '/accounts/self/gpgkeys',
+        reportUrlAsIs: true,
+      });
     },
 
     addAccountGPGKey(key) {
-      const req = {method: 'POST', url: '/accounts/self/gpgkeys', body: key};
+      const req = {
+        method: 'POST',
+        url: '/accounts/self/gpgkeys',
+        body: key,
+        reportUrlAsIs: true,
+      };
       return this._send(req)
           .then(response => {
             if (response.status < 200 && response.status >= 300) {
@@ -2334,6 +2563,7 @@
       return this._send({
         method: 'DELETE',
         url: '/accounts/self/gpgkeys/' + id,
+        anonymizedUrl: '/accounts/self/gpgkeys/*',
       });
     },
 
@@ -2342,6 +2572,7 @@
         changeNum,
         method: 'DELETE',
         endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
+        anonymizedEndpoint: '/reviewers/*/votes/*',
       });
     },
 
@@ -2351,6 +2582,7 @@
         method: 'PUT', patchNum,
         endpoint: '/description',
         body: {description: desc},
+        reportUrlAsIs: true,
       });
     },
 
@@ -2359,6 +2591,7 @@
         method: 'PUT',
         url: '/config/server/email.confirm',
         body: {token},
+        reportUrlAsIs: true,
       };
       return this._send(req).then(response => {
         if (response.status === 204) {
@@ -2372,6 +2605,7 @@
       return this._fetchJSON({
         url: '/config/server/capabilities',
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -2381,6 +2615,7 @@
         method: 'PUT',
         endpoint: '/assignee',
         body: {assignee},
+        reportUrlAsIs: true,
       });
     },
 
@@ -2389,6 +2624,7 @@
         changeNum,
         method: 'DELETE',
         endpoint: '/assignee',
+        reportUrlAsIs: true,
       });
     },
 
@@ -2408,7 +2644,13 @@
       if (opt_message) {
         body.message = opt_message;
       }
-      const req = {changeNum, method: 'POST', endpoint: '/wip', body};
+      const req = {
+        changeNum,
+        method: 'POST',
+        endpoint: '/wip',
+        body,
+        reportUrlAsIs: true,
+      };
       return this._getChangeURLAndSend(req).then(response => {
         if (response.status === 204) {
           return 'Change marked as Work In Progress.';
@@ -2428,6 +2670,7 @@
         endpoint: '/ready',
         body: opt_body,
         errFn: opt_errFn,
+        reportUrlAsIs: true,
       });
     },
 
@@ -2444,6 +2687,7 @@
         endpoint: `/comments/${commentID}/delete`,
         body: {reason},
         parseResponse: true,
+        anonymizedEndpoint: '/comments/*/delete',
       });
     },
 
@@ -2459,6 +2703,7 @@
       return this._fetchJSON({
         url: `/changes/?q=change:${changeNum}`,
         errFn: opt_errFn,
+        anonymizedUrl: '/changes/?q=change:*',
       }).then(res => {
         if (!res || !res.length) { return null; }
         return res[0];
@@ -2509,6 +2754,11 @@
      * @return {!Promise<!Object>}
      */
     _getChangeURLAndSend(req) {
+      const anonymizedBaseUrl = req.patchNum ?
+          ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
+      const anonymizedEndpoint = req.reportEndpointAsIs ?
+          req.endpoint : req.anonymizedEndpoint;
+
       return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
         return this._send({
           method: req.method,
@@ -2518,6 +2768,8 @@
           contentType: req.contentType,
           headers: req.headers,
           parseResponse: req.parseResponse,
+          anonymizedUrl: anonymizedEndpoint ?
+              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
         });
       });
     },
@@ -2528,6 +2780,10 @@
      * @return {!Promise<!Object>}
      */
     _getChangeURLAndFetch(req) {
+      const anonymizedEndpoint = req.reportEndpointAsIs ?
+          req.endpoint : req.anonymizedEndpoint;
+      const anonymizedBaseUrl = req.patchNum ?
+          ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
       return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
         return this._fetchJSON({
           url: url + req.endpoint,
@@ -2535,6 +2791,8 @@
           cancelCondition: req.cancelCondition,
           params: req.params,
           fetchOptions: req.fetchOptions,
+          anonymizedUrl: anonymizedEndpoint ?
+              (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
         });
       });
     },
@@ -2577,6 +2835,7 @@
         endpoint: `/files/${encodedPath}/blame`,
         patchNum,
         params: opt_base ? {base: 't'} : undefined,
+        anonymizedEndpoint: '/files/*/blame',
       });
     },
 
@@ -2621,7 +2880,11 @@
     getDashboard(project, dashboard, opt_errFn) {
       const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' +
           encodeURIComponent(dashboard);
-      return this._fetchSharedCacheURL({url, errFn: opt_errFn});
+      return this._fetchSharedCacheURL({
+        url,
+        errFn: opt_errFn,
+        anonymizedUrl: '/projects/*/dashboards/*',
+      });
     },
 
     getMergeable(changeNum) {
@@ -2629,6 +2892,7 @@
         changeNum,
         endpoint: '/revisions/current/mergeable',
         parseResponse: true,
+        reportEndpointAsIs: true,
       });
     },
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 70d1465..193d306 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -711,11 +711,10 @@
       sandbox.spy(element, '_send');
       element.confirmEmail('foo');
       assert.isTrue(element._send.calledOnce);
-      assert.deepEqual(element._send.lastCall.args[0], {
-        method: 'PUT',
-        url: '/config/server/email.confirm',
-        body: {token: 'foo'},
-      });
+      assert.equal(element._send.lastCall.args[0].method, 'PUT');
+      assert.equal(element._send.lastCall.args[0].url,
+          '/config/server/email.confirm');
+      assert.deepEqual(element._send.lastCall.args[0].body, {token: 'foo'});
     });
 
     test('GrReviewerUpdatesParser.parse is used', () => {
@@ -924,11 +923,10 @@
       const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
           .returns(Promise.resolve());
       return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
-        assert.deepEqual(fetchStub.lastCall.args[0], {
-          changeNum: '42',
-          endpoint: '/files?q=test%2Fpath.js',
-          patchNum: 'edit',
-        });
+        assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
+        assert.equal(fetchStub.lastCall.args[0].endpoint,
+            '/files?q=test%2Fpath.js');
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 'edit');
       });
     });
 
@@ -1387,12 +1385,25 @@
       sandbox.stub(element._auth, 'fetch').returns(Promise.resolve(response));
       const startTime = 123;
       sandbox.stub(Date, 'now').returns(startTime);
-      return element._fetch(url, fetchOptions).then(() => {
+      const req = {url, fetchOptions};
+      return element._fetch(req).then(() => {
         assert.isTrue(logStub.calledOnce);
-        assert.isTrue(logStub.calledWith(
-            url, fetchOptions, startTime, response.status));
+        assert.isTrue(logStub.calledWith(req, startTime, response.status));
         assert.isFalse(response.text.called);
       });
     });
+
+    test('_logCall only reports requests with anonymized URLss', () => {
+      sandbox.stub(Date, 'now').returns(200);
+      const handler = sinon.stub();
+      element.addEventListener('rpc-log', handler);
+
+      element._logCall({url: 'url'}, 100, 200);
+      assert.isFalse(handler.called);
+
+      element._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
+      flushAsynchronousOperations();
+      assert.isTrue(handler.calledOnce);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 6e61c8e..0310e58 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -204,6 +204,7 @@
     'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
     'gr-path-list-behavior/gr-path-list-behavior_test.html',
     'gr-tooltip-behavior/gr-tooltip-behavior_test.html',
+    'safe-types-behavior/safe-types-behavior_test.html',
   ];
   /* eslint-enable max-len */
   for (let file of behaviors) {